mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 00:00:21 +02:00
- Anonymous PostHog analytics (ui/frame_telemetry.py): usage on by default after a first-run notice; compatibility results and error details opt-in, offered together by the notice's "Share more to help fix problems" button. Random id, no person profiles or GeoIP, scrubbed text, an offline outbox, and "Show what's been sent" in the new Privacy panel. Inert without a project key, from a source checkout, or with DO_NOT_TRACK=1. - APK installs now record install_failed when the APK itself won't install, and offer a 20-second test after installing. Opted-in reports reach the shared database through PostHog and `frame_compat_db.py sync`. - The desktop app updates itself from published releases (app/updater.js): update.json from releases/latest/download, SHA-256 checked, no downgrades; macOS bundle swap, Windows NSIS, Linux AppImage, otherwise the release page. scripts/publish-release.sh publishes a tested draft with its manifest. - Report a problem (header button, Privacy panel, Help menu) files a GitHub issue through the website's feedback API, with a previewed, scrubbed diagnostics snapshot; activity and logs only when asked for. Reviewed by GPT-6 Astra (xhigh, read-only) three times; all findings fixed. Docs: docs/privacy.md, docs/releasing.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
251 lines
13 KiB
JavaScript
251 lines
13 KiB
JavaScript
// 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 };
|