mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
Merge origin/main into apk-store-fixes, preserving store and headset features
Keep the union of server routes, desktop resources and responsive controls. Preserve OpenXR install defaults and telemetry hooks alongside library artwork. Adapt the resource test to single-file entries and avoid a completed-refresh race in the F-Droid test. Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
commit
8fca0fe0a2
169 files changed
+47085
-292
No files matched your search
+21
-1
@@ -1,7 +1,9 @@
|
||||
// Downloads what the app bundles so users install nothing else: a standalone
|
||||
// Python (python-build-standalone), adb (Android platform-tools) and a CA
|
||||
// bundle. Each goes in build/deps/<os>-<arch>/{python,tools}, which package.json
|
||||
// copies into the app's resources. Everything is pinned by version and SHA-256.
|
||||
// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
|
||||
// packages for every build), into ../frame/kdeconnect/packages as listed in
|
||||
// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
|
||||
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
@@ -127,8 +129,26 @@ async function fetch(os, arch) {
|
||||
console.log(`${key}: ${version} -> ${out}`);
|
||||
}
|
||||
|
||||
// As frame/kdeconnect/fetch.py does for the iPhone app's bundle.
|
||||
async function fetchKdeConnect() {
|
||||
const dir = path.join(__dirname, "..", "..", "frame", "kdeconnect");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "packages.json"), "utf8"));
|
||||
const out = path.join(dir, "packages");
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
for (const p of manifest.packages) {
|
||||
const file = path.join(out, p.file);
|
||||
if (fs.existsSync(file) && sha(file) === p.sha256) continue;
|
||||
await download(manifest.release + p.file, p.sha256, file);
|
||||
}
|
||||
const wanted = new Set(manifest.packages.map((p) => p.file));
|
||||
for (const f of fs.readdirSync(out)) if (!wanted.has(f)) fs.rmSync(path.join(out, f), { recursive: true, force: true });
|
||||
console.log(`KDE Connect for the Frame: ${manifest.packages.length} packages -> ${out}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const [os, ...archs] = process.argv.slice(2);
|
||||
if (!os || !archs.length) throw new Error("usage: node build/fetch-deps.js <mac|win|linux> <arch>...");
|
||||
for (const arch of archs) await fetch(os, arch);
|
||||
await fetchKdeConnect();
|
||||
})().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
+116
-4
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -10,6 +10,7 @@ const net = require("net");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
@@ -114,7 +115,11 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
|
||||
@@ -244,6 +249,25 @@ function fromUi(e) {
|
||||
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
||||
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||
|
||||
ipcMain.handle("comfort:notify", (e, message) => {
|
||||
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
||||
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
||||
return new Promise((resolve, reject) => {
|
||||
const notification = new Notification({title: "Frame Control", body: message});
|
||||
const timer = setTimeout(() => reject(new Error("Notification delivery was not confirmed. Check system notification settings.")), 5000);
|
||||
notification.once("show", () => { clearTimeout(timer); resolve(true); });
|
||||
notification.once("failed", (_event, error) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Notification delivery failed. Check system notification settings: " + error));
|
||||
});
|
||||
notification.show();
|
||||
});
|
||||
});
|
||||
|
||||
// frame-control://install links from websites (docs/web-install.md). They can
|
||||
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||
@@ -276,6 +300,81 @@ ipcMain.on("install-link:ready", (e) => {
|
||||
deliverLinks();
|
||||
});
|
||||
|
||||
// ---- updates (app/updater.js, docs/releasing.md) ----
|
||||
// Checked shortly after launch and every few hours; the page shows a banner and
|
||||
// the Update button calls installUpdate.
|
||||
const UPDATE_EVERY = 6 * 3600 * 1000;
|
||||
const update = { status: "idle", current: app.getVersion(), latest: null, error: null, progress: 0, how: null };
|
||||
|
||||
function publicUpdate() {
|
||||
const r = update.latest;
|
||||
return { status: update.status, current: update.current, error: update.error, progress: update.progress,
|
||||
latest: r && { version: r.version, notes: r.notes, page: r.page },
|
||||
canInstall: !!update.how && update.how.method !== "manual", why: update.how && update.how.why };
|
||||
}
|
||||
|
||||
function setUpdate(fields) {
|
||||
Object.assign(update, fields);
|
||||
if (win && linkPage === win.webContents) win.webContents.send("update:state", publicUpdate());
|
||||
}
|
||||
|
||||
async function checkForUpdate({ manual = false } = {}) {
|
||||
if (["checking", "downloading", "ready"].includes(update.status)) return;
|
||||
setUpdate({ status: "checking", error: null });
|
||||
try {
|
||||
const latest = await updater.latestRelease();
|
||||
const how = updater.updateMethod({ platform: process.platform, isPackaged: app.isPackaged,
|
||||
execPath: process.execPath, env: process.env,
|
||||
exists: fs.existsSync, writable: updater.writable });
|
||||
if (updater.isNewer(latest.version, update.current)) {
|
||||
setUpdate({ status: "available", latest, how });
|
||||
if (manual) offerUpdateDialog();
|
||||
} else {
|
||||
setUpdate({ status: "none", latest, how });
|
||||
if (manual) dialog.showMessageBox(win, { type: "info", message: "Frame Control is up to date",
|
||||
detail: `You have ${update.current}, the newest version.` });
|
||||
}
|
||||
} catch (e) {
|
||||
// A failed check: nothing to install, and never an older release kept from before.
|
||||
setUpdate({ status: "check-failed", error: e.message, latest: null });
|
||||
if (manual) dialog.showMessageBox(win, { type: "warning", message: "Couldn't check for updates", detail: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function offerUpdateDialog() {
|
||||
const r = update.latest;
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info", message: `Frame Control ${r.version} is available`,
|
||||
detail: `You have ${update.current}.` + (update.how.method === "manual" ? ` Download it from the release page (${update.how.why}).` : ""),
|
||||
buttons: [update.how.method === "manual" ? "Open Release Page" : "Update and Restart", "Later"], defaultId: 0, cancelId: 1,
|
||||
});
|
||||
if (response === 0) installUpdate();
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
// "error" here only ever means an install failed, so trying again is safe.
|
||||
if (update.status !== "available" && update.status !== "error") return;
|
||||
if (!update.latest || !updater.isNewer(update.latest.version, update.current)) return;
|
||||
if (!update.how || update.how.method === "manual") { shell.openExternal(update.latest.page); return; }
|
||||
setUpdate({ status: "downloading", progress: 0, error: null });
|
||||
try {
|
||||
const start = await updater.prepare(update.latest, update.how,
|
||||
(done, total) => { if (total) setUpdate({ progress: done / total }); }, update.current);
|
||||
setUpdate({ status: "ready", progress: 1 });
|
||||
start();
|
||||
quitting = true;
|
||||
app.quit();
|
||||
} catch (e) {
|
||||
setUpdate({ status: "error", error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks() {
|
||||
if (process.env.FRAME_CONTROL_NO_UPDATE_CHECK === "1") return;
|
||||
setTimeout(checkForUpdate, 8000);
|
||||
setInterval(checkForUpdate, UPDATE_EVERY).unref();
|
||||
}
|
||||
|
||||
function registerScheme() {
|
||||
// A checkout runs as `electron .`, so the OS must be told the script too.
|
||||
// (macOS takes the scheme from Info.plist, which only the built app has.)
|
||||
@@ -292,7 +391,7 @@ function createWindow() {
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
||||
: { icon: path.join(__dirname, "build", "icon.png") }),
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
|
||||
preload: path.join(__dirname, "preload.js") },
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
@@ -337,7 +436,11 @@ async function setUpConnection() {
|
||||
|
||||
function buildMenu() {
|
||||
const template = [
|
||||
...(IS_MAC ? [{ role: "appMenu" }] : []),
|
||||
...(IS_MAC ? [{ label: app.name, submenu: [
|
||||
{ role: "about" }, { label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) },
|
||||
{ type: "separator" }, { role: "services" }, { type: "separator" },
|
||||
{ role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" },
|
||||
] }] : []),
|
||||
{ role: "fileMenu" },
|
||||
{ role: "editMenu" },
|
||||
{
|
||||
@@ -364,7 +467,15 @@ function buildMenu() {
|
||||
...(IS_MAC ? [{ role: "windowMenu" }] : []),
|
||||
{
|
||||
role: "help",
|
||||
submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
|
||||
submenu: [
|
||||
...(IS_MAC ? [] : [{ label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) }]),
|
||||
{ label: "Report a Problem…", click: () => {
|
||||
if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
|
||||
else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
|
||||
} },
|
||||
{ label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
|
||||
{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") },
|
||||
],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
@@ -387,6 +498,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
registerScheme();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
scheduleUpdateChecks();
|
||||
});
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+35
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -9,8 +9,8 @@
|
||||
"scripts": {
|
||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
|
||||
},
|
||||
@@ -37,6 +37,7 @@
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
@@ -47,7 +48,8 @@
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html",
|
||||
"*.js"
|
||||
"*.js",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -90,6 +92,16 @@
|
||||
"!**/__pycache__/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../frame/kdeconnect",
|
||||
"to": "frame/kdeconnect",
|
||||
"filter": [
|
||||
"packages.json",
|
||||
"NOTICE.md",
|
||||
"LICENSES/**/*",
|
||||
"packages/*.pkg.tar.zst"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../apk-catalog",
|
||||
"to": "apk-catalog",
|
||||
@@ -112,6 +124,14 @@
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../THIRD_PARTY_NOTICES.md",
|
||||
"to": "THIRD_PARTY_NOTICES.md"
|
||||
},
|
||||
{
|
||||
"from": "../LICENSE",
|
||||
"to": "LICENSE"
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
@@ -123,9 +143,19 @@
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "../mac/bin",
|
||||
"to": "mac/bin",
|
||||
"filter": [
|
||||
"frame-mac-view"
|
||||
]
|
||||
}
|
||||
],
|
||||
"extendInfo": {
|
||||
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
|
||||
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
|
||||
"NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network.",
|
||||
"NSScreenCaptureUsageDescription": "Frame Control shows your Mac's windows and screens inside the Steam Frame when you ask it to."
|
||||
},
|
||||
"artifactName": "Frame-Control-mac-${arch}.${ext}"
|
||||
},
|
||||
|
||||
@@ -5,12 +5,31 @@
|
||||
// It can open Set Up Connection when the headset can't be reached.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
// And it passes update state both ways: see app/updater.js.
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("frameApp", {
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
||||
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
// Updates (app/updater.js): the page shows a banner and an Update button.
|
||||
update: {
|
||||
get: () => ipcRenderer.invoke("update:get"),
|
||||
check: () => ipcRenderer.invoke("update:check"),
|
||||
install: () => ipcRenderer.invoke("update:install"),
|
||||
onState: (cb) => {
|
||||
ipcRenderer.removeAllListeners("update:state");
|
||||
ipcRenderer.on("update:state", (_e, s) => cb(s));
|
||||
},
|
||||
},
|
||||
// Help → Report a Problem… opens the page's report dialog (ui/frame_report.py).
|
||||
onReportProblem: (cb) => {
|
||||
ipcRenderer.removeAllListeners("report:open");
|
||||
ipcRenderer.on("report:open", () => cb());
|
||||
},
|
||||
onInstallLink: (cb) => {
|
||||
ipcRenderer.removeAllListeners("install-link");
|
||||
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Run: node --test app/test/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { isNewer, assetName, updateMethod, macBundle } = require("../updater");
|
||||
|
||||
test("versions compare numerically, and a release beats its pre-releases", () => {
|
||||
assert.ok(isNewer("0.3.10", "0.3.9"));
|
||||
assert.ok(isNewer("v1.0.0", "0.9.9"));
|
||||
assert.ok(!isNewer("0.3.1", "0.3.1"));
|
||||
assert.ok(!isNewer("0.3.0", "0.3.1"));
|
||||
assert.ok(isNewer("1.0.0", "1.0.0-beta.1"));
|
||||
assert.ok(!isNewer("1.0.0-beta.1", "1.0.0"));
|
||||
assert.ok(!isNewer("garbage", "0.1.0"));
|
||||
});
|
||||
|
||||
test("asset names match what electron-builder publishes", () => {
|
||||
assert.strictEqual(assetName("darwin", "arm64", "mac-zip"), "Frame-Control-mac-arm64.zip");
|
||||
assert.strictEqual(assetName("win32", "x64", "nsis"), "Frame-Control-Setup-x64.exe");
|
||||
assert.strictEqual(assetName("linux", "x64", "appimage"), "Frame-Control-linux-x86_64.AppImage");
|
||||
assert.strictEqual(assetName("linux", "arm64", "appimage"), "Frame-Control-linux-arm64.AppImage");
|
||||
});
|
||||
|
||||
const base = { isPackaged: true, env: {}, exists: () => false, writable: () => true };
|
||||
|
||||
test("macOS updates in place only from a writable, non-translocated location", () => {
|
||||
const exe = "/Applications/Frame Control.app/Contents/MacOS/Frame Control";
|
||||
assert.strictEqual(macBundle(exe), "/Applications/Frame Control.app");
|
||||
assert.deepStrictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe }),
|
||||
{ method: "mac-zip", bundle: "/Applications/Frame Control.app" });
|
||||
const dmg = "/Volumes/Frame Control 0.3.1/Frame Control.app/Contents/MacOS/Frame Control";
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: dmg }).method, "manual");
|
||||
const trans = "/private/var/folders/x/AppTranslocation/ABC/d/Frame Control.app/Contents/MacOS/Frame Control";
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: trans }).method, "manual");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe, writable: () => false }).method, "manual");
|
||||
});
|
||||
|
||||
test("Windows needs the installer's copy; Linux needs an AppImage", () => {
|
||||
const exe = "C:\\Users\\a\\AppData\\Local\\Programs\\Frame Control\\Frame Control.exe";
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe, exists: () => true }).method, "nsis");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe }).method, "manual");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/x", env: { APPIMAGE: "/home/a/F.AppImage" } }).method,
|
||||
"appimage");
|
||||
assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/Frame Control/frame-control" }).method, "manual");
|
||||
assert.strictEqual(updateMethod({ ...base, isPackaged: false, platform: "darwin", execPath: "x" }).method, "manual");
|
||||
});
|
||||
|
||||
test("update.json assets always download from this repository's release", () => {
|
||||
const r = require("../updater").fromManifest({ version: "0.4.0", notes: "n", assets: [
|
||||
{ name: "Frame-Control-mac-arm64.zip", url: "https://evil.example/x.zip", digest: "sha256:" + "a".repeat(64) }] });
|
||||
assert.strictEqual(r.assets[0].url, "https://github.com/saphid/frame-control/releases/download/v0.4.0/Frame-Control-mac-arm64.zip");
|
||||
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
|
||||
});
|
||||
|
||||
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
|
||||
const { prepare } = require("../updater");
|
||||
const release = { version: "0.3.1", assets: [] };
|
||||
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.4.0"), /isn't newer/);
|
||||
await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.3.1"), /isn't newer/);
|
||||
});
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
// Update checks and self-update for the desktop app (docs/releasing.md).
|
||||
//
|
||||
// The newest version is GitHub's "latest" release of saphid/frame-control. Drafts
|
||||
// and pre-releases never count, so a build reaches people only when the
|
||||
// maintainer publishes it after testing (scripts/publish-release.sh). That
|
||||
// script attaches update.json (version, notes, each asset's SHA-256), read
|
||||
// through github.com's latest/download link: the REST API allows only 60
|
||||
// unauthenticated requests an hour per IP address, shared by everyone behind
|
||||
// the same router, so it's only the fallback.
|
||||
//
|
||||
// Every download is checked against the SHA-256 digest GitHub records for the
|
||||
// asset before anything is replaced. How the update is applied:
|
||||
// macOS the .zip: unpacked next to the running app, swapped in by a small
|
||||
// script once the app has quit, then reopened.
|
||||
// Windows the NSIS installer, run silently over the current install; it
|
||||
// reopens the app. A copy unpacked from the .zip is updated by hand.
|
||||
// Linux the AppImage replaces itself; .deb installs are updated by hand.
|
||||
// When the app can't update itself it opens the release page instead.
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const REPO = "saphid/frame-control"; // renamed from saphid/steam-frame; GitHub redirects the old name
|
||||
const LATEST = `https://api.github.com/repos/${REPO}/releases/latest`;
|
||||
const MANIFEST = `https://github.com/${REPO}/releases/latest/download/update.json`;
|
||||
const RELEASES = `https://github.com/${REPO}/releases`;
|
||||
|
||||
// "0.3.1" or "v0.3.1" -> [0, 3, 1]; pre-release suffixes sort before the release.
|
||||
function parseVersion(v) {
|
||||
const m = String(v || "").trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
||||
return m ? { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null } : null;
|
||||
}
|
||||
|
||||
function isNewer(candidate, current) {
|
||||
const a = parseVersion(candidate), b = parseVersion(current);
|
||||
if (!a || !b) return false;
|
||||
for (let i = 0; i < 3; i++) if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
|
||||
if (a.pre === b.pre) return false;
|
||||
if (!a.pre) return true; // 1.0.0 is newer than 1.0.0-beta
|
||||
if (!b.pre) return false;
|
||||
return a.pre > b.pre;
|
||||
}
|
||||
|
||||
// The asset this copy of the app updates from, by the names electron-builder gives them.
|
||||
function assetName(platform, arch, method) {
|
||||
if (method === "mac-zip") return `Frame-Control-mac-${arch}.zip`;
|
||||
if (method === "nsis") return `Frame-Control-Setup-${arch}.exe`;
|
||||
if (method === "appimage") return `Frame-Control-linux-${arch === "x64" ? "x86_64" : arch}.AppImage`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// How this copy can update itself: mac-zip, nsis, appimage, or manual (with why).
|
||||
function updateMethod({ platform, isPackaged, execPath, env, exists, writable }) {
|
||||
if (!isPackaged) return { method: "manual", why: "running from a source checkout" };
|
||||
if (platform === "darwin") {
|
||||
const bundle = macBundle(execPath);
|
||||
if (!bundle) return { method: "manual", why: "can't find the app bundle" };
|
||||
if (bundle.includes("/AppTranslocation/") || bundle.startsWith("/Volumes/")) {
|
||||
return { method: "manual", why: "move Frame Control to Applications first" };
|
||||
}
|
||||
if (!writable(path.dirname(bundle))) return { method: "manual", why: `${path.dirname(bundle)} isn't writable` };
|
||||
return { method: "mac-zip", bundle };
|
||||
}
|
||||
if (platform === "win32") {
|
||||
// electron-builder's NSIS install puts its uninstaller next to the app.
|
||||
const dir = path.dirname(execPath);
|
||||
if (exists(path.join(dir, "Uninstall Frame Control.exe"))) return { method: "nsis" };
|
||||
return { method: "manual", why: "not installed with the installer" };
|
||||
}
|
||||
if (platform === "linux" && env.APPIMAGE) {
|
||||
if (!writable(path.dirname(env.APPIMAGE))) return { method: "manual", why: "the AppImage's folder isn't writable" };
|
||||
return { method: "appimage", appImage: env.APPIMAGE };
|
||||
}
|
||||
return { method: "manual", why: "installed from a package" };
|
||||
}
|
||||
|
||||
function macBundle(execPath) {
|
||||
const i = execPath.indexOf(".app/Contents/MacOS/");
|
||||
return i < 0 ? null : execPath.slice(0, i + 4);
|
||||
}
|
||||
|
||||
function get(url, { headers = {}, timeout = 20000, redirects = 5 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { headers: { "user-agent": "FrameControl-updater", ...headers }, timeout }, (res) => {
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirects > 0) {
|
||||
res.resume();
|
||||
const next = new URL(res.headers.location, url);
|
||||
if (next.protocol !== "https:") return reject(new Error("refusing a non-HTTPS redirect"));
|
||||
return resolve(get(next.href, { headers, timeout, redirects: redirects - 1 }));
|
||||
}
|
||||
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} from ${new URL(url).host}`)); }
|
||||
resolve(res);
|
||||
});
|
||||
req.on("timeout", () => req.destroy(new Error("timed out")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function getJson(url, headers) {
|
||||
const res = await get(url, { headers });
|
||||
let body = "";
|
||||
for await (const chunk of res) body += chunk;
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
// update.json and the API's release both become { version, notes, page, assets }.
|
||||
function fromManifest(m) {
|
||||
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
|
||||
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
|
||||
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
|
||||
page: m.page || RELEASES,
|
||||
// Assets always come from this repository's release, whatever the manifest says.
|
||||
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
|
||||
size: a.size, digest: a.digest || null })) };
|
||||
}
|
||||
|
||||
function fromApi(r) {
|
||||
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
|
||||
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
|
||||
page: r.html_url || RELEASES,
|
||||
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
|
||||
digest: a.digest || null })) };
|
||||
}
|
||||
|
||||
async function latestRelease() {
|
||||
try {
|
||||
return fromManifest(await getJson(MANIFEST));
|
||||
} catch (e) {
|
||||
if (!/HTTP 404/.test(e.message)) throw e; // releases before update.json existed
|
||||
}
|
||||
return fromApi(await getJson(LATEST, { accept: "application/vnd.github+json" }));
|
||||
}
|
||||
|
||||
async function download(asset, dest, onProgress) {
|
||||
const m = /^sha256:([0-9a-f]{64})$/.exec(asset.digest || "");
|
||||
if (!m) throw new Error(`GitHub has no SHA-256 for ${asset.name}, so it can't be checked`);
|
||||
const res = await get(asset.url, { timeout: 60000 });
|
||||
const total = +res.headers["content-length"] || asset.size || 0;
|
||||
const hash = crypto.createHash("sha256");
|
||||
// "wx": a new file only, never through an existing file or symlink at that path.
|
||||
const out = fs.createWriteStream(dest, { mode: 0o755, flags: "wx" });
|
||||
let done = 0;
|
||||
await new Promise((resolve, reject) => {
|
||||
res.on("data", (chunk) => { hash.update(chunk); done += chunk.length; onProgress && onProgress(done, total); });
|
||||
res.on("error", reject);
|
||||
out.on("error", reject);
|
||||
out.on("finish", resolve);
|
||||
res.pipe(out);
|
||||
});
|
||||
if (hash.digest("hex") !== m[1]) {
|
||||
fs.rmSync(dest, { force: true });
|
||||
throw new Error(`${asset.name} didn't match its SHA-256; nothing was changed`);
|
||||
}
|
||||
}
|
||||
|
||||
const run = (cmd, args) => new Promise((resolve, reject) =>
|
||||
execFile(cmd, args, { timeout: 120000 }, (err, stdout, stderr) => err ? reject(new Error((stderr || err.message).trim())) : resolve(stdout)));
|
||||
|
||||
// Waits for this process to exit, swaps the new bundle in (putting the old one
|
||||
// back if that fails), and reopens the app.
|
||||
const MAC_SWAP = `set -u
|
||||
pid="$1"; app="$2"; new="$3"; stage="$4"
|
||||
while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done
|
||||
old="$stage/old.app"
|
||||
if mv "$app" "$old"; then
|
||||
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
|
||||
fi
|
||||
xattr -dr com.apple.quarantine "$app" 2>/dev/null
|
||||
rm -rf "$stage"
|
||||
open "$app"
|
||||
`;
|
||||
|
||||
async function applyMac(release, bundle, onProgress) {
|
||||
const asset = release.assets.find((a) => a.name === assetName("darwin", process.arch, "mac-zip"));
|
||||
if (!asset) throw new Error(`the release has no ${assetName("darwin", process.arch, "mac-zip")}`);
|
||||
// Staged beside the app, so the final move stays on one volume.
|
||||
const stage = fs.mkdtempSync(path.join(path.dirname(bundle), ".frame-control-update-"));
|
||||
try {
|
||||
const zip = path.join(stage, asset.name);
|
||||
await download(asset, zip, onProgress);
|
||||
await run("/usr/bin/ditto", ["-x", "-k", zip, stage]);
|
||||
fs.rmSync(zip, { force: true });
|
||||
const name = fs.readdirSync(stage).find((n) => n.endsWith(".app"));
|
||||
if (!name) throw new Error("the download has no app in it");
|
||||
const fresh = path.join(stage, name);
|
||||
const version = (await run("/usr/bin/plutil", ["-extract", "CFBundleShortVersionString", "raw",
|
||||
path.join(fresh, "Contents", "Info.plist")])).trim();
|
||||
if (version !== release.version) throw new Error(`the download is version ${version}, not ${release.version}`);
|
||||
const script = path.join(stage, "swap.sh");
|
||||
fs.writeFileSync(script, MAC_SWAP);
|
||||
return () => spawn("/bin/sh", [script, String(process.pid), bundle, fresh, stage],
|
||||
{ detached: true, stdio: "ignore" }).unref();
|
||||
} catch (e) {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyNsis(release, onProgress) {
|
||||
const asset = release.assets.find((a) => a.name === assetName("win32", process.arch, "nsis"));
|
||||
if (!asset) throw new Error(`the release has no ${assetName("win32", process.arch, "nsis")}`);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-control-update-"));
|
||||
const exe = path.join(dir, asset.name);
|
||||
await download(asset, exe, onProgress);
|
||||
// /S: silent, into the existing install. --force-run: open the app afterwards.
|
||||
return () => spawn(exe, ["--updated", "/S", "--force-run"], { detached: true, stdio: "ignore" }).unref();
|
||||
}
|
||||
|
||||
async function applyAppImage(release, appImage, onProgress) {
|
||||
const asset = release.assets.find((a) => a.name === assetName("linux", process.arch, "appimage"));
|
||||
if (!asset) throw new Error(`the release has no ${assetName("linux", process.arch, "appimage")}`);
|
||||
// A private folder beside the AppImage, so the final rename stays on one filesystem.
|
||||
const stage = fs.mkdtempSync(path.join(path.dirname(appImage), ".frame-control-update-"));
|
||||
try {
|
||||
const next = path.join(stage, asset.name);
|
||||
await download(asset, next, onProgress);
|
||||
fs.chmodSync(next, 0o755);
|
||||
fs.renameSync(next, appImage); // the running copy keeps its open file
|
||||
} finally {
|
||||
fs.rmSync(stage, { recursive: true, force: true });
|
||||
}
|
||||
// Without FUSE the AppImage runs extracted (--appimage-extract-and-run, which isn't passed
|
||||
// on to the app); a FUSE mount lives under /tmp/.mount_*. Keep the same mode on restart.
|
||||
const extracted = process.env.APPIMAGE_EXTRACT_AND_RUN === "1" || !process.execPath.includes("/.mount_");
|
||||
const env = { ...process.env, APPIMAGE: appImage, ...(extracted ? { APPIMAGE_EXTRACT_AND_RUN: "1" } : {}) };
|
||||
// Started only once this process has exited, or the new copy would lose the single-instance lock.
|
||||
return () => spawn("/bin/sh", ["-c", 'while kill -0 "$1" 2>/dev/null; do sleep 0.2; done; exec "$2"',
|
||||
"sh", String(process.pid), appImage], { detached: true, stdio: "ignore", env }).unref();
|
||||
}
|
||||
|
||||
// Downloads and prepares the update; returns a function that starts the swap,
|
||||
// to be called just before the app quits.
|
||||
async function prepare(release, how, onProgress, current) {
|
||||
if (!isNewer(release.version, current)) throw new Error(`${release.version} isn't newer than ${current}`);
|
||||
if (how.method === "mac-zip") return applyMac(release, how.bundle, onProgress);
|
||||
if (how.method === "nsis") return applyNsis(release, onProgress);
|
||||
if (how.method === "appimage") return applyAppImage(release, how.appImage, onProgress);
|
||||
throw new Error(how.why || "this copy can't update itself");
|
||||
}
|
||||
|
||||
function writable(dir) {
|
||||
try { fs.accessSync(dir, fs.constants.W_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
module.exports = { REPO, RELEASES, parseVersion, isNewer, assetName, updateMethod, macBundle, latestRelease,
|
||||
fromManifest, fromApi,
|
||||
download, prepare, writable };
|
||||
Reference in new issue
Block a user