mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Merge main into mac-in-headset: the Mac view alongside analytics and Report a problem
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
d970107716
31 files changed
+2466
-48
No files matched your search
+98
-2
@@ -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,9 @@ function fromUi(e) {
|
||||
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||
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(); });
|
||||
|
||||
// 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 +284,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.)
|
||||
@@ -337,7 +420,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 +451,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 +482,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",
|
||||
|
||||
+4
-2
@@ -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",
|
||||
@@ -37,6 +37,7 @@
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
@@ -46,7 +47,8 @@
|
||||
"to": "ui",
|
||||
"filter": [
|
||||
"*.py",
|
||||
"*.html"
|
||||
"*.html",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,12 +5,28 @@
|
||||
// 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", {
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
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