mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-04 22:00:10 +02:00
Analytics, self-update and Report a problem
- 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>
This commit is contained in:
1 parent
10f96656e3
commit
9eeca79b5d
21 files changed
+2326
-47
No files matched your search
@@ -34,7 +34,9 @@ jobs:
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: Updater tests
|
||||
run: node --test app/test/updater.test.js
|
||||
- name: Website
|
||||
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
|
||||
|
||||
|
||||
@@ -104,6 +104,10 @@ already ships (sideloading a game copies Valve's own devkit scripts to
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
|
||||
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||
From 0.4 it updates itself: when a new version is published, a banner offers
|
||||
**Update and restart**. It sends anonymous usage statistics, which you can turn
|
||||
off. Sharing compatibility results and error details is opt-in. See
|
||||
[docs/privacy.md](docs/privacy.md).
|
||||
Google doesn't publish `adb` for arm64 Linux, so that build uses your
|
||||
distribution's. If you already have `adb`, the app uses yours.
|
||||
|
||||
@@ -172,9 +176,11 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
||||
## Feedback
|
||||
|
||||
This is a first public test, so reports are really useful, especially from
|
||||
Windows and Linux. The quickest way is the
|
||||
[feedback form](https://frame-control.pages.dev/feedback/): no GitHub account
|
||||
needed, and it opens an issue here. Please include:
|
||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||
diagnostics with personal details removed, shows you exactly what's included,
|
||||
and opens an issue here. You don't need a GitHub account. Without the app,
|
||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||
|
||||
- what you tried and what happened
|
||||
- your computer's OS and your SteamOS build (Steam Settings → System)
|
||||
@@ -230,7 +236,8 @@ cd app && npm install && npm start # run the app from the checkout
|
||||
|
||||
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
|
||||
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
|
||||
into the release. See [building](docs/frame-control.md#building).
|
||||
into a draft release, which reaches users once published. See
|
||||
[building](docs/frame-control.md#building) and [releasing](docs/releasing.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+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}`);
|
||||
@@ -243,6 +248,9 @@ function fromUi(e) {
|
||||
}
|
||||
|
||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||
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),
|
||||
@@ -275,6 +283,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.)
|
||||
@@ -336,7 +419,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" },
|
||||
{
|
||||
@@ -363,7 +450,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));
|
||||
@@ -386,6 +481,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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,11 +4,27 @@
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// 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"),
|
||||
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 };
|
||||
+26
-3
@@ -1,9 +1,32 @@
|
||||
# compat-db: Frame Control's compatibility database
|
||||
|
||||
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
|
||||
reports for Android apps on the Steam Frame. For now only the maintainer's
|
||||
copy of Frame Control has the key to read or write it. Everyone else's reports
|
||||
stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`).
|
||||
reports for Android apps on the Steam Frame. Only the maintainer's copy of
|
||||
Frame Control has the key to read or write it (see `shared()` in
|
||||
`ui/frame_compat_db.py`). Everyone else's reports stay on their computer
|
||||
unless they turn on **Share compatibility results**. Then the reports also go
|
||||
to PostHog as `compat_report` events, and the maintainer syncs them in (below).
|
||||
|
||||
## Community reports
|
||||
|
||||
```sh
|
||||
python3 ui/frame_compat_db.py sync --dry-run # what would be added
|
||||
python3 ui/frame_compat_db.py sync # add them
|
||||
```
|
||||
|
||||
`sync` reads `compat_report` events through PostHog's query API and adds
|
||||
them with `via` set to `community`, `community-probe` or `community-install`.
|
||||
It skips invalid reports and anything over 30 per reporter per day. Each run
|
||||
re-reads the last 30 days, because an offline copy sends its reports late,
|
||||
with the time they were made. `posthog-sync.json`, next to the outbox,
|
||||
remembers which reports it has handled and each reporter's daily count, so
|
||||
nothing is added twice and the cap holds across runs.
|
||||
|
||||
It needs:
|
||||
|
||||
- the PostHog project id: `"project"` in `ui/telemetry.json`
|
||||
- a personal API key with `query:read`: `POSTHOG_PERSONAL_API_KEY`, or in the
|
||||
Keychain (service `frame-control-posthog`, account `personal-api-key`)
|
||||
|
||||
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
|
||||
claimed, so it doesn't expire). The browser page only says it's private.
|
||||
|
||||
@@ -47,9 +47,11 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
Steam library), then launch, stop, test or remove it. **Report an APK** records
|
||||
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
|
||||
installed app). Your reports are saved on your computer and change the verdicts
|
||||
you see. They aren't uploaded anywhere: the shared database is maintainer-only
|
||||
for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled
|
||||
`adb`, or yours if you have one.
|
||||
you see. With **Share compatibility results** on (Privacy & updates), they also
|
||||
go to the shared database ([privacy.md](privacy.md),
|
||||
[compat-db/README.md](../compat-db/README.md)). A failed install records
|
||||
itself when the APK was the problem, and after an install the app offers a
|
||||
20-second test. Uses the app's bundled `adb`, or yours if you have one.
|
||||
- **Android display**: pick a running Lepton instance (by the app in it) and set
|
||||
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
|
||||
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
|
||||
@@ -139,5 +141,6 @@ npm run dist:win # Windows: installer and .zip
|
||||
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
|
||||
```
|
||||
|
||||
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
|
||||
release (`.github/workflows/release.yml`).
|
||||
Pushing a `v*` tag builds all three in GitHub Actions and attaches them to a
|
||||
draft release (`.github/workflows/release.yml`). Running copies are offered it
|
||||
once you publish it: see [releasing.md](releasing.md).
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Privacy and analytics
|
||||
|
||||
Frame Control sends anonymous analytics to [PostHog](https://posthog.com)
|
||||
(EU cloud) so the maintainer can see how many people use it, which features
|
||||
matter and where installs fail. You choose how much in **Privacy & updates**,
|
||||
the last panel on the page. `ui/frame_telemetry.py` is the whole
|
||||
implementation.
|
||||
|
||||
## The three levels
|
||||
|
||||
| Level | Default | What it sends |
|
||||
|---|---|---|
|
||||
| Anonymous usage statistics | On, after a notice on first run | The events in the table below |
|
||||
| Share compatibility results | Off | Your Android compatibility reports and tests |
|
||||
| Send error details | Off | Scrubbed error messages and tracebacks |
|
||||
|
||||
Nothing is sent until the first-run notice has been shown. The notice's
|
||||
**Share more to help fix problems** button turns on the second and third
|
||||
levels together. Either can be turned off later. Turning a level off
|
||||
deletes that level's events that haven't been sent yet.
|
||||
|
||||
**Show what's been sent** in the panel lists the last 50 events that left your
|
||||
computer, exactly as they were sent.
|
||||
|
||||
## Anonymous
|
||||
|
||||
- Events carry a random id, made when Frame Control first runs and kept in
|
||||
its data folder (`telemetry/settings.json`). It isn't derived from your
|
||||
computer, account or network. To get a new one, delete that file.
|
||||
- Events are sent without person profiles (`$process_person_profile: false`)
|
||||
and without location lookup (`$geoip_disable: true`). The PostHog project
|
||||
is also set to discard client IP addresses.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
|
||||
## Usage events
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category`, `installer_code`, and see below |
|
||||
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
|
||||
|
||||
`install_finished` never includes a file name, path or error message. An
|
||||
error becomes one category from a fixed list (for example `apk_wrong_abi` or
|
||||
`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
|
||||
is one. It names what was installed only when that's already public:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
- Flathub apps: `flatpak_id`
|
||||
- Steam games: `steam_appid`
|
||||
- A sideloaded title: only its runtime (Proton or Linux)
|
||||
|
||||
Any other APK is sent as `catalog: false`, with no name.
|
||||
|
||||
## Compatibility results (opt-in)
|
||||
|
||||
Each report becomes a `compat_report` event with the fields the Report dialog
|
||||
shows: package, version, result or rating, your notes, how it was run, and the
|
||||
SteamOS and Lepton builds. Before sending:
|
||||
|
||||
- the notes, app name and version are scrubbed like error messages (see
|
||||
below)
|
||||
- the APK's source is kept only if it's `F-Droid` or the public host name of
|
||||
a download link (`https://example.com/…`). File names, user names,
|
||||
passwords, ports, paths, IP addresses and local host names are dropped
|
||||
|
||||
When you turn this on, reports you made earlier on this computer are shared
|
||||
too.
|
||||
|
||||
The maintainer's `python3 ui/frame_compat_db.py sync` copies these events
|
||||
into the compatibility database, marked `via=community…`. It takes at most
|
||||
30 per reporter per day.
|
||||
|
||||
## Error details (opt-in)
|
||||
|
||||
`$exception` events carry an error message, the Frame Control file, line and
|
||||
function it came from, and the request that failed (for example
|
||||
`POST /api/android install`). Before anything is sent, the message is
|
||||
scrubbed:
|
||||
|
||||
- your home folder becomes `~`, and any user name becomes `<user>`
|
||||
- IP and MAC addresses, email addresses, `.local`, `.lan` and Tailscale host
|
||||
names, Steam ids, SSH and PEM keys, API tokens and long hex strings are
|
||||
replaced
|
||||
- URLs are cut down to their scheme and a public host name, or `<url>`. User
|
||||
names, passwords, ports, paths and queries are dropped
|
||||
- `token=`, `key=`, `password=` and similar values are replaced
|
||||
|
||||
The same error is sent at most once every 10 minutes.
|
||||
|
||||
## Report a problem
|
||||
|
||||
**Report a problem** is the warning-sign button in the header, also in the
|
||||
Privacy panel and under **Help → Report a Problem…**. It files a public issue
|
||||
on [GitHub](https://github.com/saphid/frame-control/issues) through the
|
||||
website's feedback service, so no GitHub account is needed. It works whatever
|
||||
the analytics settings are, because the person sends it deliberately.
|
||||
|
||||
With **Include diagnostics** ticked (the default), the report adds:
|
||||
|
||||
- the app version and whether it's a built app
|
||||
- the OS, its release and CPU, and the Python version
|
||||
- the Frame's SteamOS build, if it has connected since the app started
|
||||
- which analytics levels are on
|
||||
|
||||
**Also include recent activity and the server log** is off by default,
|
||||
because those lines can name files and apps. When ticked, it adds the newest
|
||||
Activity lines and server log lines, without the request lines.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
issue. Environment details are kept first, then the newest lines. **Show
|
||||
exactly what's included** shows the snapshot that will be sent, and later
|
||||
activity isn't added to it. If the service can't be reached, the dialog offers
|
||||
**Copy report** and **Open on GitHub instead**, a prefilled issue.
|
||||
|
||||
## Turning it all off
|
||||
|
||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
||||
the environment that starts Frame Control. A copy run from a source checkout
|
||||
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
||||
|
||||
## Update checks
|
||||
|
||||
The desktop app asks GitHub for the latest release shortly after starting,
|
||||
then every 6 hours (`api.github.com/repos/saphid/frame-control/releases/latest`).
|
||||
That request carries no id. To stop it, set
|
||||
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
|
||||
@@ -0,0 +1,59 @@
|
||||
# Releasing and updates
|
||||
|
||||
Frame Control checks for updates itself. The desktop app offers a new version
|
||||
only once it's GitHub's **latest release**, and drafts and pre-releases never
|
||||
count. So a build reaches people only when you publish it, after testing it.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Bump `version` in `app/package.json`, commit, and push a tag:
|
||||
|
||||
```sh
|
||||
git tag v0.4.0 && git push origin v0.4.0
|
||||
```
|
||||
|
||||
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
|
||||
attaches everything to a **draft** release for that tag. Nobody is
|
||||
offered a draft.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
3. Write the release notes on the draft. The update banner links to them.
|
||||
|
||||
4. Publish:
|
||||
|
||||
```sh
|
||||
scripts/publish-release.sh v0.4.0
|
||||
```
|
||||
|
||||
The script checks that all eight installers are attached, each with the
|
||||
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
|
||||
notes and each installer's digest), then publishes the release and marks it
|
||||
latest. From then on, running copies see the update. They check about 8
|
||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||
|
||||
To pull a bad release, mark the previous one as latest
|
||||
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
|
||||
Copies that already updated stay on it. Nothing downgrades them.
|
||||
|
||||
## How a copy updates itself
|
||||
|
||||
`app/updater.js` reads `update.json` from
|
||||
`github.com/saphid/frame-control/releases/latest/download/`. It falls back to the
|
||||
REST API only when a release has no manifest, because the API allows just 60
|
||||
unauthenticated requests an hour per IP address, shared by a whole household.
|
||||
Then it downloads the installer for its platform and checks it
|
||||
against the SHA-256 digest GitHub publishes for the asset. It refuses if the
|
||||
digest is missing or doesn't match. Then:
|
||||
|
||||
| Installed from | Update |
|
||||
|---|---|
|
||||
| macOS `.dmg`, app in a writable folder such as Applications | The `.zip` is unpacked next to the app and its version checked. After the app quits, a small script swaps the new app in, putting the old one back if that fails, and reopens it. Updates don't get the download quarantine, so there's no `xattr` step. |
|
||||
| Windows installer | The new `Setup` runs silently over the install (`/S --force-run`) and reopens the app. |
|
||||
| Linux AppImage | The new AppImage replaces the old file and is started. |
|
||||
| macOS app still on the disk image or translocated, Windows `.zip`, Linux `.deb` | The banner opens the release page instead. |
|
||||
|
||||
Version 0.3.1 and earlier have no updater, so people on them have to download
|
||||
the new version once by hand.
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/bin/sh
|
||||
# Publish a tested draft release so running copies of Frame Control offer it
|
||||
# (docs/releasing.md). Checks every installer is attached with a SHA-256
|
||||
# digest first, since the app's updater refuses assets without one, then
|
||||
# attaches update.json, the manifest the updater reads.
|
||||
# Usage: scripts/publish-release.sh v0.4.0
|
||||
set -eu
|
||||
tag="${1:?usage: $0 vX.Y.Z}"
|
||||
repo=saphid/frame-control
|
||||
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
|
||||
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
|
||||
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
|
||||
|
||||
info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
|
||||
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
|
||||
|
||||
missing=""
|
||||
for name in $expected; do
|
||||
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
|
||||
d=json.load(sys.stdin); n=sys.argv[1]
|
||||
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
|
||||
case "$digest" in
|
||||
sha256:*) echo "ok $name" ;;
|
||||
absent) echo "MISSING $name"; missing=1 ;;
|
||||
*) echo "NO HASH $name"; missing=1 ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
|
||||
|
||||
# update.json: what running copies read (app/updater.js), from github.com's
|
||||
# latest/download link rather than the rate-limited REST API.
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
names=set(sys.argv[1].split())
|
||||
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
|
||||
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
|
||||
for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
|
||||
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
|
||||
echo "ok update.json"
|
||||
|
||||
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
|
||||
echo "published $tag; running copies will offer it at their next check"
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Anonymous analytics (ui/frame_telemetry.py): what's collected at each level,
|
||||
what's scrubbed, and that nothing is sent without a key, the notice, or consent.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_compat_db as db # noqa: E402
|
||||
import frame_report as fr # noqa: E402
|
||||
import frame_telemetry as tm # noqa: E402
|
||||
|
||||
|
||||
class Base(unittest.TestCase):
|
||||
"""A packaged build with a key, its state in a temp folder."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
state = Path(tmp.name)
|
||||
for name, value in (("STATE", state), ("SETTINGS", state / "settings.json"),
|
||||
("OUTBOX", state / "outbox.jsonl"), ("SENT", state / "sent.jsonl")):
|
||||
p = mock.patch.object(tm, name, value)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
env = mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_KEY": "phc_test", "FRAME_CONTROL_PACKAGED": "1",
|
||||
"FRAME_CONTROL_POSTHOG_HOST": "http://127.0.0.1:9",
|
||||
"FRAME_CONTROL_VERSION": "9.9.9"})
|
||||
env.start()
|
||||
self.addCleanup(env.stop)
|
||||
for k in ("DO_NOT_TRACK", "FRAME_CONTROL_TELEMETRY"):
|
||||
os.environ.pop(k, None)
|
||||
tm._seen_errors.clear()
|
||||
|
||||
def queued(self):
|
||||
return tm._read_lines(tm.OUTBOX)
|
||||
|
||||
|
||||
class Gates(Base):
|
||||
def test_blocked_without_key_or_in_a_checkout_or_by_do_not_track(self):
|
||||
self.assertIsNone(tm.blocked())
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_KEY": ""}), \
|
||||
mock.patch.object(tm, "HERE", Path(tempfile.gettempdir()) / "no-config-here"):
|
||||
self.assertIn("key", tm.blocked())
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_PACKAGED": ""}):
|
||||
self.assertIn("source checkout", tm.blocked())
|
||||
with mock.patch.dict(os.environ, {"DO_NOT_TRACK": "1"}):
|
||||
self.assertIn("DO_NOT_TRACK", tm.blocked())
|
||||
self.assertFalse(tm.capture("app_opened"))
|
||||
self.assertFalse(tm.OUTBOX.exists())
|
||||
|
||||
def test_usage_is_on_by_default_the_others_are_opt_in(self):
|
||||
self.assertTrue(tm.capture("app_opened"))
|
||||
self.assertFalse(tm.capture("compat_report", {}, level="compat"))
|
||||
self.assertFalse(tm.capture("$exception", {}, level="diagnostics"))
|
||||
self.assertEqual([e["event"] for e in self.queued()], ["app_opened"])
|
||||
|
||||
def test_events_are_anonymous(self):
|
||||
tm.capture("app_opened")
|
||||
e = self.queued()[0]
|
||||
self.assertEqual(e["distinct_id"], tm.settings()["id"])
|
||||
self.assertIs(e["properties"]["$process_person_profile"], False)
|
||||
self.assertIs(e["properties"]["$geoip_disable"], True)
|
||||
self.assertEqual(e["properties"]["app_version"], "9.9.9")
|
||||
|
||||
def test_turning_a_level_off_drops_its_unsent_events(self):
|
||||
tm.update_settings({"diagnostics": True})
|
||||
tm.capture("app_opened")
|
||||
tm.diagnostic("somewhere", RuntimeError("boom"))
|
||||
self.assertEqual(len(self.queued()), 2)
|
||||
tm.update_settings({"diagnostics": False})
|
||||
self.assertEqual([e["event"] for e in self.queued()], ["app_opened"])
|
||||
tm.update_settings({"usage": False})
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertFalse(tm.capture("app_opened"))
|
||||
|
||||
def test_the_same_error_is_sent_once_in_a_while(self):
|
||||
tm.update_settings({"diagnostics": True})
|
||||
for _ in range(3):
|
||||
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
|
||||
self.assertEqual(len(self.queued()), 1)
|
||||
|
||||
def test_page_events_are_checked(self):
|
||||
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
|
||||
self.assertEqual(self.queued()[0]["properties"].get("extra"), None)
|
||||
with self.assertRaises(ValueError):
|
||||
tm.page_event({"event": "anything_else"})
|
||||
with self.assertRaises(ValueError):
|
||||
tm.page_event({"event": "tab_viewed", "properties": {"tab": "/Users/me/secret"}})
|
||||
|
||||
|
||||
class Lifecycle(Base):
|
||||
def test_install_update_and_one_open_a_day(self):
|
||||
tm.app_started()
|
||||
tm.app_started()
|
||||
self.assertEqual([e["event"] for e in self.queued()], ["app_installed", "app_opened"])
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
|
||||
tm.app_started()
|
||||
e = self.queued()[-1]
|
||||
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
|
||||
|
||||
def test_frame_build_once(self):
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
self.assertEqual(len(self.queued()), 1)
|
||||
|
||||
|
||||
class Sending(Base):
|
||||
def serve(self, status=200):
|
||||
got = []
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
got.append((self.path, json.loads(self.rfile.read(int(self.headers["Content-Length"])))))
|
||||
self.send_response(status)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"status": 1}')
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
httpd = HTTPServer(("127.0.0.1", 0), H)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
os.environ["FRAME_CONTROL_POSTHOG_HOST"] = f"http://127.0.0.1:{httpd.server_port}"
|
||||
return got
|
||||
|
||||
def test_nothing_is_sent_before_the_notice_was_shown(self):
|
||||
got = self.serve()
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 0)
|
||||
self.assertEqual(got, [])
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
path, body = got[0]
|
||||
self.assertEqual((path, body["api_key"], body["batch"][0]["event"]), ("/batch/", "phc_test", "app_opened"))
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
|
||||
|
||||
def test_a_failed_send_keeps_the_events(self):
|
||||
self.serve(status=500)
|
||||
tm.update_settings({"noticeShown": True})
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 0)
|
||||
self.assertEqual(len(self.queued()), 1)
|
||||
|
||||
|
||||
class Scrub(unittest.TestCase):
|
||||
def test_personal_details_are_removed(self):
|
||||
home = str(Path.home())
|
||||
text = (f"open {home}/Downloads/My Game.apk failed; ssh alex@192.168.1.20 (frame.local) "
|
||||
"key ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM steam 76561198000000000 mac 3c:22:fb:12:34:56 "
|
||||
"url https://example.com/private/path?token=abc phc_abcdefghijklmnopqrstu C:\\Users\\Bob\\x "
|
||||
"/home/carol/y")
|
||||
out = tm.scrub(text)
|
||||
for leaked in (home, "192.168.1.20", "frame.local", "AAAAC3Nza", "76561198000000000", "3c:22:fb",
|
||||
"private/path", "phc_abcdefghijklmnopqrstu", "Bob", "carol", "alex@"):
|
||||
self.assertNotIn(leaked, out)
|
||||
self.assertIn("https://example.com/…", out)
|
||||
self.assertIn("~/Downloads", out)
|
||||
|
||||
def test_categories(self):
|
||||
self.assertEqual(tm.categorize("adb: failed to install: INSTALL_FAILED_NO_MATCHING_ABIS: x"),
|
||||
("android_installer", "INSTALL_FAILED_NO_MATCHING_ABIS"))
|
||||
self.assertEqual(tm.categorize("X has no arm64-v8a build (armeabi-v7a)")[0], "apk_wrong_abi")
|
||||
self.assertEqual(tm.categorize("ssh: connect to host 10.0.0.2 port 22: Connection refused")[0],
|
||||
"frame_unreachable")
|
||||
self.assertEqual(tm.categorize("something new")[0], "other")
|
||||
|
||||
|
||||
class Compat(Base):
|
||||
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
|
||||
r = {"id": "r1", "package": "org.example", "version": "1.0", "rating": "works", "via": "user",
|
||||
"notes": f"from {Path.home()}/x", "source": "MyPrivateBuild.apk", "date": "2026-09-28T10:00:00"}
|
||||
self.assertFalse(tm.compat_report(r))
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertTrue(tm.compat_report(r))
|
||||
p = self.queued()[-1]["properties"]
|
||||
self.assertEqual((p["package"], p["rating"], p["id"]), ("org.example", "works", "r1"))
|
||||
self.assertNotIn("source", p)
|
||||
self.assertNotIn(str(Path.home()), p["notes"])
|
||||
r2 = dict(r, id="r2", source="https://f-droid.org/repo/org.example_1.apk")
|
||||
tm.compat_report(r2)
|
||||
self.assertEqual(self.queued()[-1]["properties"]["source"], "https://f-droid.org/…")
|
||||
|
||||
def test_opting_in_shares_earlier_local_reports(self):
|
||||
with mock.patch.object(db, "shared", return_value=False), \
|
||||
mock.patch.object(db, "_outbox", return_value=[{"id": "old1", "package": "org.a", "rating": "works",
|
||||
"date": "2026-09-01T00:00:00"}]):
|
||||
tm.update_settings({"compat": True})
|
||||
tm.update_settings({"compat": True}) # already sent: not again
|
||||
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["old1"])
|
||||
|
||||
|
||||
class ApkInstallReports(unittest.TestCase):
|
||||
"""server.apk_installed: an APK that won't install is reported; connection trouble isn't."""
|
||||
|
||||
def setUp(self):
|
||||
import server
|
||||
self.server = server
|
||||
for target, name in ((server.frame_catalog, "add_report"), (server.frame_telemetry, "install_finished")):
|
||||
p = mock.patch.object(target, name)
|
||||
setattr(self, name, p.start())
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_wrong_abi_is_an_install_failed_report(self):
|
||||
info = {"package": "org.x", "version": "2.0", "label": "X"}
|
||||
self.server.apk_installed(info, None, self.server.frame_android.FrameError(
|
||||
"X has no arm64-v8a build (armeabi-v7a); Lepton is 64-bit ARM only"), 3.0)
|
||||
args, kw = self.add_report.call_args
|
||||
self.assertEqual((args[0], args[1], kw["result"], kw["via"]), ("org.x", "2.0", "install_failed", "install"))
|
||||
self.assertIs(self.install_finished.call_args[0][1], False)
|
||||
|
||||
def test_connection_trouble_is_not_reported(self):
|
||||
self.server.apk_installed({"package": "org.x", "version": "2.0"}, None,
|
||||
self.server.frame_android.FrameError("timed out talking to frame"), 3.0)
|
||||
self.add_report.assert_not_called()
|
||||
|
||||
def test_private_package_names_stay_here(self):
|
||||
with mock.patch.dict(self.server.frame_catalog._cache, {"by_pkg": {"org.public": {}}}):
|
||||
self.server.apk_installed({"package": "com.private.thing", "version": "1"}, {"package": "com.private.thing"},
|
||||
None, 2.0)
|
||||
self.assertIsNone(self.install_finished.call_args[1]["package"])
|
||||
self.server.apk_installed({"package": "org.public", "version": "1"}, {"package": "org.public"}, None, 2.0)
|
||||
self.assertEqual(self.install_finished.call_args[1]["package"], "org.public")
|
||||
|
||||
|
||||
class CommunitySync(unittest.TestCase):
|
||||
def ev(self, i, who="a", day="2026-09-28", **kw):
|
||||
return ({"id": f"id{i}", "package": "org.x", "rating": "works", "date": f"{day}T00:00:00",
|
||||
"via": "probe", **kw}, who, f"{day} 10:00:00")
|
||||
|
||||
def test_rows_are_validated_marked_and_capped_per_reporter(self):
|
||||
events = [self.ev(i) for i in range(5)] + [self.ev(9, who="b", rating="nonsense"), self.ev(10, who="b")]
|
||||
rows, skipped = db.community_rows(events, {}, cap=3)
|
||||
self.assertEqual([r["id"] for r in rows], ["id0", "id1", "id2", "id10"])
|
||||
self.assertTrue(all(r["via"] == "community-probe" for r in rows))
|
||||
self.assertEqual(len(skipped), 3)
|
||||
|
||||
def test_the_cap_and_duplicates_hold_across_syncs(self):
|
||||
state = {}
|
||||
rows, _ = db.community_rows([self.ev(i) for i in range(3)], state, cap=3)
|
||||
self.assertEqual(len(rows), 3)
|
||||
rows, skipped = db.community_rows([self.ev(i) for i in range(6)], state, cap=3) # overlapping re-read
|
||||
self.assertEqual(rows, [])
|
||||
self.assertEqual([why for _, why in skipped], ["over the daily limit for one reporter"] * 3)
|
||||
|
||||
def test_a_malformed_event_is_skipped_not_fatal(self):
|
||||
rows, skipped = db.community_rows([self.ev(1, via=["probe"]), ("not json", "a", "2026-09-28"), self.ev(2)], {})
|
||||
self.assertEqual([r["id"] for r in rows], ["id2"])
|
||||
self.assertEqual(len(skipped), 2)
|
||||
|
||||
|
||||
class Regressions(Base):
|
||||
"""Findings from the cross-provider review."""
|
||||
|
||||
def test_urls_lose_credentials_paths_and_private_hosts(self):
|
||||
for text, leaked in (("https://alice:secret@example.com/private.apk?token=credential", ("alice", "secret", "private", "credential")),
|
||||
("https://alice:secret@192.168.1.4/private.apk", ("alice", "192.168", "private")),
|
||||
("fe80::1234 and 2001:db8::5", ("fe80", "2001:db8")),
|
||||
("sk-proj-abcdefghijklmnopqrstuv", ("abcdefghijk",)),
|
||||
("http://frame.local:8080/x", ("frame.local", "8080"))):
|
||||
out = tm.scrub(text)
|
||||
for s in leaked:
|
||||
self.assertNotIn(s, out, (text, out))
|
||||
|
||||
def test_compat_labels_versions_and_sources_are_scrubbed(self):
|
||||
tm.update_settings({"compat": True})
|
||||
tm.compat_report({"id": "r9", "package": "org.x", "rating": "works", "date": "2026-09-28T00:00:00",
|
||||
"label": "alice@example.com build", "version": "1.0-alice@example.com",
|
||||
"source": "https://alice:secret@192.168.1.4/private.apk"})
|
||||
p = self.queued()[-1]["properties"]
|
||||
self.assertNotIn("alice", json.dumps(p))
|
||||
self.assertNotIn("source", p)
|
||||
|
||||
def test_an_unsent_report_is_shared_again_after_opting_out_and_in(self):
|
||||
with mock.patch.object(db, "shared", return_value=False), \
|
||||
mock.patch.object(db, "_outbox", return_value=[{"id": "q1", "package": "org.a", "rating": "works",
|
||||
"date": "2026-09-01T00:00:00"}]):
|
||||
tm.update_settings({"compat": True})
|
||||
tm.update_settings({"compat": False})
|
||||
self.assertEqual(self.queued(), [])
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["q1"])
|
||||
|
||||
def test_opting_out_waits_for_a_send_in_progress(self):
|
||||
tm.update_settings({"noticeShown": True})
|
||||
tm.capture("app_opened")
|
||||
order = []
|
||||
started = threading.Event()
|
||||
|
||||
def slow_open(req, timeout):
|
||||
started.set()
|
||||
time.sleep(0.3)
|
||||
order.append("sent")
|
||||
return mock.MagicMock(__enter__=lambda s: s, __exit__=lambda *a: False, read=lambda: b"{}")
|
||||
|
||||
with mock.patch.object(tm.urllib.request, "urlopen", side_effect=slow_open):
|
||||
th = threading.Thread(target=tm.flush)
|
||||
th.start()
|
||||
started.wait(2)
|
||||
tm.update_settings({"usage": False})
|
||||
order.append("opted out")
|
||||
th.join()
|
||||
self.assertEqual(order, ["sent", "opted out"])
|
||||
|
||||
def test_project_id_comes_from_the_config(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_POSTHOG_PROJECT": "12345"}):
|
||||
self.assertEqual(tm.config()["project"], "12345")
|
||||
|
||||
|
||||
class ReportProblem(Base):
|
||||
"""Report a problem: diagnostics are scrubbed and fit the feedback API; sending goes to it."""
|
||||
|
||||
def serve(self, status=201, reply=None):
|
||||
got = []
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
got.append(json.loads(self.rfile.read(int(self.headers["Content-Length"]))))
|
||||
self.send_response(status)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(reply or {"ok": True, "number": 42,
|
||||
"url": "https://github.com/saphid/frame-control/issues/42"}).encode())
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
httpd = HTTPServer(("127.0.0.1", 0), H)
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
p = mock.patch.object(fr, "FEEDBACK_URL", f"http://127.0.0.1:{httpd.server_port}/api/feedback")
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
return got
|
||||
|
||||
def test_diagnostics_are_scrubbed_and_include_the_log(self):
|
||||
log = tm.STATE / "server.log"
|
||||
log.write_text("GET /api/status 200\nTraceback: ssh alice@192.168.1.9 failed in %s/x\n" % Path.home())
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
|
||||
text = fr.diagnostics(["16:00 Install failed: https://bob:pw@example.com/a.apk"], include_logs=True, limit=5000)
|
||||
self.assertIn("Frame Control 9.9.9", text)
|
||||
self.assertIn("Traceback", text)
|
||||
self.assertNotIn("GET /api/status", text)
|
||||
for leaked in ("alice", "192.168.1.9", str(Path.home()), "bob", "pw@"):
|
||||
self.assertNotIn(leaked, text)
|
||||
|
||||
def test_a_report_fits_the_api_limit_in_utf16_units(self):
|
||||
body = {"title": "Live view stops", "message": "It stops 😀 " * 600, "diagnostics": "log 😀 line\n" * 2000}
|
||||
title, message = fr.compose(body)
|
||||
self.assertLessEqual(fr.u16(message), fr.MESSAGE_MAX)
|
||||
self.assertTrue(message.startswith("It stops"))
|
||||
with self.assertRaises(ValueError):
|
||||
fr.compose({"title": "hi", "message": "It stops after a minute."})
|
||||
|
||||
def test_logs_only_when_asked_and_environment_is_kept_first(self):
|
||||
log = tm.STATE / "server.log"
|
||||
log.write_text("".join(f"old line {i}\n" for i in range(200)) + "newest line\n")
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
|
||||
plain = fr.diagnostics(["Copy Jane Doe tax return.pdf to ~/Downloads"])
|
||||
full = fr.diagnostics(["Install failed"], include_logs=True, limit=400)
|
||||
self.assertNotIn("Jane Doe", plain)
|
||||
self.assertNotIn("line", plain)
|
||||
self.assertTrue(full.startswith("Frame Control 9.9.9"))
|
||||
self.assertIn("Install failed", full)
|
||||
self.assertIn("newest line", full)
|
||||
self.assertLessEqual(fr.u16(full), 400)
|
||||
|
||||
def test_the_previewed_diagnostics_are_what_is_sent(self):
|
||||
got = self.serve()
|
||||
fr.send({"title": "Live view stops", "message": "It stops after a minute.", "elapsed": 9000,
|
||||
"diagnostics": "Frame Control 9.9.9\nssh janes-mac.tail12345.ts.net failed"})
|
||||
self.assertIn("Frame Control 9.9.9", got[0]["message"])
|
||||
self.assertNotIn("janes-mac", got[0]["message"])
|
||||
|
||||
def test_send_files_an_issue_and_reports_its_number(self):
|
||||
got = self.serve()
|
||||
res = fr.send({"kind": "bug", "title": "Live view stops", "message": "It stops after a minute.",
|
||||
"elapsed": 9000, "github": "@someone"})
|
||||
self.assertEqual((res["number"], res["url"]), (42, "https://github.com/saphid/frame-control/issues/42"))
|
||||
sent = got[0]
|
||||
self.assertEqual((sent["kind"], sent["github"], sent["website"], sent["elapsed"]), ("bug", "someone", "", 9000))
|
||||
self.assertEqual(sent["message"], "It stops after a minute.")
|
||||
|
||||
def test_the_services_error_is_passed_on(self):
|
||||
self.serve(status=429, reply={"error": "That's a lot of feedback in one hour."})
|
||||
with self.assertRaisesRegex(fr.ReportError, "a lot of feedback"):
|
||||
fr.send({"title": "Live view stops", "message": "It stops after a minute.", "elapsed": 9000})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+29
-9
@@ -106,16 +106,36 @@ def _write_meta(d, meta):
|
||||
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
|
||||
|
||||
|
||||
# Called after every install, worked or not, as fn(info, meta, error, seconds):
|
||||
# info is None if the APK couldn't be read, meta None and error set if it failed.
|
||||
install_hooks = []
|
||||
|
||||
|
||||
def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
|
||||
info = apk_info(apk_path)
|
||||
if icon_png:
|
||||
info['icon_png'] = icon_png
|
||||
check_installable(info)
|
||||
pkg = info['package']
|
||||
if not PKG_RE.match(pkg):
|
||||
raise FrameError(f'unexpected package name {pkg!r}')
|
||||
with _install_lock:
|
||||
return _install(apk_path, info, pkg, flatscreen, name, source)
|
||||
start, info = time.time(), None
|
||||
try:
|
||||
info = apk_info(apk_path)
|
||||
if icon_png:
|
||||
info['icon_png'] = icon_png
|
||||
check_installable(info)
|
||||
pkg = info['package']
|
||||
if not PKG_RE.match(pkg):
|
||||
raise FrameError(f'unexpected package name {pkg!r}')
|
||||
with _install_lock:
|
||||
meta = _install(apk_path, info, pkg, flatscreen, name, source)
|
||||
except FrameError as e:
|
||||
_after_install(info, None, e, start)
|
||||
raise
|
||||
_after_install(info, meta, None, start)
|
||||
return meta
|
||||
|
||||
|
||||
def _after_install(info, meta, error, start):
|
||||
for hook in install_hooks:
|
||||
try:
|
||||
hook(info, meta, error, time.time() - start)
|
||||
except Exception:
|
||||
pass # reporting must never change an install's outcome
|
||||
|
||||
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
|
||||
+149
-1
@@ -8,12 +8,18 @@ New reports go to a local outbox first and are sent from there, so nothing is
|
||||
lost offline. A mirror of every report is kept for offline reads. Both live in
|
||||
frame_host.data_dir('compat-db'). Python stdlib only.
|
||||
|
||||
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
|
||||
Everyone else can opt in to sharing (the Privacy panel): their reports then
|
||||
also go to PostHog as compat_report events (frame_telemetry.py), and the
|
||||
maintainer's `sync` pulls them into the database, at most SYNC_DAILY_CAP per
|
||||
reporter per day, marked via=community[-probe|-install].
|
||||
|
||||
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush|sync}
|
||||
(import restores a backup; reports already in the database are skipped.)
|
||||
"""
|
||||
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
|
||||
|
||||
import frame_host
|
||||
import frame_telemetry
|
||||
|
||||
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
||||
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
||||
@@ -216,11 +222,147 @@ def add(report):
|
||||
if shared():
|
||||
flush()
|
||||
_mem['at'] = 0 # refetch on next load
|
||||
else:
|
||||
frame_telemetry.compat_report(r) # only if this person opted in to sharing
|
||||
except Exception:
|
||||
pass # stays queued; load() shows it and a later call sends it
|
||||
return r
|
||||
|
||||
|
||||
# ---- community reports: PostHog -> the database (maintainer only) ---------------
|
||||
|
||||
POSTHOG_KEYCHAIN = ('frame-control-posthog', 'personal-api-key')
|
||||
SYNC_STATE = os.path.join(STATE, 'posthog-sync.json')
|
||||
SYNC_DAILY_CAP = 30
|
||||
COMMUNITY_VIA = {'user': 'community', 'probe': 'community-probe', 'install': 'community-install'}
|
||||
|
||||
|
||||
def posthog_personal_key():
|
||||
k = os.environ.get('POSTHOG_PERSONAL_API_KEY')
|
||||
if k:
|
||||
return k
|
||||
if frame_host.MAC:
|
||||
p = subprocess.run(['security', 'find-generic-password', '-s', POSTHOG_KEYCHAIN[0], '-a',
|
||||
POSTHOG_KEYCHAIN[1], '-w'], capture_output=True, text=True)
|
||||
if p.returncode == 0 and p.stdout.strip():
|
||||
return p.stdout.strip()
|
||||
raise DBError('No PostHog personal API key (set POSTHOG_PERSONAL_API_KEY, or on macOS the Keychain '
|
||||
f'item service {POSTHOG_KEYCHAIN[0]}, account {POSTHOG_KEYCHAIN[1]})')
|
||||
|
||||
|
||||
def _posthog_query(sql):
|
||||
cfg = frame_telemetry.config()
|
||||
project = os.environ.get('FRAME_CONTROL_POSTHOG_PROJECT') or cfg.get('project')
|
||||
if not project:
|
||||
raise DBError('No PostHog project id (ui/telemetry.json "project", or FRAME_CONTROL_POSTHOG_PROJECT)')
|
||||
# The query API lives on the app host (eu.posthog.com), not the ingestion host (eu.i.posthog.com).
|
||||
host = cfg['host'].replace('.i.posthog.com', '.posthog.com')
|
||||
req = urllib.request.Request(f'{host}/api/projects/{urllib.parse.quote(str(project))}/query/', method='POST',
|
||||
data=json.dumps({'query': {'kind': 'HogQLQuery', 'query': sql}}).encode(),
|
||||
headers={'authorization': 'Bearer ' + posthog_personal_key(),
|
||||
'content-type': 'application/json'})
|
||||
try:
|
||||
with _opener.open(req, timeout=60) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise DBError(f'PostHog said HTTP {e.code}: {e.read()[:300]!r}')
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||
raise DBError(f"can't reach PostHog: {e}")
|
||||
|
||||
|
||||
SYNC_OVERLAP_DAYS = 30 # re-read this far back: offline copies send late, with their original time
|
||||
SYNC_PAGE = 5000
|
||||
|
||||
|
||||
def community_rows(events, state, cap=SYNC_DAILY_CAP):
|
||||
"""(reports, skipped): compat_report events as database rows. `state` ({"seen": {id: day},
|
||||
"counts": {"reporter|day": n}}) persists between syncs, so an event read twice is handled
|
||||
once and each reporter gets at most `cap` reports a day in total."""
|
||||
seen, counts = state.setdefault('seen', {}), state.setdefault('counts', {})
|
||||
out, skipped = [], []
|
||||
for props, reporter, ts in events:
|
||||
if isinstance(props, str):
|
||||
try:
|
||||
props = json.loads(props)
|
||||
except ValueError:
|
||||
props = None
|
||||
if not isinstance(props, dict):
|
||||
skipped.append((None, 'unreadable properties'))
|
||||
continue
|
||||
bad = [k for k in (*FIELDS, 'id') if props.get(k) is not None and not isinstance(props[k], (str, int, float))]
|
||||
if bad:
|
||||
skipped.append((str(props.get('id'))[:60], f'bad field {bad[0]}'))
|
||||
continue
|
||||
r = {k: (str(props[k]) if props.get(k) is not None else None) for k in FIELDS}
|
||||
r['id'] = str(props['id']) if props.get('id') is not None else None
|
||||
if r['id'] in seen:
|
||||
continue # handled in an earlier sync (or earlier in this one)
|
||||
r['via'] = COMMUNITY_VIA.get(r.get('via') or 'user', 'community')
|
||||
why = problem(r)
|
||||
if why:
|
||||
skipped.append((r.get('id'), why))
|
||||
continue
|
||||
day = str(ts)[:10]
|
||||
seen[r['id']] = day
|
||||
key_ = f'{reporter}|{day}'
|
||||
if counts.get(key_, 0) >= cap:
|
||||
skipped.append((r['id'], 'over the daily limit for one reporter'))
|
||||
continue
|
||||
counts[key_] = counts.get(key_, 0) + 1
|
||||
out.append(r)
|
||||
return out, skipped
|
||||
|
||||
|
||||
def _sync_state():
|
||||
try:
|
||||
with open(SYNC_STATE) as f:
|
||||
s = json.load(f)
|
||||
return s if isinstance(s, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_sync_state(s):
|
||||
"""Forget ids and counts older than the overlap window (plus a margin)."""
|
||||
cutoff = time.strftime('%Y-%m-%d', time.gmtime(time.time() - (SYNC_OVERLAP_DAYS + 15) * 86400))
|
||||
s['seen'] = {k: d for k, d in s.get('seen', {}).items() if d >= cutoff}
|
||||
s['counts'] = {k: n for k, n in s.get('counts', {}).items() if k.rsplit('|', 1)[-1] >= cutoff}
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(SYNC_STATE + '.tmp', 'w') as f:
|
||||
json.dump(s, f)
|
||||
os.replace(SYNC_STATE + '.tmp', SYNC_STATE)
|
||||
|
||||
|
||||
def sync(dry_run=False):
|
||||
"""Pull community reports from PostHog into the database. Returns (added, skipped).
|
||||
Reads the last SYNC_OVERLAP_DAYS each time, since events carry the time they were
|
||||
made, not when they arrived; the saved state keeps that from adding anything twice."""
|
||||
key() # the maintainer's copy only
|
||||
state = _sync_state()
|
||||
since = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(time.time() - SYNC_OVERLAP_DAYS * 86400))
|
||||
events = []
|
||||
for page in range(40):
|
||||
res = _posthog_query("SELECT properties, distinct_id, timestamp FROM events "
|
||||
f"WHERE event = 'compat_report' AND timestamp >= toDateTime('{since}') "
|
||||
f"ORDER BY timestamp, uuid LIMIT {SYNC_PAGE} OFFSET {page * SYNC_PAGE}")
|
||||
rows = res.get('results') or []
|
||||
events += rows
|
||||
if len(rows) < SYNC_PAGE:
|
||||
break
|
||||
rows, skipped = community_rows(events, state)
|
||||
if dry_run:
|
||||
return rows, skipped
|
||||
if rows:
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with _lock, open(OUTBOX, 'a') as f:
|
||||
f.writelines(json.dumps(r, ensure_ascii=False) + '\n' for r in rows)
|
||||
# Saved before sending: the rows are in the outbox now, and flush retries them if sending fails.
|
||||
_save_sync_state(state)
|
||||
flush() # also retries rows a failed earlier sync left in the outbox
|
||||
_mem['at'] = 0
|
||||
return rows, skipped
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['count']
|
||||
try:
|
||||
@@ -248,6 +390,12 @@ def main():
|
||||
'reports already in the database were not duplicated')
|
||||
elif cmd == 'flush':
|
||||
print(f'{flush()} still queued')
|
||||
elif cmd == 'sync':
|
||||
rows, skipped = sync(dry_run='--dry-run' in args)
|
||||
for rid, why in skipped:
|
||||
print(f'skipped {rid!r}: {why}', file=sys.stderr)
|
||||
print(f"{len(rows)} community reports {'found' if '--dry-run' in args else 'added'}, "
|
||||
f'{len(skipped)} skipped')
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
except DBError as e:
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Report a problem from inside Frame Control. Python stdlib only.
|
||||
|
||||
The page's Report a problem dialog shows the diagnostics below before anything
|
||||
is sent, then this sends the report to the website's feedback API
|
||||
(site/functions/api/feedback.js), which files it as a GitHub issue. The user
|
||||
needs no GitHub account. Everything collected is scrubbed first
|
||||
(frame_telemetry.scrub), since the issue is public.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import frame_host
|
||||
import frame_telemetry
|
||||
|
||||
FEEDBACK_URL = os.environ.get('FRAME_CONTROL_FEEDBACK_URL', 'https://frame-control.pages.dev/api/feedback')
|
||||
ISSUES_URL = 'https://github.com/saphid/frame-control/issues/new'
|
||||
KINDS = ('bug', 'idea', 'question', 'other')
|
||||
MESSAGE_MAX = 5000 # the feedback API's limit, in JavaScript (UTF-16) units
|
||||
TEXT_MAX = 3500 # the person's own text
|
||||
DIAG_MAX = 1300 # the diagnostics block, so text + diagnostics always fit MESSAGE_MAX
|
||||
LOG_LINES = 40
|
||||
ACTIVITY_LINES = 25
|
||||
HEAD = '\n\n---\nDiagnostics from Frame Control (personal details removed):\n```\n'
|
||||
TAIL = '\n```'
|
||||
|
||||
frame = {} # the Frame's last known SteamOS build, set by server.status()
|
||||
|
||||
|
||||
def u16(s):
|
||||
"""Length as the website's validator counts it (JavaScript strings are UTF-16)."""
|
||||
return len(s.encode('utf-16-le')) // 2
|
||||
|
||||
|
||||
def cut(s, n):
|
||||
"""s shortened to at most n UTF-16 units, never splitting a character."""
|
||||
while u16(s) > n:
|
||||
s = s[:max(0, len(s) - max(1, (u16(s) - n) // 2))]
|
||||
return s
|
||||
|
||||
|
||||
def _log_tail():
|
||||
"""The last lines of the server log the app writes (FRAME_CONTROL_LOG), newest first."""
|
||||
path = os.environ.get('FRAME_CONTROL_LOG')
|
||||
if not path:
|
||||
return []
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
f.seek(max(0, f.tell() - 64 * 1024))
|
||||
lines = f.read().decode('utf-8', 'replace').splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
# Request lines ("GET /api/status ...") are noise; keep what went wrong.
|
||||
keep = [ln for ln in lines if ln.strip() and not ln.startswith(('GET ', 'POST '))]
|
||||
return list(reversed(keep[-LOG_LINES:]))
|
||||
|
||||
|
||||
def diagnostics(activity=(), include_logs=False, limit=DIAG_MAX):
|
||||
"""What a report includes, scrubbed and at most `limit` UTF-16 units. Always the versions
|
||||
and builds; recent activity and the server log only when asked for, since they can name
|
||||
files. Sections are filled in order of use, newest lines first, so trimming drops the oldest."""
|
||||
t = frame_telemetry.state()
|
||||
levels = ', '.join(f"{name} {'on' if on else 'off'}" for name, on in
|
||||
(('usage', t['usage']), ('compat', t['compat']), ('error details', t['diagnostics'])))
|
||||
env = [
|
||||
f"Frame Control {frame_telemetry.app_version()}"
|
||||
f"{' (built app)' if os.environ.get('FRAME_CONTROL_PACKAGED') else ' (source checkout)'}",
|
||||
f"Computer: {frame_host.NAME} {platform.release()} {platform.machine()}, Python {'%d.%d.%d' % sys.version_info[:3]}",
|
||||
f"SteamOS: {frame.get('build') or 'unknown'} ({frame.get('version') or 'not connected since start'})",
|
||||
f"Analytics: {levels}",
|
||||
f"Report time: {time.strftime('%Y-%m-%d %H:%M %Z')}",
|
||||
]
|
||||
out = frame_telemetry.scrub('\n'.join(env), limit=limit)
|
||||
if not include_logs:
|
||||
return cut(out, limit)
|
||||
sections = [('Recent activity (newest first):', [str(a)[:300] for a in list(activity)[:ACTIVITY_LINES] if isinstance(a, str)]),
|
||||
('Server log (newest first):', _log_tail())]
|
||||
for title, lines in sections:
|
||||
if not lines:
|
||||
continue
|
||||
block = '\n\n' + title
|
||||
if u16(out + block) > limit:
|
||||
break
|
||||
out += block
|
||||
for line in lines:
|
||||
line = '\n' + frame_telemetry.scrub(line, 300)
|
||||
if u16(out + line) > limit:
|
||||
break
|
||||
out += line
|
||||
return out
|
||||
|
||||
|
||||
def compose(body):
|
||||
"""(title, message) for the feedback API: the person's text, then the diagnostics exactly as
|
||||
the dialog previewed them (passed back, scrubbed again and bounded here)."""
|
||||
title = ' '.join(str(body.get('title') or '').split())
|
||||
text = str(body.get('message') or '').strip()
|
||||
if len(title) < 5:
|
||||
raise ValueError('give it a short title (at least 5 characters)')
|
||||
if len(text) < 10:
|
||||
raise ValueError('say a little more about what happened (at least 10 characters)')
|
||||
title, text = cut(title, 120), cut(text, TEXT_MAX)
|
||||
diag = body.get('diagnostics')
|
||||
if not isinstance(diag, str) or not diag.strip():
|
||||
return title, text
|
||||
diag = cut(frame_telemetry.scrub(diag, 20000), DIAG_MAX)
|
||||
return title, f'{text}{HEAD}{diag}{TAIL}'
|
||||
|
||||
|
||||
def send(body):
|
||||
"""File the report. Returns {"number", "url"} of the new issue; raises ReportError."""
|
||||
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
||||
title, message = compose(body)
|
||||
payload = {'kind': kind, 'title': title, 'message': message, 'website': '',
|
||||
'github': str(body.get('github') or '').strip().lstrip('@')[:40],
|
||||
'version': frame_telemetry.app_version(), 'os': f'{frame_host.NAME} {platform.machine()}',
|
||||
'steamos': str(frame.get('build') or '')[:120],
|
||||
# How long the dialog was open; the API treats anything under 3 s as a script.
|
||||
'elapsed': max(0, int(body.get('elapsed') or 0))}
|
||||
req = urllib.request.Request(FEEDBACK_URL, data=json.dumps(payload).encode(), method='POST',
|
||||
headers={'content-type': 'application/json',
|
||||
'user-agent': f'FrameControl/{frame_telemetry.app_version()}'})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
res = json.loads(r.read() or b'{}')
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
why = json.loads(e.read() or b'{}').get('error')
|
||||
except ValueError:
|
||||
why = None
|
||||
e.close()
|
||||
raise ReportError(why or f'the feedback service said HTTP {e.code}')
|
||||
except (urllib.error.URLError, OSError, ValueError) as e:
|
||||
raise ReportError(f"couldn't reach the feedback service: {e}")
|
||||
if not res.get('url'):
|
||||
raise ReportError("the feedback service didn't file it; try again in a moment")
|
||||
frame_telemetry.capture('problem_reported', {'kind': kind, 'with_diagnostics': bool(body.get('diagnostics'))})
|
||||
return {'number': res.get('number'), 'url': res['url'], 'message': f"Sent. It's issue #{res.get('number')} on GitHub."}
|
||||
|
||||
|
||||
class ReportError(RuntimeError):
|
||||
pass
|
||||
@@ -0,0 +1,522 @@
|
||||
"""Anonymous analytics for Frame Control, sent to PostHog. Python stdlib only.
|
||||
|
||||
Three levels, each chosen in the page's Privacy panel (docs/privacy.md lists
|
||||
every event and property):
|
||||
|
||||
- usage (on by default, after the first-run notice has been shown): installs of
|
||||
Frame Control, daily opens, updates, which tabs are used, and whether installs
|
||||
on the Frame worked, with an error category from a fixed list. Never file
|
||||
names, paths, hostnames, IP addresses, window titles or account data.
|
||||
- compat (opt-in): Android compatibility reports, the same fields the Report
|
||||
dialog shows, so they reach the shared database (frame_compat_db.py). The
|
||||
maintainer's sync (python3 ui/frame_compat_db.py sync) moves them there.
|
||||
- diagnostics (opt-in): error messages and Python tracebacks, scrubbed of
|
||||
home folders, user names, addresses and keys.
|
||||
|
||||
The first-run notice offers compat and diagnostics together, and the page's
|
||||
Report a problem dialog (frame_report.py) files bug reports whatever is chosen
|
||||
here.
|
||||
|
||||
Events are identified by a random id made on first run, not by the person or
|
||||
computer, and sent without person profiles or GeoIP. Nothing is sent without a
|
||||
project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source
|
||||
checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or
|
||||
$FRAME_CONTROL_TELEMETRY=0.
|
||||
|
||||
Events wait in an outbox file and are sent in batches from a background thread,
|
||||
so going offline loses nothing. The last SENT_KEEP sent events are kept on this
|
||||
computer so the page can show exactly what left it.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import frame_host
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
STATE = frame_host.data_dir('telemetry')
|
||||
SETTINGS = STATE / 'settings.json'
|
||||
OUTBOX = STATE / 'outbox.jsonl'
|
||||
SENT = STATE / 'sent.jsonl'
|
||||
SENT_KEEP = 200
|
||||
OUTBOX_MAX = 2000 # events kept while offline; the oldest go first
|
||||
FLUSH_EVERY = 60
|
||||
REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds
|
||||
DEFAULT_HOST = 'https://eu.i.posthog.com'
|
||||
|
||||
LEVELS = ('usage', 'compat', 'diagnostics')
|
||||
# Events the page may send through /api/telemetry, and the properties each may carry.
|
||||
PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'},
|
||||
'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}}
|
||||
TABS = {'view', 'shots', 'library', 'getgames', 'android', 'transfer', 'apps', 'display', 'power', 'privacy'}
|
||||
|
||||
_lock = threading.RLock()
|
||||
_send_lock = threading.Lock() # held while sending; consent changes wait for it
|
||||
_seen_errors = {}
|
||||
_flusher = None
|
||||
_wake = threading.Event()
|
||||
|
||||
|
||||
# ---- configuration and settings -------------------------------------------------
|
||||
|
||||
def config():
|
||||
"""PostHog host and project key: the environment, else ui/telemetry.json."""
|
||||
try:
|
||||
with open(HERE / 'telemetry.json') as f:
|
||||
c = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
c = {}
|
||||
host = os.environ.get('FRAME_CONTROL_POSTHOG_HOST') or c.get('host') or DEFAULT_HOST
|
||||
key = os.environ.get('FRAME_CONTROL_POSTHOG_KEY') or c.get('key') or ''
|
||||
project = os.environ.get('FRAME_CONTROL_POSTHOG_PROJECT') or c.get('project') or ''
|
||||
return {'host': host.rstrip('/'), 'key': key, 'project': str(project)}
|
||||
|
||||
|
||||
def blocked():
|
||||
"""Why nothing may be sent at all, whatever the settings say, or None."""
|
||||
if os.environ.get('DO_NOT_TRACK') == '1' or os.environ.get('FRAME_CONTROL_TELEMETRY') == '0':
|
||||
return 'turned off by DO_NOT_TRACK or FRAME_CONTROL_TELEMETRY=0'
|
||||
if not config()['key']:
|
||||
return 'no PostHog project key in this build'
|
||||
if not os.environ.get('FRAME_CONTROL_PACKAGED') and os.environ.get('FRAME_CONTROL_TELEMETRY') != '1':
|
||||
return 'running from a source checkout (set FRAME_CONTROL_TELEMETRY=1 to send)'
|
||||
return None
|
||||
|
||||
|
||||
def _defaults():
|
||||
return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False,
|
||||
'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'frames_seen': [], 'compat_sent': []}
|
||||
|
||||
|
||||
def settings():
|
||||
with _lock:
|
||||
s = _defaults()
|
||||
try:
|
||||
with open(SETTINGS) as f:
|
||||
saved = json.load(f)
|
||||
if isinstance(saved, dict):
|
||||
s.update({k: v for k, v in saved.items() if k in s})
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if not SETTINGS.exists():
|
||||
_save(s) # keep the id stable from the first call
|
||||
return s
|
||||
|
||||
|
||||
def _save(s):
|
||||
try:
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
tmp = SETTINGS.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(s, indent=1))
|
||||
os.replace(tmp, SETTINGS)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def enabled(level):
|
||||
"""Whether events of this level are collected: never when sending is blocked, so a
|
||||
source checkout or a test run leaves nothing behind."""
|
||||
if blocked():
|
||||
return False
|
||||
return bool(settings().get(level))
|
||||
|
||||
|
||||
def update_settings(changes):
|
||||
"""Apply the page's choices. Turning a level off drops its unsent events; a send already
|
||||
under way finishes first, so nothing leaves after this returns."""
|
||||
with _send_lock, _lock:
|
||||
s = settings()
|
||||
if 'noticeShown' in changes:
|
||||
s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown']
|
||||
for level in LEVELS:
|
||||
if level in changes:
|
||||
s[level] = bool(changes[level])
|
||||
s['notice_shown'] = True
|
||||
_save(s)
|
||||
_drop_unwanted(s)
|
||||
if changes.get('compat'):
|
||||
backfill_compat()
|
||||
_wake.set()
|
||||
return state()
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows: the choices, why sending is blocked, and what was sent."""
|
||||
s = settings()
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'],
|
||||
'diagnostics': s['diagnostics'],
|
||||
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
|
||||
'sent': list(reversed(_read_lines(SENT)))[:50]}
|
||||
|
||||
|
||||
# ---- scrubbing and error categories ---------------------------------------------
|
||||
|
||||
def _user_names():
|
||||
names = set()
|
||||
for v in (os.environ.get('USER'), os.environ.get('USERNAME'), Path.home().name):
|
||||
if v and len(v) > 2:
|
||||
names.add(v)
|
||||
return names
|
||||
|
||||
|
||||
URL_RE = re.compile(r'[A-Za-z][A-Za-z0-9+.-]*://[^\s\'"<>]+')
|
||||
SCRUBS = [
|
||||
(re.compile(r'ssh-(?:rsa|ed25519|dss)\s+\S+'), '<ssh-key>'),
|
||||
(re.compile(r'-----BEGIN [^-]+-----.*?-----END [^-]+-----', re.S), '<pem>'),
|
||||
(re.compile(r'\b(?:phc|phx|ghp|gho|ghu|ghs|github_pat|sk|pk|rk|xox[abpr])[_-][A-Za-z0-9_-]{12,}'), '<token>'),
|
||||
(re.compile(r'(?i)\b(token|key|secret|password|passwd|pwd|auth|signature|sig)=[^\s&]+'), r'\1=<redacted>'),
|
||||
(re.compile(r'[\w.+-]+@[\w-]+(?:\.[\w-]+)+'), '<email>'),
|
||||
(re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b'), '<ip>'),
|
||||
(re.compile(r'\b(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}\b'), '<mac>'),
|
||||
(re.compile(r'\b7656119\d{10}\b'), '<steamid>'),
|
||||
(re.compile(r'\b(?:[\w-]+\.)+(?:local|lan|home|internal|localdomain|ts\.net)\b'), '<host>'),
|
||||
(re.compile(r'\b[0-9a-fA-F]{32,}\b'), '<hex>'),
|
||||
]
|
||||
IPV6_RE = re.compile(r'(?<![\w:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%\w+)?(?![\w:])')
|
||||
|
||||
|
||||
def _ipv6(m):
|
||||
try:
|
||||
ipaddress.IPv6Address(m.group(0).split('%')[0])
|
||||
return '<ip>'
|
||||
except ValueError:
|
||||
return m.group(0)
|
||||
|
||||
|
||||
def public_host(host):
|
||||
"""A host name that's safe to send: not an address, not a private or single-label name."""
|
||||
host = (host or '').lower().rstrip('.')
|
||||
if not host or '.' not in host:
|
||||
return None
|
||||
try:
|
||||
ipaddress.ip_address(host.strip('[]'))
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
if re.search(r'\.(?:local|lan|home|internal|localdomain|ts\.net|arpa)$', host) or not re.fullmatch(r'[a-z0-9.-]+', host):
|
||||
return None
|
||||
return host
|
||||
|
||||
|
||||
def _scrub_url(u):
|
||||
"""Only the scheme and a public host name of a URL; never user names, passwords, ports,
|
||||
paths or queries."""
|
||||
try:
|
||||
parts = urlsplit(u)
|
||||
host = public_host(parts.hostname)
|
||||
except ValueError:
|
||||
host = None
|
||||
return f'{parts.scheme}://{host}/…' if host else '<url>'
|
||||
|
||||
|
||||
def scrub(text, limit=2000):
|
||||
"""Text with URLs, home folders, user names, addresses, hosts, ids and keys replaced."""
|
||||
if text is None:
|
||||
return None
|
||||
t = URL_RE.sub(lambda m: _scrub_url(m.group(0)), str(text)) # first, before anything splits a URL
|
||||
home = str(Path.home())
|
||||
if len(home) > 3:
|
||||
t = t.replace(home, '~')
|
||||
t = re.sub(r'(/Users/|/home/|[A-Za-z]:\\Users\\)[^/\\\s]+', r'\1<user>', t)
|
||||
for pattern, repl in SCRUBS:
|
||||
t = pattern.sub(repl, t)
|
||||
t = IPV6_RE.sub(_ipv6, t)
|
||||
for name in _user_names():
|
||||
t = re.sub(r'\b%s\b' % re.escape(name), '<user>', t)
|
||||
return t[:limit]
|
||||
|
||||
|
||||
# From the most to the least specific; the first match wins.
|
||||
CATEGORIES = [
|
||||
('android_installer', re.compile(r'INSTALL_(?:FAILED|PARSE_FAILED)_[A-Z_]+')),
|
||||
('apk_needs_newer_android', re.compile(r'needs Android API')),
|
||||
('apk_wrong_abi', re.compile(r'no arm64-v8a build')),
|
||||
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name')),
|
||||
('cant_run_on_frame', re.compile(r"can't run on the Frame")),
|
||||
('steam_shortcut', re.compile(r'(?i)steam did not return a shortcut|shortcut list|no Steam shortcut')),
|
||||
('frame_not_set_up', re.compile(r'(?i)Could not resolve hostname|no "?frame"? (?:SSH )?alias')),
|
||||
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed')),
|
||||
('frame_unreachable', re.compile(r'(?i)timed out|Connection (?:refused|reset|closed)|No route to host|'
|
||||
r'Network is unreachable|Operation timed out|asleep|kex_exchange')),
|
||||
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
|
||||
('download_failed', re.compile(r'(?i)HTTP (?:Error )?\d{3}|URLError|download|certificate verify failed')),
|
||||
('flatpak', re.compile(r'(?i)flatpak|flathub')),
|
||||
('cancelled', re.compile(r'(?i)cancel')),
|
||||
('lepton', re.compile(r'(?i)lepton|podman|instance')),
|
||||
]
|
||||
|
||||
|
||||
def categorize(message):
|
||||
"""(category, detail): a fixed category name, plus an Android installer code when there is one."""
|
||||
text = str(message or '')
|
||||
for name, pattern in CATEGORIES:
|
||||
m = pattern.search(text)
|
||||
if m:
|
||||
return name, (m.group(0) if name == 'android_installer' else None)
|
||||
return 'other', None
|
||||
|
||||
|
||||
# ---- capturing ------------------------------------------------------------------
|
||||
|
||||
def _common():
|
||||
return {'app_version': app_version(), 'os': frame_host.NAME, 'arch': platform.machine().lower(),
|
||||
'python': '%d.%d' % sys.version_info[:2], '$lib': 'frame-control',
|
||||
# Anonymous events: no person profile, no location lookup.
|
||||
'$process_person_profile': False, '$geoip_disable': True}
|
||||
|
||||
|
||||
def app_version():
|
||||
v = os.environ.get('FRAME_CONTROL_VERSION')
|
||||
if v:
|
||||
return v
|
||||
try:
|
||||
with open(HERE.parent / 'app' / 'package.json') as f:
|
||||
return json.load(f).get('version') or 'dev'
|
||||
except (OSError, ValueError):
|
||||
return 'dev'
|
||||
|
||||
|
||||
def capture(event, props=None, level='usage'):
|
||||
"""Queue an event if its level is on. Never raises."""
|
||||
try:
|
||||
if level not in LEVELS or not enabled(level):
|
||||
return False
|
||||
s = settings()
|
||||
e = {'event': event, 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()),
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'properties': {**_common(), **(props or {}), 'level': level}}
|
||||
with _lock:
|
||||
lines = _read_lines(OUTBOX) + [e]
|
||||
_write_lines(OUTBOX, lines[-OUTBOX_MAX:])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def page_event(body):
|
||||
"""An event from the page, checked against PAGE_EVENTS."""
|
||||
name = body.get('event')
|
||||
allowed = PAGE_EVENTS.get(name)
|
||||
if allowed is None:
|
||||
raise ValueError('unknown event')
|
||||
props = {k: str(v)[:40] for k, v in (body.get('properties') or {}).items() if k in allowed}
|
||||
if name == 'tab_viewed' and props.get('tab') not in TABS:
|
||||
raise ValueError('unknown tab')
|
||||
return {'queued': capture(name, props)}
|
||||
|
||||
|
||||
def app_started():
|
||||
"""Once per server start: first install, an update, and one open a day."""
|
||||
if blocked():
|
||||
return
|
||||
with _lock:
|
||||
s = settings()
|
||||
version, today = app_version(), time.strftime('%Y-%m-%d')
|
||||
if not s['installed_sent']:
|
||||
capture('app_installed')
|
||||
s['installed_sent'] = True
|
||||
elif s['last_version'] and s['last_version'] != version:
|
||||
capture('app_updated', {'from_version': s['last_version']})
|
||||
if s['last_open_day'] != today:
|
||||
capture('app_opened')
|
||||
s['last_open_day'] = today
|
||||
s['last_version'] = version
|
||||
_save(s)
|
||||
|
||||
|
||||
def frame_seen(build, version):
|
||||
"""The Frame's SteamOS build, once per build (public build numbers)."""
|
||||
key = f'{build}/{version}'
|
||||
with _lock:
|
||||
s = settings()
|
||||
if not build or key in s['frames_seen']:
|
||||
return
|
||||
s['frames_seen'] = (s['frames_seen'] + [key])[-20:]
|
||||
_save(s)
|
||||
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
|
||||
|
||||
|
||||
def install_finished(kind, ok, seconds=None, error=None, **props):
|
||||
"""kind: apk, flatpak, steam, title or web. props must already be public (no file names)."""
|
||||
p = {'kind': kind, 'ok': bool(ok), **{k: v for k, v in props.items() if v is not None}}
|
||||
if seconds is not None:
|
||||
p['seconds'] = round(seconds, 1)
|
||||
if error is not None:
|
||||
p['error_category'], code = categorize(error)
|
||||
if code:
|
||||
p['installer_code'] = code
|
||||
capture('install_finished', p)
|
||||
if error is not None and not ok:
|
||||
diagnostic(f'{kind} install failed', error)
|
||||
|
||||
|
||||
def diagnostic(where, error, tb=None):
|
||||
"""An error for the opt-in diagnostics level: scrubbed text, and a traceback if there is one."""
|
||||
if not enabled('diagnostics'):
|
||||
return
|
||||
message = scrub(error)
|
||||
fingerprint = f'{where}|{message[:120]}'
|
||||
now = time.time()
|
||||
with _lock:
|
||||
if now - _seen_errors.get(fingerprint, 0) < REPEAT_WINDOW:
|
||||
return
|
||||
_seen_errors[fingerprint] = now
|
||||
exc_type = type(error).__name__ if isinstance(error, BaseException) else 'Error'
|
||||
frames = []
|
||||
if tb is None and isinstance(error, BaseException):
|
||||
tb = error.__traceback__
|
||||
for fs in traceback.extract_tb(tb) if tb else []:
|
||||
frames.append({'filename': os.path.basename(fs.filename), 'lineno': fs.lineno, 'function': fs.name,
|
||||
'in_app': True, 'platform': 'python'})
|
||||
capture('$exception', {'$exception_list': [{'type': exc_type, 'value': message,
|
||||
'mechanism': {'handled': True, 'type': 'generic'},
|
||||
'stacktrace': {'type': 'raw', 'frames': frames[-30:]}}],
|
||||
'$exception_type': exc_type, '$exception_message': message,
|
||||
'where': scrub(where, 200), 'error_category': categorize(error)[0]},
|
||||
level='diagnostics')
|
||||
|
||||
|
||||
COMPAT_FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton',
|
||||
'runtime', 'label', 'source', 'id')
|
||||
|
||||
|
||||
def compat_report(report):
|
||||
"""A compatibility report for the shared database (compat level only). Free text is
|
||||
scrubbed; the source is kept only as F-Droid or a public download host."""
|
||||
if not report.get('id') or not enabled('compat'):
|
||||
return False
|
||||
p = {k: report.get(k) for k in COMPAT_FIELDS if report.get(k) not in (None, '')}
|
||||
for k, n in (('notes', 1000), ('label', 120), ('version', 80)):
|
||||
if k in p:
|
||||
p[k] = scrub(p[k], n)
|
||||
src = str(p.pop('source', '') or '')
|
||||
if src == 'F-Droid':
|
||||
p['source'] = src
|
||||
elif src.startswith(('http://', 'https://')) and _scrub_url(src) != '<url>':
|
||||
p['source'] = _scrub_url(src)
|
||||
return capture('compat_report', p, level='compat')
|
||||
|
||||
|
||||
def backfill_compat():
|
||||
"""On opting in, share the reports this computer kept before (not ones already sent or queued)."""
|
||||
try:
|
||||
import frame_compat_db
|
||||
if frame_compat_db.shared():
|
||||
return 0 # the maintainer's copy writes to the database directly
|
||||
done = set(settings()['compat_sent'])
|
||||
done |= {e['properties'].get('id') for e in _read_lines(OUTBOX) if e.get('event') == 'compat_report'}
|
||||
n = 0
|
||||
for r in frame_compat_db._outbox():
|
||||
if r.get('id') not in done and compat_report(r):
|
||||
n += 1
|
||||
return n
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
# ---- the outbox -----------------------------------------------------------------
|
||||
|
||||
def _read_lines(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
out = []
|
||||
for line in f:
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except ValueError:
|
||||
pass
|
||||
return out
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _write_lines(path, rows):
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
tmp = Path(str(path) + '.tmp')
|
||||
with open(tmp, 'w') as f:
|
||||
f.writelines(json.dumps(r, ensure_ascii=False) + '\n' for r in rows)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _drop_unwanted(s):
|
||||
"""Unsent events whose level is now off never leave the computer."""
|
||||
keep = {level: s[level] for level in LEVELS}
|
||||
rows = _read_lines(OUTBOX)
|
||||
kept = [e for e in rows if keep.get(e.get('properties', {}).get('level'), False)]
|
||||
if len(kept) != len(rows):
|
||||
_write_lines(OUTBOX, kept)
|
||||
|
||||
|
||||
def flush(timeout=20):
|
||||
"""Send what's queued. Returns how many were sent; on failure they stay queued."""
|
||||
with _send_lock:
|
||||
if blocked() or not settings()['notice_shown']:
|
||||
return 0
|
||||
cfg = config()
|
||||
with _lock:
|
||||
_drop_unwanted(settings())
|
||||
batch = _read_lines(OUTBOX)[:100]
|
||||
if not batch:
|
||||
return 0
|
||||
body = json.dumps({'api_key': cfg['key'], 'batch': batch}).encode()
|
||||
req = urllib.request.Request(cfg['host'] + '/batch/', data=body, method='POST',
|
||||
headers={'content-type': 'application/json',
|
||||
'user-agent': f'FrameControl/{app_version()}'})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
e.close()
|
||||
return 0
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return 0
|
||||
sent_ids = {e['uuid'] for e in batch}
|
||||
with _lock:
|
||||
_write_lines(OUTBOX, [e for e in _read_lines(OUTBOX) if e.get('uuid') not in sent_ids])
|
||||
_write_lines(SENT, (_read_lines(SENT) + batch)[-SENT_KEEP:])
|
||||
compat = [e['properties'].get('id') for e in batch if e.get('event') == 'compat_report']
|
||||
if compat: # remembered only once PostHog has them, so an opt-out before sending can't lose them
|
||||
s = settings()
|
||||
s['compat_sent'] = (s['compat_sent'] + compat)[-5000:]
|
||||
_save(s)
|
||||
return len(batch)
|
||||
|
||||
|
||||
def start():
|
||||
"""Record this start and send in the background from now on."""
|
||||
global _flusher
|
||||
try:
|
||||
app_started()
|
||||
except Exception:
|
||||
pass
|
||||
if _flusher:
|
||||
return
|
||||
|
||||
def loop():
|
||||
while True:
|
||||
try:
|
||||
while flush() == 100: # a full batch: there may be more
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
_wake.wait(FLUSH_EVERY)
|
||||
_wake.clear()
|
||||
|
||||
_flusher = threading.Thread(target=loop, name='telemetry', daemon=True)
|
||||
_flusher.start()
|
||||
|
||||
|
||||
def wake():
|
||||
_wake.set()
|
||||
+260
-10
@@ -44,7 +44,12 @@
|
||||
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
|
||||
.brand svg { width: 30px; height: 30px; }
|
||||
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
|
||||
nav { display: flex; gap: 4px; height: 100%; }
|
||||
/* The tabs shrink and scroll sideways rather than push the header wider than the window. */
|
||||
nav { display: flex; gap: 4px; height: 100%; min-width: 0; flex-shrink: 1; overflow-x: auto; scrollbar-width: none;
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); }
|
||||
nav::-webkit-scrollbar { display: none; }
|
||||
nav a { flex: none; }
|
||||
@media (max-width: 1600px) { header { gap: 18px; } nav a { padding: 0 9px !important; } }
|
||||
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
|
||||
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
|
||||
border-bottom: 3px solid transparent; transition: color .15s; }
|
||||
@@ -195,6 +200,17 @@
|
||||
.actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.actions button { justify-content: flex-start; height: 40px; }
|
||||
.actions svg { width: 16px; height: 16px; flex: none; opacity: .85; }
|
||||
.notice { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 12px 16px; border-radius: 4px;
|
||||
background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; }
|
||||
.notice .grow { flex: 1; min-width: 260px; }
|
||||
.notice .progress { width: 160px; margin-top: 0; display: block; }
|
||||
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
|
||||
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
|
||||
.popt b { font-weight: 600; color: var(--text); }
|
||||
.popt .sub { grid-column: 2; line-height: 1.45; }
|
||||
.sentlog { max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 3px; padding: 10px;
|
||||
font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
|
||||
details summary { cursor: pointer; color: var(--link); font-size: 13px; margin-top: 12px; }
|
||||
.links { margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.06); font-size: 13px; color: var(--muted); }
|
||||
.links a { color: var(--link); text-decoration: none; } .links a:hover { color: #fff; }
|
||||
.links div { margin: 5px 0; }
|
||||
@@ -221,10 +237,18 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#repDlg, #titleDlg, #wiDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||||
#bugForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
||||
#bugForm .popt { margin: 14px 0 0; }
|
||||
#bugForm .sentlog { max-height: 200px; }
|
||||
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
@@ -309,16 +333,36 @@
|
||||
<a href="#apps">Apps</a>
|
||||
<a href="#display">Display</a>
|
||||
<a href="#power">Power</a>
|
||||
<a href="#privacy">Privacy</a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<span class="chip" id="conn"><span class="dot"></span><span>Connecting…</span></span>
|
||||
<span class="chip" id="battChip" title="Battery">—</span>
|
||||
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
|
||||
</button>
|
||||
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="notice" id="updateBar" hidden>
|
||||
<div class="grow" id="updateText"></div>
|
||||
<div class="progress" id="updateProg" hidden><i></i></div>
|
||||
<button class="small" id="updateNotes">What's new</button>
|
||||
<button class="action small" id="updateGo">Update and restart</button>
|
||||
<button class="small" id="updateLater">Later</button>
|
||||
</div>
|
||||
<div class="notice" id="privacyNotice" hidden>
|
||||
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
|
||||
your operating system, which tabs you use, and whether installs on the Frame worked. Never file names, paths,
|
||||
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
|
||||
worked and the details of errors, which helps fix problems faster.</div>
|
||||
<button class="small" id="noticeSettings">Privacy settings</button>
|
||||
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
|
||||
<button class="action small" id="noticeOk">OK</button>
|
||||
</div>
|
||||
<div class="grid-top" id="view">
|
||||
<section class="panel">
|
||||
<div class="toolbar">
|
||||
@@ -543,6 +587,24 @@
|
||||
<div><a href="https://framedropvr.com" target="_blank">FrameDrop</a>: sideloader (Windows only for now)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="privacy">
|
||||
<div class="shelf-head"><h2>Privacy & updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
|
||||
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
|
||||
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
|
||||
worked (with an error category, never the message). F-Droid package names only.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
|
||||
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
|
||||
compatibility database, so the verdicts get better for everyone.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="tDiag"><b>Send error details</b>
|
||||
<span class="sub">Error messages and where in Frame Control they happened, with your home
|
||||
folder, user name, addresses and keys removed.</span></label>
|
||||
<div class="hint" id="tStatus"></div>
|
||||
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
|
||||
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
|
||||
<button class="small" id="updateCheck" hidden>Check for updates</button>
|
||||
<a class="sub" href="https://github.com/saphid/frame-control/blob/main/docs/privacy.md" target="_blank">What's collected, exactly</a></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -581,6 +643,30 @@
|
||||
<button type="submit" class="action small" id="repSave">Save report</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="bugDlg" aria-labelledby="bugTitle">
|
||||
<form method="dialog" id="bugForm">
|
||||
<h2 id="bugTitle">Report a problem</h2>
|
||||
<label class="field">What kind of report?<select id="bugKind">
|
||||
<option value="bug">Something's broken</option><option value="idea">An idea</option>
|
||||
<option value="question">A question</option><option value="other">Something else</option></select></label>
|
||||
<label class="field">Title<input type="text" id="bugTitleIn" maxlength="120" required minlength="5"
|
||||
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
|
||||
<label class="field">What happened?<textarea id="bugText" maxlength="3500" required minlength="10"
|
||||
placeholder="What you did, what happened, and what you expected."></textarea></label>
|
||||
<label class="field">GitHub username (optional, so you can follow replies)<input type="text" id="bugGithub" maxlength="40" placeholder="@yourname"></label>
|
||||
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
|
||||
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
|
||||
<span class="sub">Often shows what went wrong, but can contain file and app names. Check it below before sending.</span></label>
|
||||
<details id="bugDiagBox"><summary>Show exactly what's included</summary><div class="sentlog" id="bugDiagText">Loading…</div></details>
|
||||
<p id="bugWarn">This becomes a public issue on GitHub (saphid/frame-control). No GitHub account is needed.</p>
|
||||
<div class="row rep-actions"><span class="sub" id="bugMsg"></span><span class="spacer"></span>
|
||||
<button type="button" class="small" id="bugCancel">Cancel</button>
|
||||
<button type="button" class="small" id="bugCopy">Copy report</button>
|
||||
<a class="small" id="bugGithubLink" href="#" target="_blank" hidden>Open on GitHub instead</a>
|
||||
<button type="submit" class="action small" id="bugSend">Send report</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="titleDlg" aria-labelledby="titleTitle">
|
||||
<form method="dialog" id="titleForm">
|
||||
<h2 id="titleTitle">Add to the Steam library</h2>
|
||||
@@ -1162,7 +1248,8 @@ async function sendFiles(files, dirs = new Set()) {
|
||||
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
|
||||
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||||
const apk = f.name.toLowerCase().endsWith(".apk");
|
||||
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||||
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||||
if (apk && res && res.app) await offerTest(res.app);
|
||||
}
|
||||
$("fileInput").value = "";
|
||||
refresh();
|
||||
@@ -1544,7 +1631,8 @@ document.body.addEventListener("click", async e => {
|
||||
const pkg = b.dataset.pkg, action = b.dataset.and;
|
||||
if (action === "install") {
|
||||
toast(`Installing ${b.dataset.name}. The first time takes a minute…`);
|
||||
await act(`Install ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||||
const res = await act(`Install ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||||
if (res && res.app) await offerTest(res.app);
|
||||
} else if (action === "remove") {
|
||||
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
||||
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||||
@@ -1661,13 +1749,19 @@ loadDisplays();
|
||||
const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", runs: "Runs (test)",
|
||||
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
||||
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
||||
let repShared = false;
|
||||
function setRepHint() {
|
||||
$("repHint").textContent = repShared
|
||||
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||||
: telemetry.compat && !telemetry.blocked
|
||||
? "Reports change the verdicts you see and are shared with Frame Control's compatibility database (Privacy settings). Any APK can be reported, including ones not on F-Droid."
|
||||
: "Reports are saved on this computer and change the verdicts you see. Turn on Share compatibility results in Privacy settings to add them to the shared database. Any APK can be reported, including ones not on F-Droid.";
|
||||
}
|
||||
async function loadReports() {
|
||||
let reps, shared;
|
||||
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
|
||||
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||||
$("repHint").textContent = shared
|
||||
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||||
: "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
|
||||
repShared = shared; setRepHint();
|
||||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||
$("repList").innerHTML = reps.length ? reps.slice(0, 40).map(r => {
|
||||
const k = r.rating || r.result;
|
||||
@@ -1820,7 +1914,163 @@ const spy = new IntersectionObserver(entries => {
|
||||
const top = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
||||
if (top) document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + top.target.id));
|
||||
}, { rootMargin: "-80px 0px -55% 0px" });
|
||||
["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power"].forEach(id => spy.observe($(id)));
|
||||
["view", "shots", "library", "getgames", "android", "transfer", "apps", "display", "power", "privacy"].forEach(id => spy.observe($(id)));
|
||||
|
||||
// ---- after an APK install: test it, so the compatibility database learns whether it runs ----
|
||||
async function offerTest(m) {
|
||||
if (!m.package || !confirm(`${m.label || m.package} is installed. Test it now?\n\nIt opens in the headset for about 20 seconds `
|
||||
+ "and records whether it stays up.")) return;
|
||||
toast(`Testing ${m.label || m.package}: launching it and watching for 20 s…`);
|
||||
await act(`Test ${m.label || m.package}`, () => api("/api/android", { action: "probe", package: m.package }));
|
||||
loadReports();
|
||||
}
|
||||
|
||||
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
||||
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
||||
function renderTelemetry(s) {
|
||||
Object.assign(telemetry, s);
|
||||
setRepHint();
|
||||
$("tUsage").checked = s.usage; $("tCompat").checked = s.compat; $("tDiag").checked = s.diagnostics;
|
||||
$("tStatus").textContent = s.blocked ? `Nothing is being sent: ${s.blocked}.`
|
||||
: `${s.queued ? s.queued + " waiting to send. " : ""}Your anonymous id is ${s.id.slice(0, 8)}…; it isn't linked to you or this computer.`;
|
||||
$("tSent").textContent = s.sent.length ? s.sent.map(e => JSON.stringify({ event: e.event, time: e.timestamp, ...e.properties })).join("\n\n")
|
||||
: "Nothing sent yet.";
|
||||
const showNotice = !s.blocked && !s.noticeShown && s.usage;
|
||||
$("privacyNotice").hidden = !showNotice;
|
||||
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
|
||||
}
|
||||
async function loadTelemetry() {
|
||||
try { renderTelemetry(await api("/api/telemetry")); } catch {}
|
||||
}
|
||||
async function setTelemetry(change) {
|
||||
try { renderTelemetry(await api("/api/telemetry", change)); }
|
||||
catch (e) { toast(`Couldn't save: ${e.message}`, true); loadTelemetry(); }
|
||||
}
|
||||
$("tUsage").onchange = e => setTelemetry({ usage: e.target.checked });
|
||||
$("tCompat").onchange = e => setTelemetry({ compat: e.target.checked });
|
||||
$("tDiag").onchange = e => setTelemetry({ diagnostics: e.target.checked });
|
||||
$("tSentBox").ontoggle = () => { if ($("tSentBox").open) loadTelemetry(); };
|
||||
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; };
|
||||
$("noticeMore").onclick = async () => {
|
||||
$("privacyNotice").hidden = true;
|
||||
await setTelemetry({ compat: true, diagnostics: true });
|
||||
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
|
||||
};
|
||||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; $("privacy").scrollIntoView(); };
|
||||
loadTelemetry();
|
||||
function pageEvent(event, properties) {
|
||||
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
||||
}
|
||||
// Which tabs get used: once per tab per session.
|
||||
const tabsSeen = new Set();
|
||||
document.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => {
|
||||
const tab = a.getAttribute("href").slice(1);
|
||||
if (!tabsSeen.has(tab)) { tabsSeen.add(tab); pageEvent("tab_viewed", { tab }); }
|
||||
}));
|
||||
|
||||
// ---- report a problem (ui/frame_report.py): a GitHub issue via the website, with diagnostics ----
|
||||
const bug = { opened: 0, preview: "" };
|
||||
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
|
||||
function bugReportText() {
|
||||
const body = `${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
|
||||
return { title: $("bugTitleIn").value.trim(), body };
|
||||
}
|
||||
function bugGithubUrl() {
|
||||
const { title, body } = bugReportText();
|
||||
return "https://github.com/saphid/frame-control/issues/new?labels=feedback&title=" + encodeURIComponent(title)
|
||||
+ "&body=" + encodeURIComponent(body.slice(0, 6000));
|
||||
}
|
||||
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
|
||||
async function loadBugPreview() {
|
||||
if (!$("bugDiag").checked) { bug.preview = ""; $("bugDiagText").textContent = "Nothing: diagnostics are off."; return; }
|
||||
$("bugDiagText").textContent = "Loading…";
|
||||
try { bug.preview = (await api("/api/report/preview", { activity: activityLines(), includeLogs: $("bugLogs").checked })).text; }
|
||||
catch (e) { bug.preview = ""; $("bugDiagText").textContent = `Couldn't collect diagnostics: ${e.message}`; return; }
|
||||
$("bugDiagText").textContent = bug.preview;
|
||||
}
|
||||
function openBugReport() {
|
||||
$("bugForm").reset();
|
||||
$("bugMsg").textContent = ""; $("bugGithubLink").hidden = true; $("bugSend").disabled = false;
|
||||
$("bugCancel").textContent = "Cancel";
|
||||
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
|
||||
bug.opened = performance.now();
|
||||
$("bugDlg").showModal();
|
||||
loadBugPreview();
|
||||
}
|
||||
document.body.addEventListener("click", e => { if (e.target.closest("[data-report]")) openBugReport(); });
|
||||
$("bugDiag").onchange = () => { $("bugLogs").disabled = !$("bugDiag").checked; loadBugPreview(); };
|
||||
$("bugLogs").onchange = loadBugPreview;
|
||||
$("bugCancel").onclick = () => $("bugDlg").close();
|
||||
$("bugCopy").onclick = async () => {
|
||||
const { title, body } = bugReportText();
|
||||
try { await navigator.clipboard.writeText(`${title}\n\n${body}`); $("bugMsg").textContent = "Copied."; }
|
||||
catch { $("bugMsg").textContent = "Couldn't copy; select the text under Show exactly what's included."; }
|
||||
};
|
||||
$("bugForm").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
if (!$("bugForm").reportValidity()) return;
|
||||
$("bugSend").disabled = true; $("bugMsg").textContent = "Sending…";
|
||||
try {
|
||||
const res = await api("/api/report", {
|
||||
kind: $("bugKind").value, title: $("bugTitleIn").value, message: $("bugText").value,
|
||||
github: $("bugGithub").value, diagnostics: $("bugDiag").checked ? bug.preview : "",
|
||||
elapsed: Math.round(performance.now() - bug.opened) });
|
||||
$("bugMsg").innerHTML = `Sent. <a href="${esc(res.url)}" target="_blank">Issue #${esc(String(res.number))}</a> on GitHub.`;
|
||||
$("bugCancel").textContent = "Close";
|
||||
log(res.message, "ok");
|
||||
} catch (err) {
|
||||
$("bugMsg").textContent = `Couldn't send it: ${err.message}.`;
|
||||
$("bugGithubLink").href = bugGithubUrl(); $("bugGithubLink").hidden = false;
|
||||
$("bugSend").disabled = false;
|
||||
}
|
||||
};
|
||||
if (window.frameApp && window.frameApp.onReportProblem) window.frameApp.onReportProblem(openBugReport);
|
||||
|
||||
// ---- updates (the desktop app only: app/updater.js) ----
|
||||
const upd = { dismissed: false, offered: null };
|
||||
function renderUpdate(s) {
|
||||
if (!s) return;
|
||||
$("appVersion").textContent = `Version ${s.current}`;
|
||||
const r = s.latest;
|
||||
const show = r && ["available", "downloading", "ready", "error"].includes(s.status) && !upd.dismissed
|
||||
&& !(s.status === "error" && !r);
|
||||
$("updateBar").hidden = !show;
|
||||
if (!show) return;
|
||||
if (s.status === "available" && upd.offered !== r.version) { upd.offered = r.version; pageEvent("update_offered", { to_version: r.version }); }
|
||||
const busy = s.status === "downloading" || s.status === "ready";
|
||||
$("updateText").innerHTML = s.status === "error"
|
||||
? `Updating to ${esc(r.version)} didn't work: ${esc(s.error || "unknown error")}`
|
||||
: busy ? `Downloading Frame Control ${esc(r.version)}… It restarts when it's ready.`
|
||||
: `<b>Frame Control ${esc(r.version)} is available.</b> You have ${esc(s.current)}.`
|
||||
+ (s.canInstall ? "" : ` ${esc(s.why ? "It can't update itself here (" + s.why + ")," : "")} download it from the release page.`);
|
||||
$("updateProg").hidden = !busy;
|
||||
$("updateProg").firstElementChild.style.width = Math.round((s.progress || 0) * 100) + "%";
|
||||
$("updateGo").textContent = s.canInstall ? (s.status === "error" ? "Try again" : "Update and restart") : "Open release page";
|
||||
$("updateGo").disabled = busy; $("updateLater").hidden = busy;
|
||||
}
|
||||
if (window.frameApp && window.frameApp.update) {
|
||||
window.frameApp.update.onState(renderUpdate);
|
||||
window.frameApp.update.get().then(renderUpdate);
|
||||
$("updateCheck").hidden = false;
|
||||
$("updateCheck").onclick = async () => {
|
||||
const btn = $("updateCheck");
|
||||
btn.disabled = true;
|
||||
let s;
|
||||
try { s = await window.frameApp.update.check(); } finally { btn.disabled = false; }
|
||||
upd.dismissed = false; renderUpdate(s);
|
||||
if (s && s.status === "none") toast(`You have the newest version (${s.current})`);
|
||||
if (s && s.status === "check-failed") toast(`Couldn't check for updates: ${s.error}`, true);
|
||||
};
|
||||
$("updateGo").onclick = () => {
|
||||
pageEvent("update_started", { to_version: upd.offered || "" });
|
||||
window.frameApp.update.install();
|
||||
};
|
||||
$("updateLater").onclick = () => { upd.dismissed = true; $("updateBar").hidden = true; };
|
||||
$("updateNotes").onclick = async () => {
|
||||
const s = await window.frameApp.update.get();
|
||||
if (s && s.latest) window.open(s.latest.page, "_blank");
|
||||
};
|
||||
}
|
||||
|
||||
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
|
||||
// The app passes each link here. The server checks it and reads the manifest;
|
||||
|
||||
+95
-8
@@ -36,7 +36,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import frame_android # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_report # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_telemetry # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
import frame_webinstall # noqa: E402
|
||||
|
||||
@@ -153,7 +155,12 @@ def terminal(argv):
|
||||
# ---- actions ---------------------------------------------------------------
|
||||
|
||||
def status(_body):
|
||||
return json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20))
|
||||
s = json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20))
|
||||
osr = s.get("os") if isinstance(s, dict) else None
|
||||
if isinstance(osr, dict):
|
||||
frame_telemetry.frame_seen(osr.get("build"), osr.get("version"))
|
||||
frame_report.frame.update(build=osr.get("build"), version=osr.get("version"))
|
||||
return s
|
||||
|
||||
|
||||
def headset_view():
|
||||
@@ -335,7 +342,16 @@ def steam(body):
|
||||
raise Failure("bad appid", 400)
|
||||
if action not in ("install", "store"):
|
||||
raise Failure("action must be install or store", 400)
|
||||
return steam_frame(action, appid)
|
||||
if action == "store":
|
||||
return steam_frame(action, appid)
|
||||
# Starts Steam's download; Steam reports the rest in the headset.
|
||||
try:
|
||||
res = steam_frame(action, appid)
|
||||
except Failure as e:
|
||||
frame_telemetry.install_finished("steam", False, error=e, steam_appid=appid)
|
||||
raise
|
||||
frame_telemetry.install_finished("steam", True, steam_appid=appid)
|
||||
return res
|
||||
|
||||
|
||||
def steam_search(query):
|
||||
@@ -408,10 +424,16 @@ def flatpak(body):
|
||||
if not FLATPAK_ID.match(app):
|
||||
raise Failure("bad Flatpak app ID", 400)
|
||||
if action == "install":
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=900)
|
||||
start = time.time()
|
||||
try:
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=900)
|
||||
except Failure as e:
|
||||
frame_telemetry.install_finished("flatpak", False, time.time() - start, e, flatpak_id=app)
|
||||
raise
|
||||
frame_telemetry.install_finished("flatpak", True, time.time() - start, flatpak_id=app)
|
||||
return {"message": f"Installed {app}"}
|
||||
if action == "uninstall":
|
||||
out = ssh(f"flatpak uninstall --user -y -- {shlex.quote(app)}", timeout=300)
|
||||
@@ -468,13 +490,37 @@ def android(body):
|
||||
runtime=body.get("runtime") or "instance",
|
||||
label=body.get("label"), source=body.get("source"))
|
||||
name = r.get("label") or pkg
|
||||
where = "" if frame_catalog.compat_db.shared() else " on this computer"
|
||||
where = ("" if frame_catalog.compat_db.shared() else
|
||||
" and shared it" if frame_telemetry.enabled("compat") else " on this computer")
|
||||
return {"message": f"Saved your report for {name}{where}", "report": r}
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e))
|
||||
raise Failure("unknown action", 400)
|
||||
|
||||
|
||||
# Errors that are the APK's own fault, so they belong in the compatibility
|
||||
# database as install_failed. Connection trouble and the like don't.
|
||||
APK_FAULTS = {"android_installer", "apk_needs_newer_android", "apk_wrong_abi"}
|
||||
|
||||
|
||||
def apk_installed(info, meta, error, seconds):
|
||||
"""Every APK install (catalogue, dropped file, web link): usage analytics, and an
|
||||
install_failed report when the APK itself wouldn't install."""
|
||||
pkg = (info or {}).get("package")
|
||||
by_pkg = frame_catalog._cache.get("by_pkg") or {}
|
||||
in_catalog = bool(pkg) and pkg in by_pkg
|
||||
# Package names only for catalogue apps, which are public; a private APK's name stays here.
|
||||
# No version: a local rebuild can share a catalogue app's package name but carry anything in its version.
|
||||
frame_telemetry.install_finished("apk", error is None, seconds, error, catalog=in_catalog,
|
||||
package=pkg if in_catalog else None)
|
||||
if error is not None and pkg and frame_telemetry.categorize(error)[0] in APK_FAULTS:
|
||||
frame_catalog.add_report(pkg, info.get("version"), result="install_failed", notes=str(error)[:300],
|
||||
via="install", label=info.get("label"))
|
||||
|
||||
|
||||
frame_android.install_hooks.append(apk_installed)
|
||||
|
||||
|
||||
# ---- Sideloaded titles (Linux/Windows builds as Steam Devkit Games) --------
|
||||
#
|
||||
# Installing is two steps: inspect (a dropped file is uploaded and a zip
|
||||
@@ -528,14 +574,18 @@ def _run_title_install(token, entry, name, exe, runtime):
|
||||
with _titles_lock:
|
||||
_title_jobs[token].update(fields)
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
m = frame_titles.install_plan(entry["plan"], name=name, exe=exe, runtime=runtime,
|
||||
progress=lambda stage, fraction: update(stage=stage, fraction=fraction))
|
||||
update(title=m, message=f"Installed {m['id']} in the Steam library ({m['runtime_label']})")
|
||||
frame_telemetry.install_finished("title", True, time.time() - start, runtime=m.get("runtime"))
|
||||
except frame_android.FrameError as e:
|
||||
update(error=str(e))
|
||||
frame_telemetry.install_finished("title", False, time.time() - start, e)
|
||||
except Exception as e:
|
||||
update(error=f"{type(e).__name__}: {e}")
|
||||
frame_telemetry.install_finished("title", False, time.time() - start, e)
|
||||
finally:
|
||||
_drop_staged(entry)
|
||||
update(done=True, time=time.time())
|
||||
@@ -964,10 +1014,16 @@ def _webinstall_run(plan, job):
|
||||
ensure_master()
|
||||
res = frame_webinstall.dispatch(path, name=plan["name"], exe=plan["exe"], progress=detail, source=plan["url"])
|
||||
job["message"], job["phase"] = res["message"], "done"
|
||||
if res.get("kind") != "apk": # APKs are counted by apk_installed
|
||||
frame_telemetry.install_finished("web", True, kind_detail=res.get("kind"))
|
||||
except Exception as e:
|
||||
stage = job.get("phase") # download or install, before it becomes "error"
|
||||
known = (frame_webinstall.WebInstallError, Failure, frame_android.FrameError)
|
||||
job["error"] = str(e) if isinstance(e, known) else f"{type(e).__name__}: {e}"
|
||||
job["phase"] = "error"
|
||||
# An APK that failed to install was counted by apk_installed.
|
||||
if not isinstance(e, frame_webinstall.Cancelled) and not (stage == "install" and plan.get("kind") == "apk"):
|
||||
frame_telemetry.install_finished("web", False, error=e, stage=stage, kind_detail=plan.get("kind"))
|
||||
finally:
|
||||
with _web_lock:
|
||||
job.pop("_conn", None)
|
||||
@@ -1066,14 +1122,36 @@ def _sweep_one(prefix, d):
|
||||
pass
|
||||
|
||||
|
||||
# ---- Report a problem (frame_report.py) --------------------------------------
|
||||
|
||||
def report_preview(body):
|
||||
"""Exactly the diagnostics a report would include, for the dialog to show first."""
|
||||
return {"text": frame_report.diagnostics(body.get("activity") or (), include_logs=bool(body.get("includeLogs")))}
|
||||
|
||||
|
||||
def report_send(body):
|
||||
try:
|
||||
return frame_report.send(body)
|
||||
except frame_report.ReportError as e:
|
||||
raise Failure(str(e))
|
||||
|
||||
|
||||
POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel}
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||
"/api/report/preview": report_preview, "/api/report": report_send}
|
||||
|
||||
|
||||
# ---- HTTP ------------------------------------------------------------------
|
||||
|
||||
def action_of(body):
|
||||
"""The action a request asked for, for diagnostics: a short word, never user data."""
|
||||
a = body.get("action") if isinstance(body, dict) else None
|
||||
return a if isinstance(a, str) and re.fullmatch(r"[a-z]{1,20}", a) else ""
|
||||
|
||||
|
||||
def _pipe_reader(pipe):
|
||||
"""Chunks from a pipe via a thread; select() can't wait on pipes on Windows."""
|
||||
chunks = queue.Queue() # unbounded: the pump never blocks, so it ends at EOF
|
||||
@@ -1182,6 +1260,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"shared": frame_catalog.compat_db.shared()})
|
||||
elif path == "/api/android/catalog":
|
||||
self.send_json({"apps": frame_catalog.catalog()})
|
||||
elif path == "/api/telemetry":
|
||||
self.send_json(frame_telemetry.state())
|
||||
elif path == "/api/status":
|
||||
self.send_json(status({}))
|
||||
elif path == "/api/steam/owned":
|
||||
@@ -1208,12 +1288,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except frame_android.FrameError as e:
|
||||
self.send_json({"error": str(e)}, 502)
|
||||
except Exception as e:
|
||||
frame_telemetry.diagnostic(f"GET {path}", e)
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
def do_POST(self):
|
||||
if not self.local_request():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
body = None
|
||||
try:
|
||||
if path == "/api/upload":
|
||||
self.send_json(self.upload())
|
||||
@@ -1230,12 +1312,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
self.send_json(handler(body))
|
||||
except Failure as e:
|
||||
if e.status >= 500:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
self.send_json({"error": str(e)}, e.status)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||
except frame_android.FrameError as e:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
self.send_json({"error": str(e)}, 502)
|
||||
except Exception as e:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
def stream_video(self, query):
|
||||
@@ -1356,6 +1442,7 @@ def main():
|
||||
args = ap.parse_args()
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
sweep_tmp()
|
||||
frame_telemetry.start()
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
if args.exit_on_eof:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"host": "https://eu.i.posthog.com",
|
||||
"key": "",
|
||||
"project": ""
|
||||
}
|
||||
Reference in new issue
Block a user