diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 04cfeaf..29cfd85 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,7 +14,40 @@ permissions:
contents: write
jobs:
+ # One job makes the draft, before the builds: three matrix jobs each running
+ # "view || create" could race and make duplicate drafts. The draft is tied to
+ # the pushed tag (the tag must already exist on GitHub, then tag_name is set
+ # explicitly), so scripts/publish-release.sh and `gh release upload` find it
+ # by tag. It is created with the REST API so the id comes straight back: the
+ # release list can lag a fresh draft (v0.4.2's first run found nothing and
+ # PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
+ # this step against tests/fakegh.
+ draft:
+ if: startsWith(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Create the draft release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ run: |
+ tag="${GITHUB_REF_NAME}"
+ # A rerun finds the draft an earlier attempt made.
+ id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
+ if [ -z "$id" ]; then
+ # Given a missing tag, GitHub would create it on the default branch.
+ gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
+ || { echo "tag $tag isn't on GitHub" >&2; exit 1; }
+ id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
+ -F draft=true -f body="" --jq .id)
+ fi
+ [ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
+ gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
+
build:
+ needs: draft
+ # Still runs for pull requests and manual runs, where the draft job is skipped.
+ if: ${{ !failure() && !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -40,15 +73,27 @@ jobs:
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
+ # The installer, not just the unpacked build: its 7-Zip payload once dropped
+ # the OpenXR layer's arm64 library without any error. Install it silently
+ # and compare every installed file with the unpacked build.
+ - name: Check the Windows installer installs every file
+ if: matrix.os == 'windows-latest'
+ shell: pwsh
+ run: |
+ $setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
+ $p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
+ if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
+ $installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
+ for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
+ node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Upload to the release
if: startsWith(github.ref, 'refs/tags/')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
- tag="${GITHUB_REF_NAME}"
- gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
- gh release upload "$tag" ${{ matrix.files }} --clobber
+ gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
- uses: actions/upload-artifact@v4
with:
name: frame-control-${{ matrix.os }}
diff --git a/README.md b/README.md
index 1fbfcd5..cdd94d8 100644
--- a/README.md
+++ b/README.md
@@ -213,6 +213,8 @@ Frame's software fits together, all checked against a real headset and labelled
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
+| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
+| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
diff --git a/apk-catalog/reports.py b/apk-catalog/reports.py
index b425d04..6429910 100644
--- a/apk-catalog/reports.py
+++ b/apk-catalog/reports.py
@@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed |
instance_failed, from an automated test), rating (works | issues | broken, from
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
Newest wins, and a person's rating beats an automated result.
+
+An install_failed report saying the file had no arm64-v8a build (or needed a
+newer Android) is about that one APK file, often the wrong per-ABI download of
+an app that has an arm64 build, so it says nothing about the app and is left out.
"""
+import re
+
+FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
+
+
+def about_app(r):
+ """False for reports about one wrong APK file rather than the app itself."""
+ return not (r.get('result') == 'install_failed' and not r.get('rating')
+ and FILE_FAULT.search(r.get('notes') or ''))
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
+ reports = [r for r in reports or () if about_app(r)]
if not reports:
return None
rs = sorted(reports, key=lambda r: r.get('date') or '')
diff --git a/app/build/check-resources.js b/app/build/check-resources.js
new file mode 100644
index 0000000..4eef75e
--- /dev/null
+++ b/app/build/check-resources.js
@@ -0,0 +1,79 @@
+// Checks that the app's resources hold what Frame Control can't work without.
+// The OpenXR compatibility layer is why this exists: VR APK installs need its
+// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
+//
+// As electron-builder's afterPack hook it checks the unpacked app. From the
+// command line it checks an installed copy, which is what caught the Windows
+// installer silently dropping the library (see .github/workflows/release.yml):
+// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
+// With a reference (the unpacked build's resources), every file in it must also
+// be in the installed copy, at the same size.
+const fs = require("fs");
+const path = require("path");
+
+const REQUIRED = [
+ "ui/server.py",
+ "ui/frame_android.py",
+ "frame/android/lepton-app.sh",
+ "frame/openxr-compat/XrApiLayer_FRAME_compat.json",
+ "frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
+];
+
+function resourcesDir(context) {
+ if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
+ const app = `${context.packager.appInfo.productFilename}.app`;
+ return path.join(context.appOutDir, app, "Contents", "Resources");
+ }
+ return path.join(context.appOutDir, "resources");
+}
+
+function size(file) {
+ try {
+ return fs.statSync(file).size;
+ } catch {
+ return -1;
+ }
+}
+
+// Required files that are missing or empty.
+function missing(dir) {
+ return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
+}
+
+// Files under reference that aren't in dir at the same size.
+function differences(dir, reference) {
+ const out = [];
+ (function walk(rel) {
+ for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
+ const r = path.join(rel, e.name);
+ if (e.isDirectory()) walk(r);
+ else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
+ }
+ })("");
+ return out;
+}
+
+exports.default = async function afterPack(context) {
+ const dir = resourcesDir(context);
+ const gone = missing(dir);
+ if (gone.length) {
+ throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
+ }
+};
+exports.missing = missing;
+exports.differences = differences;
+exports.REQUIRED = REQUIRED;
+
+if (require.main === module) {
+ const [dir, reference] = process.argv.slice(2);
+ if (!dir) {
+ console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
+ process.exit(2);
+ }
+ const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
+ if (problems.length) {
+ console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
+ process.exit(1);
+ }
+ console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
+}
diff --git a/app/main.js b/app/main.js
index 4497136..5150efb 100644
--- a/app/main.js
+++ b/app/main.js
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
-const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
+const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -11,6 +11,7 @@ const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const updater = require("./updater");
+const { createMetaSignIn } = require("./meta-signin");
const run = promisify(execFile);
@@ -115,10 +116,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.
+ // The version, the time zone 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,
+ FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
@@ -149,7 +151,7 @@ async function startServer() {
const target = `http://127.0.0.1:${port}/`;
for (let i = 0; i < 100; i++) {
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
- if (await ping(target)) { url = target; return; }
+ if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
@@ -175,18 +177,27 @@ function stopServer() {
if (server) endServer(server);
}
-function errorPage(message) {
+function errorPage(message, title = "Frame Control couldn't start") {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
const html = `
-
Frame Control couldn't start
-
${esc(message)}
Fix it, then choose Frame → Restart Server.
`;
+
${esc(title)}
+
${esc(message)}
+
+
Frame → Restart Server does the same.
`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
+// A server that had been running starts again by itself (something stopped it: a
+// signal, a crash). One that stops again within a minute shows the error instead,
+// so a server that can't stay up doesn't restart forever.
+let serverStarted = 0;
function serverDied(why) {
url = null;
- if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
+ if (!win) return;
+ if (Date.now() - serverStarted > 60000) restartServer();
+ else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
}
// Restarts that overlap share one: two could each start a server, and the one
@@ -263,7 +274,20 @@ function fromUi(e) {
} catch { return false; }
}
+// The error page's Try Again button. The error page is the only data: page the window
+// shows (`url` can still be set then: the server answered but the page failed to load).
+ipcMain.handle("server:restart", (e) => {
+ if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
+});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
+// A PNG or JPEG (a screenshot) onto the clipboard as an image.
+ipcMain.handle("clipboard:writeImage", (e, bytes) => {
+ if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
+ const img = nativeImage.createFromBuffer(Buffer.from(bytes));
+ if (img.isEmpty()) throw new Error("not an image");
+ clipboard.writeImage(img);
+ return true;
+});
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
@@ -325,6 +349,47 @@ ipcMain.handle("comfort:notify", (e, message) => {
});
});
+// ---- Sign in with Meta (app/meta-signin.js, docs/meta-library.md) ----
+// The token goes from the sign-in window's own session straight to the server's
+// connect path; the page asks for it and only hears the server's answer.
+function postApi(apiPath, body) {
+ return new Promise((resolve, reject) => {
+ if (!url) return reject(new Error("Frame Control's server isn't running."));
+ const data = JSON.stringify(body);
+ const req = http.request(new URL(apiPath, url), {
+ method: "POST", timeout: 60000,
+ headers: { "Content-Type": "application/json", "X-Frame-UI": process.env.FRAME_UI_KEY || "1",
+ "Content-Length": Buffer.byteLength(data) },
+ }, (res) => {
+ let text = "";
+ res.setEncoding("utf8");
+ res.on("data", (c) => { text += c; });
+ res.on("end", () => {
+ let parsed = {};
+ try { parsed = JSON.parse(text); } catch {}
+ resolve({ status: res.statusCode, data: parsed });
+ });
+ });
+ req.on("error", reject);
+ req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
+ req.end(data);
+ });
+}
+
+let metaSignIn = null;
+const meta = () => (metaSignIn ||= createMetaSignIn({
+ BrowserWindow, session, shell, parent: () => win,
+ // The page only offers sign-in after its "I understand" box is ticked (checked again below).
+ connect: (token, attempt) => postApi("/api/meta", { action: "connect", token, acknowledged: true, attempt }),
+ cancel: (attempt) => postApi("/api/meta", { action: "cancel-signin", attempt }),
+}));
+ipcMain.handle("meta:signin", (e, acknowledged) => {
+ if (!fromUi(e)) return null;
+ if (acknowledged !== true) return { ok: false, error: "My Meta library is experimental: tick that you understand the warning first." };
+ return meta().signIn();
+});
+ipcMain.handle("meta:signout", (e) => fromUi(e) ? meta().signOut() : false);
+
// 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),
// so they wait here until the page asks for them. The page checks the link with
diff --git a/app/meta-signin.js b/app/meta-signin.js
new file mode 100644
index 0000000..1da77ea
--- /dev/null
+++ b/app/meta-signin.js
@@ -0,0 +1,252 @@
+// "Sign in with Meta" for My Meta library (docs/meta-library.md), the way BSManager's
+// "Log in with Meta" works: Meta's own login page in a Frame Control window, then the
+// access token cookie (oc_ac_at) that page sets is handed to the local server's
+// existing connect path (format check, "who am I" with Meta, 0600 file).
+//
+// The window has its own persistent session (PARTITION), so the cookie is read only
+// from there: never from Frame Control's main window, never from any browser. It has
+// no preload and no Node, and it only navigates within Meta's login sites; anything
+// else opens in the system browser. Nothing here logs the token, and the page only
+// ever gets back the server's answer (connected, display name), never the token.
+// Electron is passed in, so app/test/meta-signin.test.js can run it with fakes.
+const crypto = require("crypto");
+
+const PARTITION = "persist:meta-signin";
+// BSManager's loginUrl (src/main/services/bs-version-download/bs-oculus-downloader.service.ts).
+const LOGIN_URL = "https://secure.oculus.com";
+const COOKIE = "oc_ac_at";
+// Sites the login can go through: the Oculus store web, Meta accounts, Facebook login,
+// and Instagram (Meta's login passes through www.instagram.com: seen live, 2026-10-14).
+const ALLOWED = ["oculus.com", "meta.com", "facebook.com", "instagram.com"];
+const TIMEOUT = 10 * 60 * 1000; // as BSManager
+
+const underDomain = (host, domains) => {
+ host = String(host || "").replace(/^\./, "").toLowerCase();
+ return domains.some((d) => host === d || host.endsWith("." + d));
+};
+
+// Plain HTTPS on the default port, to one of the login sites.
+function allowedUrl(raw) {
+ let u;
+ try { u = new URL(String(raw)); } catch { return false; }
+ if (u.protocol !== "https:" || u.username || u.password || (u.port && u.port !== "443")) return false;
+ return underDomain(u.hostname, ALLOWED);
+}
+
+// Frames inside the page: any HTTPS address (Meta's login can embed captcha and CDN
+// frames from other sites), or an empty frame; never data:, blob:, javascript:, http:,
+// file: or any other scheme. Frames can't reach the cookie read or IPC (main process only).
+function allowedSubframeUrl(raw) {
+ if (/^about:(?:blank|srcdoc)$/.test(String(raw))) return true;
+ let u;
+ try { u = new URL(String(raw)); } catch { return false; }
+ return u.protocol === "https:" && !u.username && !u.password;
+}
+
+const onOculus = (raw) => { try { return underDomain(new URL(raw).hostname, ["oculus.com"]); } catch { return false; } };
+
+// A cheap first look, so a signed-out or app value is never sent anywhere; the server's
+// check_token (ui/frame_meta.py) has the final word.
+function looksLikeUserToken(v) {
+ return typeof v === "string" && /^(?:OC|FRL)[A-Za-z0-9+/=_-]{18,4093}$/.test(v)
+ && !/^OC\d{15}$/.test(v) && /[a-z]/.test(v);
+}
+
+const fingerprint = (v) => crypto.createHash("sha256").update(v).digest("hex");
+
+// Anything token-shaped out of a message, in case a server error ever carried one.
+function scrub(text, token) {
+ let s = String(text == null ? "" : text);
+ if (token) s = s.split(token).join("");
+ return s.replace(/(?:OC|FRL)[A-Za-z0-9+/=_%|:-]{8,}/g, "").slice(0, 600);
+}
+
+// deps: { BrowserWindow, session, shell, parent() -> BrowserWindow|null, timeout,
+// connect(token, attempt) -> Promise<{status, data}> (the server's connect action),
+// cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) }
+function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) {
+ let active = null; // { win, promise, finish }
+ const pending = new Set(); // cancels still to reach the server, from flows already finished
+ let signingOut = null; // the sign-out under way; no sign-in starts while it runs
+ const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment.";
+
+ const openOutside = (target) => {
+ if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target));
+ };
+
+ function signIn() {
+ if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT });
+ if (active) { active.win.focus(); return active.promise; }
+ const ses = session.fromPartition(PARTITION);
+ ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
+ ses.setPermissionCheckHandler(() => false);
+ // Electron adds its own name and the app's to the user agent; send a plain Chrome one.
+ if (ses.getUserAgent && ses.setUserAgent) {
+ ses.setUserAgent(ses.getUserAgent().replace(/\s(?:Electron|frame-control|Frame Control)\/\S+/g, ""));
+ }
+ const p = parent();
+ const win = new BrowserWindow({
+ width: 520, height: 760, title: "Sign in with Meta – Frame Control", autoHideMenuBar: true,
+ ...(p ? { parent: p } : {}),
+ webPreferences: { partition: PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true,
+ nodeIntegrationInSubFrames: false, webviewTag: false, safeDialogs: true, devTools: false },
+ });
+ const wc = win.webContents;
+
+ let resolve;
+ const promise = new Promise((r) => { resolve = r; });
+ let done = false, busy = false, again = false, lastError = null, blocked = null;
+ const tried = new Set(); // fingerprints of values already sent, so each goes once
+ // The server refuses to save a connect from an attempt this window gave up on.
+ const attemptId = crypto.randomUUID();
+
+ const onCookie = (_e, cookie, _cause, removed) => {
+ if (!removed && cookie && cookie.name === COOKIE && underDomain(cookie.domain, ["oculus.com"])) attempt();
+ };
+ const timer = setTimeout(() => finish({ ok: false, error: "Sign-in timed out after 10 minutes. Try again." }), timeout);
+ if (timer.unref) timer.unref();
+
+ // Stops cookie intake first, then the window (destroy: a page's beforeunload can't
+ // keep it open), and answers the page. Unless it connected, and if it sent a token, it
+ // then has the server drop this attempt: only once the connect under way has settled,
+ // so the server always sees the cancel after the connect (and deletes the token if
+ // that connect saved it). The returned promise settles when that is done.
+ let finished = null;
+ let inflight = null; // the last connect sent
+ function finish(result) {
+ if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed"
+ done = true;
+ ses.cookies.removeListener("changed", onCookie);
+ clearTimeout(timer);
+ if (active && active.win === win) active = null;
+ if (!win.isDestroyed()) win.destroy();
+ resolve(result);
+ if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel
+ finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {});
+ pending.add(finished);
+ finished.then(() => pending.delete(finished));
+ return finished;
+ }
+
+ async function attempt() {
+ if (done) return;
+ if (busy) { again = true; return; }
+ busy = true;
+ try {
+ do {
+ again = false;
+ // Only this window's own session, and only Meta's Oculus cookie.
+ const cookies = await ses.cookies.get({ name: COOKIE });
+ const c = cookies.find((k) => underDomain(k.domain, ["oculus.com"]) && looksLikeUserToken(k.value));
+ if (!c || done) continue;
+ const key = fingerprint(c.value);
+ if (tried.has(key)) continue;
+ tried.add(key);
+ let r;
+ try {
+ inflight = connect(c.value, attemptId);
+ r = await inflight;
+ } catch {
+ lastError = "Frame Control's local server didn't answer.";
+ continue;
+ }
+ if (done) return;
+ const d = (r && r.data) || {};
+ if (r && r.status === 200 && d.connected) {
+ finish({ ok: true, connected: true, user: d.user && d.user.name ? { name: scrub(d.user.name, c.value).slice(0, 80) } : null,
+ message: scrub(d.message || "Connected your Meta account (Experimental)", c.value) });
+ return;
+ }
+ lastError = scrub(d.error || `HTTP ${r && r.status}`, c.value);
+ } while (again && !done);
+ } catch {
+ // The session went away (the window closed); nothing to do.
+ } finally {
+ busy = false;
+ }
+ }
+
+ // Navigation stays on Meta's login sites. A link elsewhere opens in the system
+ // browser; a redirect elsewhere is just stopped (it may carry sign-in data).
+ // Every frame: the window itself stays on the list; frames inside it may load any
+ // HTTPS page (captchas), and one going anywhere else is just stopped.
+ wc.on("will-navigate", (e, target) => { if (!allowedUrl(target)) { e.preventDefault(); openOutside(target); } });
+ // A stopped page change in the window itself says where it went (the host only), so
+ // closing the stuck window explains why instead of "closed before you signed in".
+ const stopped = (to) => {
+ let host = "";
+ try { host = new URL(String(to)).hostname; } catch { /* not an address */ }
+ blocked = `Meta's sign-in went to ${scrub(host || "an address", null).slice(0, 80)}, which Frame Control doesn't allow.`;
+ };
+ wc.on("will-frame-navigate", (e) => {
+ if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return;
+ e.preventDefault();
+ if (e.isMainFrame) stopped(e.url);
+ });
+ // Redirects fire for frames too: the same rule per frame (details on the event in
+ // current Electron, positional arguments in older ones; unknown counts as the window).
+ wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => {
+ const to = e && e.url !== undefined ? e.url : target;
+ const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false;
+ if (main ? allowedUrl(to) : allowedSubframeUrl(to)) return;
+ e.preventDefault();
+ if (main) stopped(to);
+ });
+ // A page's beforeunload never keeps the window open.
+ wc.on("will-prevent-unload", (e) => e.preventDefault());
+ wc.setWindowOpenHandler(({ url: target }) => {
+ if (allowedUrl(target)) wc.loadURL(target).catch(() => {});
+ else openOutside(target);
+ return { action: "deny" };
+ });
+ // Signed in: the cookie changes, or the window lands back on Meta's store site.
+ ses.cookies.on("changed", onCookie);
+ wc.on("did-navigate", (_e, target) => { blocked = null; if (onOculus(target)) attempt(); }); // it recovered
+ wc.on("did-navigate-in-page", (_e, target) => { if (onOculus(target)) attempt(); });
+ win.on("closed", () => finish({ ok: false, cancelled: true,
+ error: lastError ? `The sign-in window closed before Frame Control could connect. ${lastError}`
+ : blocked ? `The sign-in window was closed before you signed in. ${blocked}`
+ : "The sign-in window was closed before you signed in." }));
+
+ active = { win, promise, finish };
+ wc.loadURL(LOGIN_URL).catch(() => {});
+ return promise;
+ }
+
+ // Signs out of Meta in Frame Control: stops any sign-in under way (and has the server
+// refuse to save it), destroys the window, then clears everything
+ // the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh.
+ // One at a time (a second call joins the first), and no sign-in can start meanwhile.
+ // It goes round until no window is open and no connect or cancel is left, so nothing
+ // started before it can set a token after it (or after the Disconnect that follows).
+ function signOut() {
+ if (!signingOut) {
+ signingOut = (async () => {
+ for (;;) {
+ if (active) {
+ const { win, finish } = active;
+ await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." });
+ // Clear only once nothing can write to the session again.
+ if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again.");
+ } else if (pending.size) {
+ // A connect under way settles and is cancelled before the caller (Disconnect)
+ // deletes the account, so it can't be saved afterwards.
+ await Promise.all([...pending]);
+ } else {
+ break;
+ }
+ }
+ const ses = session.fromPartition(PARTITION);
+ await ses.clearStorageData();
+ await ses.clearCache();
+ if (ses.clearAuthCache) await ses.clearAuthCache();
+ return true;
+ })().finally(() => { signingOut = null; });
+ }
+ return signingOut;
+ }
+
+ return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut };
+}
+
+module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED };
diff --git a/app/package-lock.json b/app/package-lock.json
index 264b1dd..8467e4a 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frame-control",
- "version": "0.4.0",
+ "version": "0.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
- "version": "0.4.0",
+ "version": "0.4.7",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
diff --git a/app/package.json b/app/package.json
index f59e889..93e695c 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
- "version": "0.4.0",
+ "version": "0.4.7",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -29,6 +29,7 @@
]
}
],
+ "afterPack": "build/check-resources.js",
"directories": {
"output": "dist",
"buildResources": "build"
@@ -38,6 +39,7 @@
"preload.js",
"install-link.js",
"updater.js",
+ "meta-signin.js",
"package.json",
"build/icon.png"
],
@@ -49,6 +51,7 @@
"*.py",
"*.html",
"*.js",
+ "*.css",
"telemetry.json"
]
},
@@ -81,7 +84,7 @@
"to": "frame/openxr-compat",
"filter": [
"XrApiLayer_FRAME_compat.json",
- "prebuilt/**/*.so"
+ "prebuilt/**/*.so.gz"
]
},
{
diff --git a/app/preload.js b/app/preload.js
index 8ab5b8f..740d94b 100644
--- a/app/preload.js
+++ b/app/preload.js
@@ -2,17 +2,20 @@
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
-// It can open Set Up Connection when the headset can't be reached, and keeps the
-// Frame menu's list of headsets up to date.
+// It can put a screenshot on the clipboard as an image, open Set Up Connection when
+// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
// 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.
+// And it passes update state both ways: see app/updater.js. It opens the Sign in
+// with Meta window and signs out of it (app/meta-signin.js).
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
+ writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
+ restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
@@ -37,6 +40,12 @@ contextBridge.exposeInMainWorld("frameApp", {
ipcRenderer.removeAllListeners("report:open");
ipcRenderer.on("report:open", () => cb());
},
+ // Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
+ // answer is the server's (connected, display name), never the token.
+ meta: {
+ signIn: (acknowledged) => ipcRenderer.invoke("meta:signin", acknowledged === true),
+ signOut: () => ipcRenderer.invoke("meta:signout"),
+ },
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
diff --git a/app/test/meta-signin.test.js b/app/test/meta-signin.test.js
new file mode 100644
index 0000000..5873667
--- /dev/null
+++ b/app/test/meta-signin.test.js
@@ -0,0 +1,578 @@
+// Run: node --test app/test/
+// "Sign in with Meta" (app/meta-signin.js) with fake Electron windows, sessions and cookies.
+const nodeTest = require("node:test");
+// A broken flow can leave a sign-in waiting forever; fail it instead of hanging.
+const test = (name, fn) => nodeTest(name, { timeout: 5000 }, fn);
+const assert = require("node:assert");
+const { EventEmitter } = require("node:events");
+const { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE } = require("../meta-signin");
+
+const TOKEN = "OCAbcdefghijklmnopQRSTUVwxyz0123456789abcdEFGH";
+const OTHER = "OCzyxwvutsrqponmlkJIHGFEDcba9876543210zyxwVUTS";
+
+class FakeCookies extends EventEmitter {
+ constructor() { super(); this.store = []; this.reads = 0; }
+ async get(filter) {
+ this.reads++;
+ return this.store.filter((c) => !filter.name || c.name === filter.name);
+ }
+ set(cookie) { // as the page would: store it, then tell listeners
+ this.store = this.store.filter((c) => !(c.name === cookie.name && c.domain === cookie.domain)).concat(cookie);
+ this.emit("changed", {}, cookie, "explicit", false);
+ }
+}
+
+class FakeSession {
+ constructor(name, log) { this.name = name; this.log = log; this.cookies = new FakeCookies(); this.cleared = []; this.permission = null; }
+ setPermissionRequestHandler(fn) { this.permission = fn; }
+ setPermissionCheckHandler(fn) { this.permissionCheck = fn; }
+ getUserAgent() { return "Mozilla/5.0 Chrome/140.0 frame-control/0.4.4 Electron/44.4.5 Safari/537.36"; }
+ setUserAgent(ua) { this.ua = ua; }
+ async clearStorageData(opts) { this.log.push("clear"); this.cleared.push(["storage", opts]); }
+ async clearCache() { this.cleared.push(["cache"]); }
+ async clearAuthCache() { this.cleared.push(["auth"]); }
+}
+
+function fakes({ timeout } = {}) {
+ const log = []; // the order things happened in
+ const sessions = new Map();
+ const defaultSession = new FakeSession("default", log);
+ const session = {
+ defaultSession,
+ fromPartition(name) {
+ if (!sessions.has(name)) sessions.set(name, new FakeSession(name, log));
+ return sessions.get(name);
+ },
+ };
+ const windows = [];
+ class BrowserWindow extends EventEmitter {
+ constructor(opts) {
+ super();
+ this.opts = opts;
+ this.destroyed = false;
+ this.veto = false; // a page whose beforeunload keeps the window open on close()
+ this.focused = 0;
+ const wc = new EventEmitter();
+ wc.loaded = [];
+ wc.loadURL = async (u) => { wc.loaded.push(u); };
+ wc.setWindowOpenHandler = (fn) => { wc.openHandler = fn; };
+ // Deliberately a different session: the code must read the partition's, not guess.
+ wc.session = defaultSession;
+ this.webContents = wc;
+ windows.push(this);
+ }
+ isDestroyed() { return this.destroyed; }
+ focus() { this.focused++; }
+ close() { if (this.destroyed || this.veto) return; this.destroy(); }
+ destroy() { if (this.destroyed) return; this.destroyed = true; log.push("destroyed"); this.emit("closed"); }
+ }
+ const opened = [];
+ const shell = { openExternal: (u) => { opened.push(u); } };
+ const calls = [], attempts = [], cancels = [];
+ let answer = () => ({ status: 200, data: { connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" } });
+ const connect = async (token, attempt) => { calls.push(token); attempts.push(attempt); log.push("connect"); return answer(token); };
+ const cancel = async (attempt) => {
+ cancels.push(attempt);
+ log.push(`cancel (cookie listeners: ${session.fromPartition(PARTITION).cookies.listenerCount("changed")})`);
+ };
+ const flow = createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent: () => null, ...(timeout ? { timeout } : {}) });
+ return { flow, session, sessions, windows, opened, calls, attempts, cancels, log, setAnswer: (fn) => { answer = fn; },
+ meta: () => session.fromPartition(PARTITION) };
+}
+
+const tick = () => new Promise((r) => setImmediate(r));
+async function settle() { for (let i = 0; i < 10; i++) await tick(); }
+
+function navigate(wc, event, target) {
+ let prevented = false;
+ wc.emit(event, { preventDefault: () => { prevented = true; } }, target);
+ return prevented;
+}
+
+test("only plain HTTPS addresses on Meta's login sites are allowed", () => {
+ for (const ok of ["https://secure.oculus.com", "https://secure.oculus.com/my/profile", "https://auth.meta.com/login/",
+ "https://www.meta.com/", "https://www.facebook.com/login.php", "https://oculus.com:443/",
+ "https://www.instagram.com/accounts/login/"]) {
+ assert.ok(allowedUrl(ok), ok);
+ }
+ for (const bad of ["http://secure.oculus.com/", "https://oculus.com.evil.example/", "https://evil-oculus.com/",
+ "https://notmeta.com/", "https://example.com/", "javascript:alert(1)", "file:///etc/passwd",
+ "https://secure.oculus.com:8443/", "https://user:pw@secure.oculus.com/", "https://evil.example/?u=https://www.meta.com",
+ "data:text/html,hi", "not a url"]) {
+ assert.ok(!allowedUrl(bad), bad);
+ }
+});
+
+test("the window is isolated: its own partition, no preload, no Node, sandboxed, on Meta's login page", async () => {
+ const f = fakes();
+ f.flow.signIn();
+ const [w] = f.windows;
+ const wp = w.opts.webPreferences;
+ assert.strictEqual(wp.partition, PARTITION);
+ assert.strictEqual(PARTITION, "persist:meta-signin");
+ assert.strictEqual(wp.contextIsolation, true);
+ assert.strictEqual(wp.nodeIntegration, false);
+ assert.strictEqual(wp.sandbox, true);
+ assert.strictEqual(wp.preload, undefined);
+ assert.strictEqual(wp.devTools, false);
+ assert.deepStrictEqual(w.webContents.loaded, [LOGIN_URL]);
+ assert.strictEqual(LOGIN_URL, "https://secure.oculus.com");
+ // Permission prompts (camera, notifications...) are refused.
+ let granted = null;
+ f.meta().permission(null, "media", (v) => { granted = v; });
+ assert.strictEqual(granted, false);
+ assert.strictEqual(f.meta().permissionCheck(null, "notifications", "https://secure.oculus.com"), false);
+ assert.ok(!/Electron|frame-control/.test(f.meta().ua));
+ w.close();
+});
+
+test("navigation stays on Meta's sites; other links open in the system browser", async () => {
+ const f = fakes();
+ f.flow.signIn();
+ const wc = f.windows[0].webContents;
+ assert.strictEqual(navigate(wc, "will-navigate", "https://auth.meta.com/login/"), false);
+ assert.strictEqual(navigate(wc, "will-navigate", "https://www.facebook.com/login.php"), false);
+ assert.strictEqual(navigate(wc, "will-navigate", "https://example.com/help"), true);
+ assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
+ // A redirect elsewhere is stopped and not opened anywhere (it may carry sign-in data).
+ assert.strictEqual(navigate(wc, "will-redirect", "https://evil.example/cb?x=1"), true);
+ assert.strictEqual(navigate(wc, "will-redirect", "https://auth.meta.com/next"), false);
+ assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
+ // New windows: never created. Meta's own load in this window; others go outside; non-web schemes nowhere.
+ assert.deepStrictEqual(wc.openHandler({ url: "https://www.meta.com/legal/terms/" }), { action: "deny" });
+ assert.deepStrictEqual(wc.openHandler({ url: "https://example.org/" }), { action: "deny" });
+ assert.deepStrictEqual(wc.openHandler({ url: "file:///etc/passwd" }), { action: "deny" });
+ await settle();
+ assert.deepStrictEqual(wc.loaded, [LOGIN_URL, "https://www.meta.com/legal/terms/"]);
+ assert.deepStrictEqual(f.opened, ["https://example.com/help", "https://example.org/"]);
+ f.windows[0].close();
+});
+
+test("the cookie is read only from the sign-in partition, only for Meta's Oculus domain", async () => {
+ const f = fakes();
+ // The same cookie elsewhere: the default session and another partition. Never read.
+ f.session.defaultSession.cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
+ f.session.fromPartition("persist:other").cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
+ const done = f.flow.signIn();
+ const w = f.windows[0];
+ // A cookie with the right name on another site, and a signed-out value: ignored.
+ f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: OTHER });
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: "OC123456789012345" });
+ w.webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
+ await settle();
+ assert.deepStrictEqual(f.calls, []);
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ const r = await done;
+ assert.deepStrictEqual(f.calls, [TOKEN]);
+ assert.strictEqual(r.ok, true);
+ assert.strictEqual(f.session.defaultSession.cookies.reads, 0);
+ assert.strictEqual(f.session.fromPartition("persist:other").cookies.reads, 0);
+ assert.ok(f.sessions.get(PARTITION).cookies.reads > 0);
+});
+
+test("landing on the logged-in page picks up a cookie set earlier", async () => {
+ const f = fakes();
+ const done = f.flow.signIn();
+ f.meta().cookies.store.push({ name: COOKIE, domain: "secure.oculus.com", value: TOKEN }); // no change event
+ f.windows[0].webContents.emit("did-navigate", {}, "https://auth.meta.com/login/"); // not Meta's store site yet
+ await settle();
+ assert.deepStrictEqual(f.calls, []);
+ f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
+ assert.strictEqual((await done).ok, true);
+ assert.deepStrictEqual(f.calls, [TOKEN]);
+});
+
+test("after a connect the window closes, and the page gets the answer, never the token", async () => {
+ const f = fakes();
+ const done = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ const r = await done;
+ assert.ok(f.windows[0].isDestroyed());
+ assert.ok(!f.flow.isOpen());
+ assert.deepStrictEqual(r, { ok: true, connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" });
+ assert.ok(!JSON.stringify(r).includes(TOKEN));
+ // The cookie listener is gone: a later change sends nothing.
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
+ await settle();
+ assert.deepStrictEqual(f.calls, [TOKEN]);
+});
+
+test("a token the server refuses keeps the window open, is sent once, and the next one connects", async () => {
+ const f = fakes();
+ f.setAnswer((t) => t === TOKEN ? { status: 400, data: { error: "Meta didn't accept this token." } }
+ : { status: 200, data: { connected: true, user: null } });
+ const done = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ await settle();
+ f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile"); // same value again
+ await settle();
+ assert.deepStrictEqual(f.calls, [TOKEN]);
+ assert.ok(!f.windows[0].isDestroyed());
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
+ const r = await done;
+ assert.deepStrictEqual(f.calls, [TOKEN, OTHER]);
+ assert.strictEqual(r.ok, true);
+ assert.strictEqual(r.user, null);
+});
+
+test("the token is never logged, and an error that echoed it is scrubbed", async () => {
+ const seen = [];
+ const names = ["log", "info", "warn", "error", "debug", "trace"];
+ const saved = Object.fromEntries(names.map((n) => [n, console[n]]));
+ const writes = [process.stdout.write, process.stderr.write];
+ for (const n of names) console[n] = (...a) => { seen.push(a.map(String).join(" ")); };
+ process.stdout.write = (c, ...rest) => { seen.push(String(c)); return writes[0].call(process.stdout, c, ...rest); };
+ process.stderr.write = (c, ...rest) => { seen.push(String(c)); return writes[1].call(process.stderr, c, ...rest); };
+ let r;
+ try {
+ const f = fakes();
+ f.setAnswer((t) => ({ status: 500, data: { error: `RuntimeError: bad ${t} and ${t.slice(0, 12)}` } }));
+ const done = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ await settle();
+ f.windows[0].close(); // the user gives up
+ r = await done;
+ } finally {
+ for (const n of names) console[n] = saved[n];
+ [process.stdout.write, process.stderr.write] = writes;
+ }
+ assert.strictEqual(r.ok, false);
+ assert.strictEqual(r.cancelled, true);
+ assert.ok(!r.error.includes(TOKEN) && !r.error.includes(TOKEN.slice(0, 12)), r.error);
+ assert.match(r.error, //);
+ assert.ok(!seen.some((s) => s.includes(TOKEN.slice(0, 12))), "token in output");
+ assert.strictEqual(scrub(`x FRL${"a".repeat(30)} y`), "x y");
+});
+
+test("closing the window before signing in reports it, and a second click reuses the open window", async () => {
+ const f = fakes();
+ const a = f.flow.signIn();
+ const b = f.flow.signIn();
+ assert.strictEqual(a, b);
+ assert.strictEqual(f.windows.length, 1);
+ assert.strictEqual(f.windows[0].focused, 1);
+ f.windows[0].close();
+ const r = await a;
+ assert.deepStrictEqual(r, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
+ assert.deepStrictEqual(f.calls, []);
+});
+
+test("disconnect's sign-out closes the window and clears only the sign-in partition", async () => {
+ const f = fakes();
+ f.session.fromPartition("persist:other");
+ const pending = f.flow.signIn();
+ await f.flow.signOut();
+ const r = await pending;
+ assert.strictEqual(r.ok, false);
+ assert.ok(f.windows[0].isDestroyed());
+ assert.deepStrictEqual(f.meta().cleared, [["storage", undefined], ["cache"], ["auth"]]);
+ assert.deepStrictEqual(f.session.defaultSession.cleared, []);
+ assert.deepStrictEqual(f.session.fromPartition("persist:other").cleared, []);
+ // The next sign-in starts with a new window.
+ f.flow.signIn();
+ assert.strictEqual(f.windows.length, 2);
+ f.windows[1].close();
+});
+
+test("the first look at a cookie value matches the server's token rules", () => {
+ assert.ok(looksLikeUserToken(TOKEN));
+ assert.ok(looksLikeUserToken("FRL" + "aB3".repeat(10)));
+ for (const bad of [undefined, "", "OC123456789012345", "OC|123|abc", "OCabc%2Fdef" + "x".repeat(20), "OCshort",
+ "XY" + "a".repeat(30), "OC" + "A".repeat(30)]) {
+ assert.ok(!looksLikeUserToken(bad), String(bad));
+ }
+});
+
+function frameNav(wc, url, isMainFrame) {
+ let prevented = false;
+ wc.emit("will-frame-navigate", { url, isMainFrame, preventDefault: () => { prevented = true; } });
+ return prevented;
+}
+
+test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => {
+ const f = fakes();
+ f.flow.signIn();
+ const wc = f.windows[0].webContents;
+ // Frames: captcha and CDN frames from other sites load; other schemes are stopped.
+ for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x",
+ "https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x",
+ "https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) {
+ assert.strictEqual(frameNav(wc, ok, false), false, ok);
+ }
+ for (const bad of ["http://www.facebook.com/plugin", "data:text/html,", "blob:https://evil.example/1",
+ "javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/",
+ "chrome://settings", "not a url"]) {
+ assert.strictEqual(frameNav(wc, bad, false), true, bad);
+ }
+ // The window itself: Meta's sites only.
+ assert.strictEqual(frameNav(wc, "about:blank", true), true);
+ assert.strictEqual(frameNav(wc, "https://evil.example/", true), true);
+ assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true);
+ assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false);
+ assert.deepStrictEqual(f.opened, []); // stopped, never opened outside
+ f.windows[0].destroy();
+});
+
+function redirect(wc, url, isMainFrame, style) {
+ let prevented = false;
+ const e = { preventDefault: () => { prevented = true; } };
+ if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame }));
+ else wc.emit("will-redirect", e, url, false, isMainFrame);
+ return prevented;
+}
+
+test("redirects follow the same rule per frame", async () => {
+ const f = fakes();
+ f.flow.signIn();
+ const wc = f.windows[0].webContents;
+ for (const style of ["details", "positional"]) {
+ // A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't.
+ assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style);
+ assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style);
+ assert.strictEqual(redirect(wc, "data:text/html,x", false, style), true, style);
+ assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style);
+ assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style);
+ // The window: Meta's sites only.
+ assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style);
+ assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style);
+ }
+ // Without frame information it is treated as the window.
+ assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true);
+ assert.deepStrictEqual(f.opened, []);
+ f.windows[0].destroy();
+});
+
+test("a redirect the window stops is named when the window is closed", async () => {
+ const f = fakes();
+ const p = f.flow.signIn();
+ const wc = f.windows[0].webContents;
+ assert.strictEqual(redirect(wc, "https://www.instagram.com/x?next=1", true, "details"), false); // Meta's login goes here
+ assert.strictEqual(redirect(wc, "https://login.evil.example/cb?code=SECRET", true, "details"), true);
+ f.windows[0].close();
+ const r = await p;
+ assert.strictEqual(r.ok, false);
+ assert.match(r.error, /went to login\.evil\.example, which Frame Control doesn't allow/);
+ assert.doesNotMatch(r.error, /SECRET|code=/);
+});
+
+test("a stopped redirect is forgotten once the window loads a page again", async () => {
+ const f = fakes();
+ const p = f.flow.signIn();
+ const wc = f.windows[0].webContents;
+ assert.strictEqual(redirect(wc, "https://login.evil.example/cb", true, "details"), true);
+ wc.emit("did-navigate", {}, "https://auth.meta.com/login/"); // the user carried on
+ f.windows[0].close();
+ assert.deepStrictEqual(await p, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
+});
+
+test("a page's beforeunload can't keep the window open", async () => {
+ const f = fakes();
+ const done = f.flow.signIn();
+ const w = f.windows[0];
+ let prevented = false;
+ w.webContents.emit("will-prevent-unload", { preventDefault: () => { prevented = true; } });
+ assert.ok(prevented);
+ // Even if close() would be vetoed, finishing destroys the window.
+ w.veto = true;
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ assert.strictEqual((await done).ok, true);
+ assert.ok(w.isDestroyed());
+});
+
+test("closing during a connect: the cancel goes to the server only after that connect settles", async () => {
+ for (const outcome of ["saved", "refused", "failed"]) {
+ const f = fakes();
+ let release, fail;
+ f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; }));
+ const done = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ await settle();
+ assert.deepStrictEqual(f.calls, [TOKEN]);
+ f.windows[0].close(); // the user gives up while the server and Meta are answering
+ const r = await done; // the page hears at once
+ assert.strictEqual(r.ok, false);
+ await settle();
+ assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled
+ if (outcome === "saved") release({ status: 200, data: { connected: true } });
+ else if (outcome === "refused") release({ status: 400, data: { error: "no" } });
+ else fail(new Error("socket hang up"));
+ await settle();
+ // The same attempt id the connect carried, after it settled and after cookie intake stopped.
+ assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome);
+ assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/);
+ assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome);
+ assert.deepStrictEqual(f.calls, [TOKEN]);
+ }
+});
+
+test("closing before any connect needs no cancel", async () => {
+ const f = fakes();
+ const done = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable
+ await settle();
+ f.windows[0].close();
+ assert.strictEqual((await done).ok, false);
+ await f.flow.signOut();
+ await settle();
+ assert.deepStrictEqual(f.calls, []);
+ assert.deepStrictEqual(f.cancels, []);
+});
+
+test("a timeout during a connect cancels after it settles", async () => {
+ const f = fakes({ timeout: 50 });
+ let release;
+ f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow
+ const done = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ const r = await done;
+ assert.match(r.error, /timed out/);
+ assert.ok(f.windows[0].isDestroyed());
+ assert.deepStrictEqual(f.cancels, []);
+ release({ status: 400, data: { error: "no" } });
+ await settle();
+ assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
+});
+
+test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => {
+ const f = fakes();
+ let release;
+ f.setAnswer(() => new Promise((r) => { release = r; }));
+ const done = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ await settle();
+ f.log.length = 0;
+ let signedOut = false;
+ const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one
+ assert.strictEqual((await done).ok, false);
+ await settle();
+ assert.strictEqual(signedOut, false); // still waiting for the connect
+ assert.deepStrictEqual(f.cancels, []);
+ release({ status: 200, data: { connected: true } }); // it saved, just now
+ await out;
+ // Only now would the page post its disconnect: the cancel (which deletes what that
+ // connect saved) has reached the server, and the session is cleared.
+ assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
+ assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
+});
+
+test("a later disconnect still waits for a closed window's connect", async () => {
+ const f = fakes();
+ let release;
+ f.setAnswer(() => new Promise((r) => { release = r; }));
+ const done = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ await settle();
+ f.windows[0].close();
+ await done;
+ let signedOut = false;
+ const out = f.flow.signOut().then(() => { signedOut = true; });
+ await settle();
+ assert.strictEqual(signedOut, false);
+ release({ status: 400, data: { error: "no" } });
+ await out;
+ assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
+});
+
+test("nothing sent means nothing to cancel; sign-out destroys the window, cancels, then clears", async () => {
+ const f = fakes();
+ // Nothing sent: closing cancels nothing.
+ const a = f.flow.signIn();
+ f.windows[0].close();
+ await a;
+ assert.deepStrictEqual(f.cancels, []);
+ // Something sent, then signed out: the attempt is cancelled before the session is cleared.
+ f.setAnswer(() => ({ status: 400, data: { error: "Meta didn't accept this token." } }));
+ const b = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ await settle();
+ f.windows[1].veto = true; // and its page tries to stay open
+ f.log.length = 0;
+ await f.flow.signOut();
+ assert.strictEqual((await b).ok, false);
+ assert.ok(f.windows[1].isDestroyed());
+ assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
+ assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
+ // After sign-out a new cookie in the partition sends nothing.
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
+ await settle();
+ assert.deepStrictEqual(f.calls, [TOKEN]);
+});
+
+test("each sign-in has its own attempt id", async () => {
+ const f = fakes();
+ f.setAnswer(() => ({ status: 400, data: { error: "no" } }));
+ const a = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ await settle();
+ f.windows[0].close();
+ await a;
+ const b = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
+ await settle();
+ f.windows[1].close();
+ await b;
+ await settle();
+ assert.strictEqual(f.attempts.length, 2);
+ assert.notStrictEqual(f.attempts[0], f.attempts[1]);
+ assert.deepStrictEqual(f.cancels, f.attempts);
+});
+
+test("no sign-in can start while a sign-out waits on an earlier connect", async () => {
+ const f = fakes();
+ let release;
+ f.setAnswer(() => new Promise((r) => { release = r; }));
+ const first = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ await settle();
+ const out = f.flow.signOut(); // Disconnect pressed while that connect is under way
+ assert.strictEqual((await first).ok, false);
+ assert.ok(f.flow.isSigningOut());
+ // The page answered, so the user tries again at once: refused, no window, nothing sent.
+ const second = await f.flow.signIn();
+ assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." });
+ assert.strictEqual(f.windows.length, 1);
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
+ await settle();
+ assert.deepStrictEqual(f.calls, [TOKEN]);
+ // A second sign-out joins the first.
+ assert.strictEqual(f.flow.signOut(), out);
+ release({ status: 200, data: { connected: true } });
+ await out;
+ assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
+ assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1);
+ assert.ok(!f.flow.isSigningOut());
+ // Once it is done, signing in works again.
+ f.setAnswer(() => ({ status: 200, data: { connected: true } }));
+ const third = f.flow.signIn();
+ assert.strictEqual(f.windows.length, 2);
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
+ assert.strictEqual((await third).ok, true);
+});
+
+test("sign-out goes round until no window and no connect or cancel is left", async () => {
+ const f = fakes();
+ let releaseA;
+ f.setAnswer(() => new Promise((r) => { releaseA = r; }));
+ const a = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
+ await settle();
+ f.windows[0].close(); // window A closed; its cancel waits on its connect
+ await a;
+ // Window B is open, with its own connect under way, when sign-out starts.
+ let releaseB;
+ f.setAnswer(() => new Promise((r) => { releaseB = r; }));
+ const b = f.flow.signIn();
+ f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
+ await settle();
+ let finished = false;
+ const out = f.flow.signOut().then(() => { finished = true; });
+ await b;
+ releaseB({ status: 400, data: { error: "no" } });
+ await settle();
+ assert.strictEqual(finished, false); // A's connect is still under way
+ releaseA({ status: 200, data: { connected: true } });
+ await out;
+ assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort());
+ assert.strictEqual(f.attempts.length, 2);
+ assert.strictEqual(f.log[f.log.length - 1], "clear");
+});
diff --git a/app/test/updater.test.js b/app/test/updater.test.js
index 4bf9594..2ae00af 100644
--- a/app/test/updater.test.js
+++ b/app/test/updater.test.js
@@ -51,6 +51,27 @@ test("update.json assets always download from this repository's release", () =>
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
});
+test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
+ const { fromManifest, fromApi } = require("../updater");
+ const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
+ const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
+ assert.strictEqual(page(tagPage), tagPage);
+ // 0.4.0's real update.json: the draft's address, a 404 once published.
+ assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
+ assert.strictEqual(page(undefined), tagPage);
+ assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
+ assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
+ // Paths that normalize to another repository.
+ assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
+ tagPage);
+ assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
+ tagPage);
+ assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
+ assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
+ assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
+ html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
+});
+
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
const { prepare } = require("../updater");
const release = { version: "0.3.1", assets: [] };
diff --git a/app/updater.js b/app/updater.js
index 85caab5..e6ccbc9 100644
--- a/app/updater.js
+++ b/app/updater.js
@@ -106,12 +106,24 @@ async function getJson(url, headers) {
return JSON.parse(body);
}
+// The release page the banner links to. update.json for 0.4.0 carried the draft's
+// address (releases/tag/untagged-...), which is dead once the release is published,
+// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
+// normalizes to another repository) before shell.openExternal. So the given page is
+// ignored: the link is always this repository's tag page, built from a valid version.
+function releasePage(version) {
+ const v = parseVersion(version);
+ if (!v) return RELEASES;
+ return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
+}
+
// 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,
+ const version = String(m.version).replace(/^v/i, "");
+ const base = `https://github.com/${REPO}/releases/download/v${version}/`;
+ return { version, notes: String(m.notes || "").slice(0, 4000),
+ page: releasePage(version),
// 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 })) };
@@ -119,8 +131,9 @@ function fromManifest(m) {
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,
+ const version = String(r.tag_name || "").replace(/^v/i, "");
+ return { version, notes: String(r.body || "").slice(0, 4000),
+ page: releasePage(version),
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
digest: a.digest || null })) };
}
diff --git a/docs/apks.md b/docs/apks.md
index 4a841d4..458bcb5 100644
--- a/docs/apks.md
+++ b/docs/apks.md
@@ -39,6 +39,12 @@ section below), instead of going into Lepton Development:
run at once alongside Lepton Development, each in its own container
(`lepton-steamlaunch-`, ADB on 5556, 5557, …).
+Split APKs (a base plus `split_*.apk` files) install as one app:
+`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
+APK, so splits that add native libraries or assets are merged into the base
+and re-signed; density and language splits are refused. See
+[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
+
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
from the shortcut, stopped, and relaunched with their data intact. ADB and
Lepton Development aren't involved.
diff --git a/docs/devices.md b/docs/devices.md
index 4cfd823..6d4bf50 100644
--- a/docs/devices.md
+++ b/docs/devices.md
@@ -115,7 +115,9 @@ connection is an SSH ControlMaster that every command shares; when it dies (the
headset slept or left the network) the connector notices and starts again. On
Windows, where OpenSSH can't share a connection, the same handshake runs once
and each command then connects on its own; a command that can't reach the
-headset makes the connector start again.
+headset makes the connector start again. There ssh's stderr goes to a file that
+the connector reads as it grows, not a pipe: Windows' OpenSSH can block writing
+to a redirected pipe.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName= -o HostKeyAlias=frame-control-
@@ -132,10 +134,57 @@ lease that moved) would look like a new host there. The app keeps one known_host
file per headset instead, so saving or forgetting one headset's key never touches
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
-the key it shows, as Set Up Connection does. After reinstalling SteamOS the
-headset has a new key; **Forget identity** (Devices tab → **Advanced**, with the
-SSH alias, user and port) lets the next
-connection save the new one.
+the key it shows, as Set Up Connection does: while a headset has never had an
+identity here, every ssh command gets `StrictHostKeyChecking=accept-new` (the
+first contact saves it, whichever command makes it), and from then on
+`StrictHostKeyChecking=yes`. `accept-new` never replaces a saved key, so it
+can't hide a change. The saved key's fingerprint is also recorded in
+`devices.json` (`identity`), along with a flag that never clears
+(`identity_established`), as soon as ssh saves the key: even if the login is then
+refused. A headset counts as having had an identity if the flag is set, a
+fingerprint is recorded, its pin file exists at all, or a connection to it ever
+worked (older files get the flag from that when loaded, so editing or removing
+the address that worked doesn't undo it); then
+a pin file that's deleted, empty, unreadable or not parseable **fails closed**
+(*Checking the headset's identity* fails, "missing or can't be read") rather than
+trusting whatever answers, and only **Check its key…** saves a key again. It
+says when the key it reads matches the recorded fingerprint.
+
+**When the key changes** (SteamOS reinstalled or reset, a new headset at the
+same address, or a different device answering there), nothing is accepted by
+itself. The connection fails at *Checking the headset's identity*; the banner
+and the headset on the Devices tab say so, with **Check the new key…** (also
+**Check its key…** on the Devices tab at any time). That reads the key the
+headset shows now, and nothing more: ssh writes it to a throwaway known_hosts
+file during the key exchange, before any login, and is stopped as soon as it
+has. Every other kind of contact is ruled out on the command line, ahead of
+anything `~/.ssh/config` says: no authentication of any kind, no agent
+(`IdentityAgent=none`, `ForwardAgent=no`), no X11 or port forwarding, no local
+or remote command (`-N`), no terminal, and `-F none`. Through a ProxyJump host
+it builds that hop itself, as a ProxyCommand whose ssh forwards nothing and runs
+no local command (ProxyJump's own ssh would take `ForwardAgent` and
+`LocalCommand` from `~/.ssh/config`); it still logs in to the jump host as
+configured. Every word of that ProxyCommand is quoted for the shell that runs it
+(and `%` doubled for ssh's own tokens), the forward is `[%h]:%p` so an IPv6
+headset works, and an IPv6 jump host is passed bare with its port. Through a ProxyCommand from `~/.ssh/config`, or several jump hosts,
+it refuses, since those can't be made safe from here: add an address it can
+reach directly, or compare the key by hand. It shows the key's SHA256 fingerprint next to
+the saved one, with the command that prints it on the headset
+(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, in Konsole on its desktop).
+**This is my headset: trust its new key** then saves exactly that key, replacing
+the old one, and reconnects. The server keeps the key it showed for 10 minutes
+and saves it only if the page sends back the same fingerprint, and only while
+the headset's port, user and that address are unchanged; it never saves whatever
+answers next. The API's old `forget-identity`, which deleted the pin so that the
+next contact saved any key unseen, is refused. Set Up Connection can't fix a changed key: it checks your
+own `known_hosts`, not the headset's file.
+
+**When the headset refuses this computer's key** (`Permission denied
+(publickey,…)`), the connection stops at *Logging in*, and the banner and the
+Devices tab offer **Set Up Connection…**, which pairs this computer again.
+
+Neither is reported as a fault: the diagnostics report each kind at most once
+a session ([privacy.md](privacy.md)).
## One server at a time
@@ -156,7 +205,7 @@ All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
| `GET /api/devices/mdns?id=` | Headsets found on this network |
-| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
+| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `recorded`, `matches`, `recorded_match`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `forget-identity` (refused: use the two before), `name-network`, `setup` (alias, optional host), `retry` |
Every host, alias and user is checked against strict patterns before it's
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
@@ -173,7 +222,7 @@ share the format later (it still connects to one host; see
```json
{"version": 1, "active": "f67f8b7e",
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
- "identity_files": ["~/.ssh/id_ed25519_frame"],
+ "identity_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"], "identity_established": true,
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
diff --git a/docs/frame-control.md b/docs/frame-control.md
index 4c10fe9..f714e6f 100644
--- a/docs/frame-control.md
+++ b/docs/frame-control.md
@@ -17,16 +17,18 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
-The window has five tabs: **Home** (what the headset's doing and what you'd do
+The window has six tabs: **Home** (what the headset's doing and what you'd do
next, the headset view, screenshots, family and comfort, the keyboard and
trackpad, and VR performance, folded away), **Games** (installed games,
sideloaded titles, getting games), **Android** (apps, the catalogue, display
settings, reports), **Tools** (in the headset: your computer's windows, the media
player and the panel switcher; then sending files and text, Flatpaks, remote and
-power) and **Devices** (your headsets and their addresses). Keys 1–5 switch
-between them. The gear in the header (key 6) opens **Settings**: privacy and
-updates, library artwork and the assistant. The battery chip opens the headset's
-details: storage, memory, temperature, Wi-Fi, uptime and SteamOS build. Files can be dropped anywhere in the window. A
+power), **Devices** (your headsets and their addresses), and **Control** (the
+searchable Control Center, metrics, supported settings and headset guidance).
+Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
+privacy and updates, library artwork and the assistant. The battery chip opens
+the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
+build. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset and whether it's
connected, and how (Tailscale, or the network's name). Click it to add, edit or
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
@@ -93,8 +95,68 @@ counts them while they run.
name your networks, and switch headsets. See [devices.md](devices.md).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
- Linux). Sleep, restart and shut down open a terminal window because SteamOS
- asks for the sudo password over SSH.
+ Linux). Remote desktop first checks that the Frame's xrdp answers on port
+ 3389 (Developer Mode turns it on). On Windows it opens a connection file for
+ user `steamos`, because `mstsc /v:` alone offers your Windows account, which
+ xrdp turns away. Accept the warning about the Frame's own certificate, then
+ sign in with the Developer Mode password. Sleep, restart and
+ shut down open a terminal window because SteamOS asks for the sudo password
+ over SSH.
+
+## Control Center
+
+Open **Control** or `#control` to search controls, status and help. Categories
+cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
+preferences. Search is local: it matches labels, descriptions and familiar terms
+such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
+Steam, app catalogues, analytics or models. Choosing a workflow link opens its
+existing screen; it does not install, cast, open a helper or change a setting.
+Home, Games, Android, Tools and Devices retain their existing features.
+
+The overview and detail cards use the shared `/api/status` sample: charge and
+power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
+and VR timings. Values refresh with the normal 30-second poll. A failed status
+request, lost connection, headset switch or sample received more than 90 seconds
+ago clears current readings and disables audio writes. The client receipt time
+and failed-read state are retained through page startup and UI redraws. Only a
+new successful status read clears a failure; reusing the retained sample does not.
+The headset timestamp is shown without assuming
+its clock matches this computer. Missing sensors show **Unavailable**;
+they are not converted to zero. Compositor FPS is separate from application FPS;
+GPU time is render time, not GPU utilisation. The temperature is the hottest
+readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
+not a guessed universal temperature threshold.
+
+**Audio** controls the default output sink through the existing `/api/volume`
+endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
+and **Unmute** send explicit mute states. The UI captures the selected headset,
+blocks duplicate/pending actions, and requests status after command acknowledgment
+for readback, draining an earlier poll first. Mute preserves an unapplied volume
+draft; switching headsets discards it. It reports
+unconfirmed or differing output separately from confirmed state. API validation
+rejects invalid types, out-of-range levels and invalid mute states before SSH.
+
+Other settings link to the existing forms and their validation, progress,
+confirmations and reset behavior: per-instance Android resolution/density/text,
+comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
+sources and artwork. Android display links reveal **On your Frame** before
+scrolling to the form. Mac streaming remains searchable on all platforms, with
+its action shown only when the server is on macOS.
+
+Hardware guidance is deliberately separate from controls. Microphone/routing,
+brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
+tracking-origin changes, saved spatial layouts, firmware updates and recovery
+have no verified remote setting here. Use the headset's own controls or the
+documented manual procedures. Movement comfort remains per-game; automatic
+sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
+Android data backup/restore remain script/MCP capabilities, not new UI switches.
+
+**Verification:** the Control Center's JavaScript runs under Node with mocked
+API and DOM boundaries; the server tests mock SSH and reject invalid values
+before any device command. Packaging tests cover the shared CSS/JS in desktop
+and iPhone bundles. These checks establish wiring, search, navigation, offline,
+error and device-switch behavior; they do not establish wearer experience,
+physical settings persistence or a new hardware capability.
## How it works
diff --git a/docs/img/control-center/desktop.png b/docs/img/control-center/desktop.png
new file mode 100644
index 0000000..7f1bfed
Binary files /dev/null and b/docs/img/control-center/desktop.png differ
diff --git a/docs/img/control-center/phone.png b/docs/img/control-center/phone.png
new file mode 100644
index 0000000..586d577
Binary files /dev/null and b/docs/img/control-center/phone.png differ
diff --git a/docs/meta-library.md b/docs/meta-library.md
new file mode 100644
index 0000000..561d660
--- /dev/null
+++ b/docs/meta-library.md
@@ -0,0 +1,371 @@
+# My Meta library (Experimental)
+
+Install Quest games your Meta account owns straight from Meta, test whether
+they run on the Steam Frame, and add the result to the shared compatibility
+reports. It is off until you connect, and it lives in **Android → My Meta
+library**.
+
+**Read this first.** My Meta library uses Meta's undocumented internal API
+with your own Meta access token, as the open-source tools QAVS, DownQuest and
+BSManager do. It may stop working whenever Meta changes things, and using it
+may be against Meta's terms. Your account is your responsibility. Frame
+Control is unofficial and not made or endorsed by Meta or Valve.
+
+What it will and won't do:
+
+- It lists and downloads only games your account owns (its active
+ entitlements). It never downloads anything else.
+- It installs games as Meta ships them. There is **no licence-check bypass**:
+ no patching, no stub libraries, no OVRPort or FramePort. A game that
+ enforces Meta's licence check may quit or hang on the Frame, because that
+ check needs Meta's services, which the Frame doesn't have.
+- Game files go only to a cache folder on your computer and then to your
+ Frame. They are deleted from the cache after each install, and never
+ uploaded or shared. Reports carry results only (below), never files or your
+ token.
+
+## Your token
+
+Frame Control never reads your browser's cookies. There are two ways to give
+it your token.
+
+**Sign in with Meta** (desktop app only; a plain browser doesn't show it).
+Tick **I understand…**, then choose **Sign in with Meta…**. Meta's own login
+page (`https://secure.oculus.com`, the address BSManager's "Log in with Meta"
+opens) appears in a Frame Control window. Sign in there as usual. When Meta's
+site sets its access token cookie (`oc_ac_at`), Frame Control takes it from
+that window, connects with it exactly as if you had pasted it (below), and
+closes the window. The window:
+
+- has its own storage (the Electron session `persist:meta-signin`), separate
+ from Frame Control's main window and from every browser on your computer;
+ the cookie is read only from there, and only for `oculus.com`;
+- runs Meta's page sandboxed, with no access to Frame Control (no preload, no
+ Node, context isolation on, no developer tools) and every permission request
+ or check (camera, notifications…) refused;
+- only goes to `https://` pages on `oculus.com`, `meta.com`, `facebook.com` and
+ `instagram.com` (and their subdomains; Meta's login passes through Instagram).
+ A link anywhere else opens in your system browser; a redirect anywhere else
+ is stopped, and closing the window then says where it went. Pop-ups on those sites open in the same
+ window. Frames inside the page may load any `https://` page (Meta's login
+ can embed captcha and CDN frames from other sites, such as Arkose Labs,
+ hCaptcha, reCAPTCHA or fbcdn) or an empty frame (`about:blank`,
+ `about:srcdoc`); a frame going to any other scheme (`http:`, `data:`,
+ `blob:`, `javascript:`, `file:`…) is just stopped. Redirects follow the same
+ rule for the frame they happen in. Frames can't reach the
+ token: the cookie is read by Frame Control's main process, and the window
+ has no IPC;
+- sends a cookie value on only if it looks like a user token (`OC` or `FRL`,
+ mixed case, no `%`, `|` or `:`), and each value once. If the server refuses
+ it, the window stays open; close it to see why;
+- gives up after 10 minutes, as BSManager does;
+- when it is closed, times out or is signed out of, stops reading cookies
+ first, then is destroyed (a page's "leave this page?" can't keep it open),
+ and, if it had sent a token, tells the server to drop that sign-in: each
+ sign-in has a random attempt id. The cancel is sent only once the connect
+ under way has finished (saved, refused or failed), so the server always
+ gets it after the connect; with nothing sent, there is nothing to cancel.
+ The server lists an attempt only while its
+ connect is being checked: cancelled then, it isn't saved; cancelled just
+ after its token was saved (even before the connect has finished tidying
+ up), that token is deleted (as a disconnect). A cancel
+ never removes an account a later sign-in or a pasted token saved, and one
+ for an unknown attempt changes nothing.
+
+The page never sees the token: the desktop app hands it straight to the local
+server's connect path and tells the page only the answer (connected, and your
+Meta display name). It isn't logged anywhere.
+
+The window keeps you signed in to Meta (in `persist:meta-signin` only) until
+you **Disconnect**, which also signs out of Meta in Frame Control: it clears
+that window's cookies, storage and cache, so the next sign-in starts fresh.
+**Sign out of Meta in Frame Control** next to the sign-in button does just
+that part, for a sign-in that didn't connect.
+
+**Paste a token.** You copy your own token and paste it in. The two ways
+BSManager's guide describes
+([How to obtain your Oculus Token](https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token)):
+
+1. **From the Meta website.** Sign in at `https://secure.oculus.com`. Open the
+ browser's developer tools, then **Application** (Chrome, Edge) or
+ **Storage** (Firefox) → **Cookies** → `https://secure.oculus.com`. Copy the
+ value of `oc_ac_at`. It starts with `OC`.
+2. **From the Meta Quest PC app** (Windows). Press **Ctrl+Shift+I**, open
+ **Network**, press **Ctrl+R**, type `graph` in the filter, open the first
+ request, and copy the access token from its **Payload**. It starts with
+ `FRL`.
+
+Treat it like a password: anyone with it can act as your Meta account. Signing
+out of the Meta website or app usually ends it; then connect again with a new
+one.
+
+Frame Control refuses values that can't be a user token before sending them
+anywhere: anything not starting with `OC` or `FRL`, URL-encoded copies (with
+`%`), app tokens (with `|` or `:`), cut-off values (under 20 characters), and
+anything with characters other than letters, digits and `+ / = - _` (the
+base64 and URL-safe base64 sets), or with no lower-case letter (real tokens are
+mixed case). The public tools only describe tokens made
+of letters and digits; if a real token needs another character, it has to be
+added in one place (`TOKEN_CHARS` in `ui/frame_meta.py`), which also makes
+the redaction look for it.
+
+## Using it
+
+1. Tick **I understand…**, then either choose **Sign in with Meta…** (desktop
+ app) or paste your token and choose **Connect**. Frame Control asks Meta
+ "who am I" with it (a read-only query); only a token Meta accepts is kept.
+2. Your Quest games are listed with their version, size, game data, and the
+ community verdict from the compatibility database when there is one.
+3. **Install** downloads the game and installs it on the Frame, as [Import
+ from my Quest](quest-import.md) does. A game with OBB game data then needs
+ a second **Install** while you have it open on the Frame: Frame Control
+ downloads the data, copies it in and closes the game.
+4. **Install and test** does the same, then starts the game itself (twice if
+ it has game data to add: once to add it, once for the test), watches it for
+ about 30 seconds, and records what happened. **Test again** repeats only
+ the test.
+5. Choose **Install** on as many games as you like: they wait in the
+ **Install queue** and install one after another, with Quest imports and
+ Android store installs. **Remove** takes one out before it starts.
+6. **Disconnect** deletes the token from your computer and, in the desktop
+ app, signs out of Meta in Frame Control (clears the sign-in window's
+ storage). Games already on the Frame stay.
+
+From a terminal (the token is read from standard input, never an argument):
+
+```sh
+python3 ui/frame_meta.py connect --token-stdin # paste the token, press Enter
+python3 ui/frame_meta.py status
+python3 ui/frame_meta.py list [--refresh] # owned Quest games, with community verdicts
+python3 ui/frame_meta.py download APP_ID # into the local install cache (a later download clears it after 6 hours)
+python3 ui/frame_meta.py install APP_ID [--test] [--allow-downgrade]
+python3 ui/frame_meta.py test com.example.game [--wait 30] [--no-report]
+python3 ui/frame_meta.py disconnect
+```
+
+The local API: `GET /api/meta` (connected or not, never the token),
+`GET /api/meta?library=1[&refresh=1]` (the games), and `POST /api/meta` with
+`{"action": "connect", "token": "...", "acknowledged": true}`,
+`{"action": "disconnect"}`, `{"action": "cancel-signin", "attempt": "..."}`, `{"action": "install", "app": "APP_ID", "test": false,
+"allowDowngrade": false}` or `{"action": "test", "package": "..."}`. Install and
+test return a background job (`/api/job`); an install waits its turn in the
+install queue (`/api/queue`, see [Import from my Quest](quest-import.md#using-it)). Sign in with Meta uses the same
+`connect` action: the desktop app's main process (`app/meta-signin.js`) posts
+it with the cookie's value and an `"attempt"` id (8 to 64 letters, digits or
+`-`) once the page's acknowledgement box is ticked. A connect whose attempt
+was cancelled while it was checked answers 409 and saves nothing;
+`cancel-signin` for an attempt whose token is the saved account deletes it.
+
+## What an install does
+
+1. Checks the game is in your library (by its Meta app id), and that there's
+ room on this computer for the APK, a patched copy of it and the game data.
+2. Downloads the APK from Meta's download server, within the size Meta
+ declared (and at most 2 GB), checking its SHA-256 if Meta gives one, and
+ that it is an APK of the package and version Meta said.
+3. Runs Import from my Quest's checks. Refused: 32-bit-only builds (no
+ `arm64-v8a`), and apps that need an Android newer than Lepton's (API 30).
+ Installed with a warning: games that use Meta's licence check
+ (`libovrplatformloader.so`), and VrApi-only games ("may not run").
+4. Installs it with `frame_android.install` (VR detection, the launcher fix,
+ the OpenXR compatibility layer, artwork, a Steam library entry). An older
+ copy never silently replaces a newer one on the Frame unless you tick
+ **Allow replacing a newer version**.
+5. Game data: the app's OBB (saved as Meta names it when that is a standard
+ `main|patch...obb` name, otherwise as
+ `main...obb`) and any *required* asset files with
+ standard OBB names, at most 8 GB each. One install downloads at most 16 GB
+ in all, APK included, counted as the bytes arrive (so files whose size Meta
+ doesn't give count too); a file that goes past it is stopped and removed.
+ The data is copied into the
+ running game by the same two-step helper Import from my Quest uses. Other
+ asset files (DLC packs, `.pak` files a game fetches through Meta's
+ Platform SDK) aren't downloaded; the result lists them.
+6. Deletes everything it downloaded, whether the install worked or not.
+ Downloads left by a crash are removed after six hours.
+
+## The test, and the report
+
+The test starts the game, waits for its Lepton instance, then looks every 10
+seconds for 30 seconds: is the game's process alive, and what does its Android
+log (main, system and crash buffers) say. It recognises:
+
+| In the log | Cause given |
+|---|---|
+| "entitlement check failed", "not entitled", `ovr_Entitlement…` or `IsUserEntitledToApplication` reporting a failure (a call that passes doesn't count) | Meta's licence check failed |
+| Platform SDK initialisation errors | Meta's Platform SDK couldn't start |
+| `Failed to launch SystemActivities`, "device not supported" | The game checks it is on a Meta Quest |
+| `vrapi_… was called before…`, VrApi load errors | Legacy VrApi |
+| `XR_ERROR_EXTENSION_NOT_PRESENT` (with the extension's name) | An OpenXR feature the Frame lacks |
+| `XR_ERROR_API_VERSION_UNSUPPORTED`, runtime/initialisation failures | OpenXR version or session problems |
+| `VK_ERROR_DEVICE_LOST`, `zink`, `libgallium_dri.so`, `GL_INVALID_…`, shader failures | Graphics driver errors |
+| `ClipboardManager` | Lepton's missing clipboard service |
+| `NoSuchMethodError`, `UnsatisfiedLinkError`, `dlopen failed` | Android or library features Lepton lacks |
+| `FATAL EXCEPTION`, `Fatal signal`, `SIGSEGV` | A crash |
+| `ANR in …`, "Input dispatching timed out" | Not responding |
+| `XR_SESSION_STATE_SYNCHRONIZED/VISIBLE/FOCUSED`, `xrEndFrame` | The VR scene started |
+
+The outcome is one of **runs** or **runs with issues** (its VR scene
+started), **quits** (closed by itself), **crashes**, **hangs** (Android
+reported it not responding; Frame Control then closes it, and says so if it
+couldn't), **didn't start** (its instance never came up), or **no clear
+result**: it stayed open but its log never showed its VR scene starting. That
+can be a game still loading, or one stuck after a failed check; Frame Control
+can't tell, so it doesn't record it. A game already open is closed first, so
+the test starts clean; if it can't be closed, nothing is tested. If the Frame
+fails while the test reads the game or its log, the test says only that it
+couldn't read the log: a failed command's output can be the game's log, so it
+is never shown. Signatures look at the first 4 KB of each log line and are
+bounded, so a huge or hostile log can't make the test slow. They
+are written for Frame Control from the research in [vr-apks.md](vr-apks.md);
+they are not copied from other tools.
+
+The result goes through the same path as **Test** and **Report**
+(`frame_catalog.add_report`): `via` probe, source `meta-store`, the result
+(`runs`, `crashes` or `instance_failed`, the values the database accepts), and
+notes with the outcome, the cause, the names of the matched signals and the
+version code, all fixed texts. Nothing else: no token, account name, file or
+log text. The page may show a short name from the log next to the cause (an
+OpenXR extension, a library, an exception class or a signal), taken from the
+log line after the token is removed from it, and left out unless it looks like
+a real name (64 characters at most, no long mixed letter-and-digit runs);
+reports never carry it. It is always kept on this
+computer; it reaches the shared database only if **Share compatibility
+results** is on in Privacy settings, as for every report. The panel offers to
+turn that on.
+
+## Your token and privacy
+
+- Kept in `meta-account.json` in Frame Control's per-user data folder
+ (`~/Library/Application Support/Frame Control` on macOS,
+ `%APPDATA%\Frame Control` on Windows, `~/.local/share/frame-control` on
+ Linux), written owner-only (0600) through a temporary file. Never on the
+ Frame.
+- Sent only to `graph.oculus.com` (in the POST body) and
+ `securecdn.oculus.com` (in the download address, which is how that server
+ takes it), over HTTPS. A download that redirects elsewhere (Meta's CDN) is
+ followed only to a plain HTTPS address on port 443 that doesn't carry the
+ token in any form, anywhere (host, path, query or fragment; literal,
+ recased, percent-encoded, or 8+ characters of it), nor an `access_token`
+ parameter. That is checked before any lookup or connection, and a redirect
+ that fails it is refused. API answers that redirect are refused.
+- Every connection goes to an address checked to be public (an allow-list:
+ no private, local, shared, reserved, multicast, NAT64, 6to4 or Teredo
+ addresses), and to exactly that address, with TLS checked for the host
+ name, so a second DNS answer can't swap in another. System proxy settings
+ aren't used. This is the same design as the itch.io purchases branch.
+- Never in logs, error messages, API answers, the page or reports: every
+ message is cleaned first, matching the token (or any start of it of 8+
+ characters) in any letter case, with each character written literally or
+ percent-encoded once or twice (%XX, %25XX) in either case, and any other
+ token-shaped value, before it is shortened.
+- Disconnect deletes the file and the library Frame Control holds in memory,
+ and stops a download under way. A connect still being checked when you
+ disconnect is not saved. In the desktop app, before the token is deleted, it
+ also stops any sign-in under way, destroys the sign-in window, waits for a
+ connect still under way to finish and cancels its attempt (deleting its
+ token if it was just saved), and only then clears the `persist:meta-signin` session
+ (cookies, storage, cache); no other session is touched. Sign in with Meta
+ stays off from the moment you press Disconnect until the token is deleted,
+ and the app refuses a sign-in while it is signing out.
+- With Sign in with Meta, a second copy of your Meta sign-in lives in that
+ session's storage, in Electron's per-user data folder (`Partitions/meta-signin`),
+ until you disconnect or sign out of Meta in Frame Control.
+
+## Where the protocol comes from
+
+Frame Control's code is its own (MIT). These public sources describe the
+protocol; Frame Control takes only facts from them (addresses, query ids,
+field names), not code. QAVS and BSManager are GPL-3.0; OculusGraphQLApiLib,
+DownQuest and Oculus-downgrader state no licence.
+
+| Fact | Source |
+|---|---|
+| GraphQL at `https://graph.oculus.com/graphql`, POST, form fields `access_token`, `doc_id`, `variables`; `OAuthException` / code 190 for a bad token | [OculusGraphQLApiLib `GraphQLClient.cs`](https://github.com/ComputerElite/OculusGraphQLApiLib/blob/866081e2/OculusGraphQLApiLib/GraphQL/GraphQLClient.cs), [DownQuest `DownQuest.js`](https://github.com/ptrpaws/DownQuest/blob/50f5d558/DownQuest.js) |
+| "Who am I": `doc_id` 4149322231793299, `data.viewer.user {id, alias}` | OculusGraphQLApiLib `GraphQLClient.cs` and `Results/User.cs` |
+| Owned apps: `doc_id` 4850747515044496, `data.viewer.user.active_entitlements.nodes[].item` | OculusGraphQLApiLib `GraphQLClient.cs`, `Results/Entitlement.cs`, `Results/Application.cs`; used by [QAVS `GameDownloadManager.cs`](https://github.com/ComputerElite/QuestAppVersionSwitcher/blob/36bebd5a/QuestAppVersionSwitcher/GameDownloadManager.cs) |
+| One binary: `doc_id` 4734929166632773 with `binaryID`; fields `package_name`, `version`, `version_code`, `size`, `obb_binary`, `asset_files` (`file_name`, `size`, `is_required`) | OculusGraphQLApiLib `GraphQLClient.cs`, `Results/OculusBinary.cs`, `Results/AssetFile.cs` |
+| Downloads: `https://securecdn.oculus.com/binaries/download/?id=&access_token=`, the same for the APK, `obb_binary` and asset files; QAVS takes the OBB and required asset files | OculusGraphQLApiLib `Game/GameDownloader.cs`, [QAVS `DownloadManager.cs`](https://github.com/ComputerElite/QuestAppVersionSwitcher/blob/36bebd5a/QuestAppVersionSwitcher/DownloadManager.cs), DownQuest `DownQuest.js` |
+| Token checks (`OC`/`FRL`, no `%`, `|`, `:`, not `OC` + 15 digits) | OculusGraphQLApiLib `GraphQL/TokenTools.cs`, [BSManager `oculus.helpers.ts`](https://github.com/Zagrios/bs-manager/blob/00380c79/src/shared/helpers/oculus.helpers.ts) |
+| How users get a token (`oc_ac_at` cookie, or the PC app's `FRL` token) | [BSManager wiki](https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token) |
+| Sign in with Meta: open `https://secure.oculus.com` in an app window, and once it reaches `https://secure.oculus.com/my/profile` read the `oc_ac_at` cookie from that window's session (checked on `did-navigate`, `did-navigate-in-page`, `did-stop-loading`); 10-minute timeout | [BSManager `bs-oculus-downloader.service.ts`](https://github.com/Zagrios/bs-manager/blob/00380c79/src/main/services/bs-version-download/bs-oculus-downloader.service.ts) (`getUserTokenFromMetaAuth`) |
+
+**Assumed, not seen in those sources:** that the entitlement and binary
+answers carry the fields above in the shapes the libraries' models describe
+(the stored queries are opaque); that the list isn't paginated (none of the
+tools page it); that `ANDROID_6DOF` marks Quest apps; where downloads redirect
+to (the tools let their HTTP client follow redirects; Frame Control follows
+up to five, to any public HTTPS host, and refuses one carrying the token); and
+a `sha256`
+field (none was seen, so in practice only sizes are checked). The log
+signatures for the test are inferred from the compatibility research, not
+from a real Frame.
+
+## Evidence and limits
+
+**Verified locally** with a fake Meta and a fake Frame (`tests/test_meta.py`):
+token checks; storage owner-only through a temporary file; redaction (the
+stored token recased and percent-encoded in either case, starts of it, other
+token-shaped values, and redaction before cutting); HTTPS-only URLs; the
+public-address allow-list, resolving once and connecting only to a checked
+address within one deadline; redirects off Meta's hosts refused when they
+carry the token anywhere, before any lookup; the token never sent to the
+CDN; GraphQL redirects refused; disconnect beating a connect in flight and
+stopping a download; library parsing (Quest apps only, nothing expired or
+inactive, details from the binary query); downloads with an OBB; size, length
+and checksum limits and the whole-install budget with nothing left behind;
+refusing apps not in the
+library; the handoff to Import from my Quest's checks, install and data step;
+the licence-check warning instead of a refusal; test outcomes from log
+fixtures (a passing licence check isn't a failure; no VR scene is no clear
+result, not recorded); closing before a test and saying when a close failed;
+reports carrying only fixed texts, no token, log text or file names. The
+real TLS connection code is the same as the itch.io branch's and is exercised
+here only with a fake connection.
+
+**Sign in with Meta, verified locally** with fake Electron windows, sessions
+and cookies (`app/test/meta-signin.test.js`): the window's options (its own
+partition, sandbox, context isolation, no Node, no preload) and Meta's login
+address, no developer tools; permission requests and checks refused; the
+navigation allow-list (links elsewhere open in the system browser, redirects
+elsewhere are stopped, pop-ups never become windows); frames allowed any
+HTTPS page but no other scheme, the window itself only Meta's sites; a
+page's beforeunload not keeping the window open; the cookie read only from
+`persist:meta-signin` and only for `oculus.com`, even when the default
+session and another partition hold one; signed-out values ignored; a value
+sent once, a refused one keeping the window open; the window closing after a
+connect, with the page getting the answer and never the token; nothing
+logged, and a server error that echoed the token scrubbed; closing the window
+or signing out ending the wait; closing, timing out or signing out during a
+connect cancelling that attempt (with its own id) after cookie intake stops
+and only after that connect settles (saved, refused or failed), and no cancel
+when nothing was sent; sign-out (Disconnect's first step) waiting for a
+connect under way, even from a window closed earlier, before it finishes;
+sign-out destroying the window before clearing only that partition; no
+sign-in starting while a sign-out runs (it is refused: "try again in a
+moment"), a second sign-out joining the first, and a sign-out waiting for
+every window and connect started before it. The
+frame redirects held to the frame rule, the window's to Meta's sites. The
+server side (`tests/test_meta.py`): an attempt cancelled while Meta is
+answering not saved, even after 300 other cancels; a cancel just after the
+save deleting that token; a cancel never removing a later sign-in's or a
+pasted token's account; unknown attempts changing nothing; no attempt left
+listed after a connect, refused or not; attempt ids checked.
+
+**Differences from BSManager:** BSManager uses its app's default session (and
+clears only `.oculus.com` cookies afterwards); Frame Control uses a session of
+its own and clears all of it on disconnect. BSManager waits for
+`/my/profile`; Frame Control also reacts to the cookie changing, and to any
+`oculus.com` page, and lets the server decide whether the value is a
+working token.
+
+**Not verified:** Sign in with Meta against Meta's real login page (no real
+Meta account was used, and nothing was sent to Meta): whether Meta's login
+still goes only through `oculus.com`, `meta.com` and `facebook.com`, whether
+it uses pop-ups (they open in the same window here, which would break a
+pop-up that reports back to its opener), whether `oc_ac_at` is still set on
+`oculus.com` after login, and whether Meta accepts the window (Frame Control
+removes Electron's name from the user agent as a precaution). A real Meta token and Meta's real answers (none was
+available; nothing was sent to Meta), real downloads and their redirects,
+real Quest games installed or tested on a real Frame, and whether the log
+signatures match what real games print in Lepton.
diff --git a/docs/privacy.md b/docs/privacy.md
index 141f76d..af26741 100644
--- a/docs/privacy.md
+++ b/docs/privacy.md
@@ -33,23 +33,46 @@ computer, exactly as they were sent.
yours.
- Every event includes the app version, OS name (macOS, Windows or Linux),
CPU architecture and Python version.
+- `app_installed`, `app_updated` and `app_opened` carry `country`: a
+ two-letter code such as `DE`, worked out on your computer from its time zone
+ setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
+ Control is used without knowing your address. The time zone itself isn't
+ sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
+ Copies that showed the first-run notice before it mentioned this (before
+ 0.4.6) show it again, and don't send a country until it has been seen.
+- `frame_connected` carries `headset_id`: the first 16 hex digits of a
+ SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
+ so the machine-id itself never leaves it. It is the same for one headset on
+ every computer, which lets us count headsets rather than installs; it can't
+ be turned back into the machine-id or a serial number. `headsets_saved` is how
+ many headsets this copy of Frame Control has set up.
## 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` |
+| `app_installed` | First run | `country` |
+| `app_updated` | First run of a new version | `from_version`, `country` |
+| `app_opened` | At most once a day | `country` |
+| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
| `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 |
+| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, 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:
+error becomes one category from this fixed list, plus Android's own
+`INSTALL_FAILED_…` code (`installer_code`) when there is one:
+`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
+`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
+`cant_run_on_frame`, `steam_shortcut`, `steam_not_running`,
+`steam_not_responding`, `steam_js_error`, `artwork_failed`, `copy_timeout`,
+`frame_not_set_up`, `frame_auth`, `frame_unreachable`, `frame_disk_full`,
+`copy_failed`, `download_failed`, `flatpak`, `cancelled`, `lepton`,
+`internal_error` (a mistake in Frame Control's own code) or `other`. A VR
+APK that installed without Frame Control's OpenXR compatibility layer,
+because this copy of the app is missing it, carries `xr_layer_missing: true`;
+otherwise that field is left out. 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
@@ -67,13 +90,21 @@ 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,
+- the APK's source is kept only if it's `F-Droid`, `meta-store` (a test
+ from [My Meta library](meta-library.md)) 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.
+My Meta library (Experimental) keeps your Meta access token in
+`meta-account.json` in this data folder, owner-only, and sends it only to
+Meta's API and download hosts. It is never in analytics, reports, error
+details or logs; its test reports carry the package, version, version code,
+title, result and the reason, nothing from your account. See
+[meta-library.md](meta-library.md#your-token-and-privacy).
+
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.
@@ -93,7 +124,11 @@ scrubbed:
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.
+The same error is sent at most once every 10 minutes. When ssh reports that
+it couldn't reach the Frame (asleep, away, or not set up yet), or that ssh and
+the Frame refused each other's keys (a changed host key, or this computer's
+key not accepted), that is sent at most once per kind each time Frame Control
+runs.
## Report a problem
@@ -103,9 +138,18 @@ privately to Frame Control's PostHog project as a `problem_report` event, the
same way as the analytics above, so only the maintainer can read it and
nothing is published. It works whatever the analytics settings are, because
the person sends it deliberately. The report has the kind, title and text you
-wrote, how to reach you if you gave it, a short reference shown after sending,
-and the diagnostics below. It has its own random id, so it isn't linked to
-your analytics events.
+wrote, a short reference shown after sending, and the diagnostics below. Your
+email address goes with it only if you tick **The maintainer may contact me
+with follow-up questions** (the report then carries `contact_followup: true`);
+it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
+your analytics events. With that box ticked, the address also becomes your
+**Contact email** below with follow-up questions ticked, so you remove it there
+like any other. If it's a different address from the one saved there, it
+replaces it, and update notices stop until you turn them on again (they were
+agreed for the old address); the form says so before you send. The report then also
+carries this copy's contact id and change number (`contact_id`, `contact_rev`,
+see below), so removing or changing the address later takes back the
+follow-up permission given with the report too.
With **Include diagnostics** ticked (the default), the report adds:
@@ -113,10 +157,56 @@ With **Include diagnostics** ticked (the default), the report adds:
- 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
+- a short connection summary, so "the app can't find the headset" can be
+ diagnosed. It is made of fixed words and counts only; no text from an
+ error message or from ssh, and no name you chose, goes in it:
+ - the connector's state (idle, connecting, connected or failed), the stage
+ it failed at (network, find, ssh, identity or login), the attempt number
+ and why it started (such as "Starting up" or "Trying again")
+ - how many headsets are saved; whether the active one's ssh alias is the
+ default `frame` or a custom one (a custom alias itself is never named);
+ whether it's saved or a bare ssh alias; and its number of addresses
+ - for the current error and the last failure: the stage and an error
+ category (the same fixed names as error details, such as
+ `frame_unreachable`, `frame_not_set_up`, `frame_auth` or `other`), and
+ how long ago the last failure was. The category is decided when the
+ failure happens
+ - for each address tried, only its kind (`.local`, `ipv4`, `ipv6`,
+ `ipv6 link-local`, `tailscale`, `hostname`, `alias` for one read from
+ `~/.ssh/config`, and what a name resolved to, such as
+ `.local->ipv6 link-local`) and how the try went (answered, unresolved,
+ timeout, refused, unreachable, sshfailed). Never the address itself
+ - when connected, the kind of address used and its round trip in ms
+ - whether this computer has a network gateway, and whether Tailscale is on,
+ off or not installed
+ - which kind of `ssh` the app runs (Windows OpenSSH in System32, OpenSSH in
+ Program Files, Git for Windows, MSYS2/Cygwin, Homebrew or /usr/local,
+ Nix, the system one, or "other"), never its path; its version, rebuilt
+ from the numbers in the banner `ssh -V` prints (such as
+ `OpenSSH for Windows 9.5p1, LibreSSL 3.8.2`; anything that isn't a
+ plain OpenSSH banner is reported as `unknown`); and the kinds of any
+ other `ssh` on the PATH.
+ This is looked up once in the background after the app starts, so a
+ report sent straight away may say "still being checked"
+ - whether `~/.ssh/config` exists, whether it has Set Up Connection's
+ managed block for the active alias, how many managed blocks it has, and
+ whether a hand-written `Host` line also names the alias (yes or no; the
+ alias isn't named)
**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.
+Activity lines and server log lines, without the request lines. The server
+log has a line for each failed connection attempt: its stage, the message
+shown on the connection pill, ssh's last line about it, and the address kinds
+above. That free text is scrubbed when the line is written: the addresses, ssh alias
+and display name of the headset being tried (whole, whatever their case), and
+then any name ssh gives after "hostname", "host" or "to", become ``; a Windows home folder's
+whole name (spaces and apostrophes included) becomes ``, and ssh's
+whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
+included) becomes `@:`; then the scrubbing below. A host name ssh mentions
+in some other wording can still get through, so check the log lines in **Show
+exactly what's included** before sending. A failure that repeats on every
+retry is written at most once every 5 minutes.
Everything is scrubbed like error details and limited to what fits in the
report. Environment details are kept first, then the newest lines. **Show
@@ -128,11 +218,67 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
API key as `frame_compat_db.py sync`.
+## Contact email (optional)
+
+Frame Control never needs an email address. If you'd like to leave one, there
+are two separate choices, both off until you tick them:
+
+| Choice | What it's for |
+|---|---|
+| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
+| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
+
+You're asked once, in a bar at the top of the page, after the Frame has
+connected for the first time, and never in the same visit as the first-run
+privacy notice. **No thanks** hides it for good, and it isn't
+shown again even if you ignore it. **Contact email** in **Privacy & updates**
+is where you add, change or remove the address and either choice at any time.
+
+**What's sent, and where.** The address and the two choices go privately to
+Frame Control's PostHog project, the same place as problem reports, as a
+`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
+or `withdraw`) and the common properties above. Only the maintainer can read
+that project, and nothing in it is published or shared. It's sent only when
+you save, or when you send a problem report with follow-up questions ticked,
+whatever the analytics settings are, because you chose to. With a report, the
+address and choices are saved before the report is sent and stay saved if it
+fails; like any change, they're sent as soon as PostHog can be reached. It
+carries its own random contact id, not the analytics id, so it isn't linked
+to your usage events, and a `rev` number that goes up with each change, so
+the newest choice always wins. Like everything else sent, it's listed under
+**Show what's been sent**. On this computer the address and choices are kept in
+`contact/contact.json` in Frame Control's data folder. An address is only
+kept with at least one choice ticked.
+
+**Removing it.** **Remove my email** (or clearing the address and saving)
+deletes it from this computer, including from the **Show what's been sent**
+log (in earlier contact events and problem reports), and sends a `withdraw`
+event with no address in it. The maintainer's list only uses the newest event from each copy, so from
+then on the address isn't listed for either choice. Unticking one choice
+works the same way for that choice. This also covers problem reports you sent
+from this copy with follow-up questions ticked: if your newest choice since the
+report (by change number, not the clock) no longer agrees to follow-up
+questions at that address, the maintainer's inbox shows the permission as
+withdrawn and leaves the address out. If you're offline, the change waits on
+this computer and is sent when PostHog can be reached. The earlier event
+stays in PostHog until its data retention removes it; to have it deleted
+sooner, ask the maintainer (for example in a problem report).
+
+Nothing sends email yet: this only records who agreed to what. The
+maintainer lists the addresses with
+`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
+personal API key as `inbox`.
+
## 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.
+never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
+
+These switches cover the analytics above. A problem report or a contact email
+is sent only because you pressed its Send or Save button, so those still go
+when you choose to send them (a contact change saved while offline is sent
+by itself once PostHog can be reached); if you don't, nothing is sent.
## Update checks
diff --git a/docs/quest-import.md b/docs/quest-import.md
new file mode 100644
index 0000000..a67421b
--- /dev/null
+++ b/docs/quest-import.md
@@ -0,0 +1,219 @@
+# Import from my Quest
+
+Move games you own from your own Meta Quest onto the Steam Frame. Plug the
+Quest into your computer by USB; Frame Control copies the game off it with
+adb, checks it can run in Lepton, and installs it on the Frame like any other
+APK, with its own Lepton instance and Steam library entry
+([apks.md](apks.md), [vr-apks.md](vr-apks.md)).
+
+There is no licence-check bypass, and there won't be. A game that uses Meta's
+licence check installs with a warning: on the Frame that check can't reach
+Meta, so the game may quit, hang, or run without online features. See
+[What won't run](#what-wont-run-and-why) below. To install games you own
+straight from Meta instead, without a Quest, see [My Meta library
+(Experimental)](meta-library.md).
+
+## Before you start: developer mode on the Quest
+
+adb only reaches a Quest with developer mode on:
+
+1. Make a free Meta developer organisation at
+ [developers.meta.com](https://developers.meta.com/horizon/) on a **verified**
+ Meta account (Meta asks for a phone number or payment card to verify).
+2. In the Meta Horizon phone app, open the headset's settings and switch
+ **Developer Mode** on. Restart the Quest.
+3. Plug the Quest into the computer that runs Frame Control with a USB cable
+ that carries data (some charging cables don't).
+4. Put the headset on. Accept **Allow USB debugging**, and tick **Always allow
+ from this computer**.
+
+Frame Control uses the adb it already uses for the Frame (an adb you
+installed, else the one it bundles). On Linux, adb also needs permission to
+open the Quest: add a udev rule for Meta's USB vendor id `2833`, for example
+`SUBSYSTEM=="usb", ATTR{idVendor}=="2833", MODE="0660", GROUP="plugdev"`, and
+be in that group. Then unplug and replug.
+
+The Frame's own adb connections (Frame Control's SSH tunnels to Lepton,
+`127.0.0.1:`) are never touched. Only a device that reports itself as
+Oculus or Meta, or a Quest model, counts as a Quest.
+
+## Using it
+
+In the app: **Android → On your Frame → Import from my Quest**. Choose **Look
+for my Quest**, then **Import** next to a game. Tick **Also copy each game's
+files in Android/data** to bring the game's external files too (some games
+keep saves there). Progress shows in the panel; the result and any reason it
+stopped appear in the message log.
+
+You can choose **Import** on several games at once: they wait in the **Install
+queue** (above the Quest panel) and import one after another, together with
+installs from the Android store and My Meta library. **Remove** takes a game
+out of the queue before it starts. Each queued import runs on the headset that
+was selected when you queued it; if you switch headsets first, it stops with a
+message instead.
+
+A game with game data (an OBB folder, or Android/data when ticked) takes two
+imports, because Android can only take that data while the game's Lepton
+instance is running, and there is no way to start the instance without the
+game. Frame Control never starts the game itself:
+
+1. **Import** installs the game and says it has data to add.
+2. Start the game from the Steam library and leave it on its first screen.
+3. **Import** again. Frame Control sees the game open with the same version,
+ copies the data in, and closes the game. Start it again to play.
+
+A game that quits straight away without its data can't take it this way.
+
+From a terminal:
+
+```sh
+python3 ui/frame_quest.py devices # what adb sees, and which is the Quest
+python3 ui/frame_quest.py list # apps on the Quest: split or not, OBB and data sizes
+python3 ui/frame_quest.py import com.example.game # copy one to the Frame
+python3 ui/frame_quest.py import com.example.game --data --serial 1WMHH000XXXXXX
+```
+
+The API is `GET /api/quest` (the Quest, or guidance when there isn't one, and
+its apps) and `POST /api/quest` with
+`{"action": "import", "package": "...", "serial": "...", "data": false}`,
+which returns a background job (`/api/job`) with a stage and percentage. The
+job waits in the install queue first (`"queued": true`, stage `Queued: N
+installs before it`). `GET /api/queue` lists the running install and those
+waiting; `POST /api/queue` with `{"action": "remove", "job": "..."}` removes a
+waiting one (409 once it has started).
+
+## What an import does
+
+1. Finds the Quest (`adb devices -l`, then `ro.product.manufacturer` and
+ `ro.product.model`). If none is ready it says why: nothing connected, not
+ authorised yet (the **Allow USB debugging** prompt), no USB permission
+ (Linux), offline, another Android device, or several Quests.
+2. Lists third-party apps (`pm list packages -3`), each with `pm path` (one
+ line is a single APK, several a split APK) and the size of
+ `/sdcard/Android/obb/` and `/sdcard/Android/data/`.
+ Labels aren't shown before import: reading them means copying the APK.
+3. Checks there is room on the computer (the APKs, a merged and a patched copy
+ of them counted uncompressed, the OBB folder and the Android/data folder
+ once, since that is streamed to the Frame), then copies the APK and its splits
+ into a per-user cache folder (`Frame Control/quest-import` under the
+ system's cache folder).
+4. **Checks the APK before anything else is copied**, refusing:
+ - 32-bit-only builds (native code but no `arm64-v8a`);
+ - apps that need an Android newer than Lepton's (API 30);
+ - split APKs Frame Control can't merge (below).
+ Installed with a warning:
+ - games that use Meta's licence check (Meta's Platform SDK,
+ `libovrplatformloader.so`, in the base or any split): "uses Meta's
+ licence check; it may quit, hang, or run without online features on the
+ Frame. Frame Control won't remove the check." Linking the SDK doesn't
+ mean a game enforces the check: Meta's store requirement for it
+ (VRC.Quest.Security.1) has only been *Recommended* since 2021, and what
+ a game does when the check fails is up to the game;
+ - VrApi-only builds (`libvrapi.so`, no OpenXR loader), flagged as for any
+ APK: the Frame has no VrApi, so they may not run.
+5. If the game isn't open on the Frame: installs it through the normal APK
+ install (`frame_android.install`: VR detection, the launcher fix, the
+ OpenXR compatibility layer, artwork), and says whether it has game data
+ to add. An older Quest copy never replaces a newer version on the Frame
+ (compared by version code, which installs now record; for installs made
+ before that, it is read from the installed APK on the Frame) unless you
+ tick **Allow replacing a newer version** (`--allow-downgrade`,
+ `"allowDowngrade": true`). If the installed version can't be read at all,
+ that permission is needed too.
+6. If the game is open on the Frame (the second import): refuses unless
+ Frame Control's install record is for this package, in this package's own
+ instance, at the Quest's exact version code (an open game is never
+ replaced), otherwise copies the OBB folder (and `Android/data` if ticked)
+ from the Quest, waits for Android to have finished booting, copies standard
+ `main|patch...obb` files with the existing OBB helper
+ (checked by SHA-256 inside Android, [sidequest.md](sidequest.md#obb-files))
+ once Android's own `dumpsys package` in that instance shows the same
+ package at the same version code,
+ streams the `Android/data` files in as a tar written straight into the SSH
+ transfer, then closes the game so its next start reads them, and checks
+ its instance has stopped; if it hasn't, or the check itself fails (a
+ failed `podman ps` never reads as "stopped"), the result says so. The game
+ is closed whatever happens during the copy, including a local read error
+ or Ctrl-C, and a failed close is reported without hiding the original
+ error. Everything
+ from the second check of the install record (after the pulls, which can
+ be long) to closing the game holds the same lock as installs and removals.
+ Other files in the OBB folder are listed as not copied.
+7. Deletes the cache folder, whether the import worked or not. A folder left
+ by a crash is removed by a later import after six hours.
+
+Only one import runs at a time. If copying game data fails, the game is
+closed and the message says so; start it and import again.
+
+### Split APKs
+
+Lepton runs one APK per instance, so the shared install path now merges a
+split APK into one (`frame_android.install(base, splits=[...])`, or
+`python3 ui/frame_android.py install base.apk split1.apk ...`). Merging copies
+each split's native libraries and assets into the base, drops the base
+manifest's `isSplitRequired` flag, and re-signs with Frame Control's key, as
+the launcher fix already does. Only splits that hold nothing but
+`lib//*.so`, `assets/` and their own signature, manifest and an empty
+resource table are merged. Refused, with the reason, before anything reaches
+the Frame: splits with resources (screen density or language), code
+(`classes*.dex`), any other file, a manifest that declares components or
+libraries (``, ``...), feature splits, and splits of
+another package. The merged files are held in memory while the APK is
+repacked and signed (as for every patched APK), so a merge adds at most
+512 MB per file and 1 GB in all. Each file is read from its raw bytes and
+checked against both of its ZIP headers: the local header must name the same
+file with the same method, sizes and checksum as the central directory (or a
+data descriptor must carry them; ZIP64 sizes are understood), the data must
+end before the next entry, a stored file must be exactly its declared size, a
+compressed one must end exactly at the end of its data with nothing after it,
+and its checksum must match. So a forged header can't truncate a file or fill
+memory. A resource table counts as empty only
+if it defines nothing at all (styles and other complex entries included). A merged, re-signed APK can't update a
+copy signed by the publisher.
+
+## What won't run, and why
+
+- **Games that enforce their Meta store licence.** Many store games link
+ Meta's Platform SDK (`libovrplatformloader.so`), for the licence check and
+ for user ids, achievements, multiplayer, cloud saves or DLC. On the Frame the
+ SDK can't reach Meta's services, which only exist on Meta headsets. A game
+ that enforces the check then quits or hangs; one that doesn't may run
+ without its online features. Frame Control can't tell which from the APK, so
+ it installs these with a warning and **won't remove or work around the
+ check**, and doesn't bundle, call or link to any tool that does. If yours
+ quits, ask the developer for a build without the check, or get the game from
+ Steam or itch.io. Most SideQuest, itch.io and open-source games don't link
+ the SDK.
+- **32-bit games** (Quest 1 era): the Frame runs 64-bit ARM only. Refused.
+- **VrApi-only games**: the Frame has OpenXR, not Meta's older VrApi. They
+ install with a warning, but may not run.
+- **Anything else that needs Meta**: avatars, Quest-only device checks,
+ passthrough and other `XR_FB_*` features SteamVR lacks
+ ([vr-apks.md](vr-apks.md#what-steamvrs-android-runtime-supports-verified)).
+- **Private app data** (`/data/data/`) can't be read over adb without
+ root, so only saves in `Android/data` come across.
+
+What usually works: OpenXR games without Meta's licence check, for example
+open-source and itch.io builds you sideloaded onto the Quest.
+
+Only import games you own, for your own use.
+
+## Evidence and limits
+
+**Verified locally** with a fake adb (`tests/fakeadb.py`, `tests/test_quest.py`):
+device detection (Quest, the Frame's tunnel, the Frame over USB, other
+Android devices, unauthorised, no permissions, offline, none, several),
+package listing and split detection, the refusals and the licence-check warning, the order of copies
+(APK checked before the OBB is copied), the two-step install then data copy
+(the game is never started by Frame Control, never replaced while open, and
+closed after its data is copied), split merging and signing, the split
+refusals and size limits (including a forged zip bomb), the space estimate,
+and cache clean-up after success and failure.
+
+**Not verified:** a real Quest (none was available), Quest system apps in
+`pm list packages -3`, real split APKs from the Meta Store, OBB and
+`Android/data` writes into a running Lepton instance on a real Frame
+(the OBB helper itself is also unverified there), whether games stay open
+without their OBB long enough for the second import, and the Linux udev rule.
+Large games are copied into memory while being merged or patched, as every
+patched APK already is.
diff --git a/docs/releasing.md b/docs/releasing.md
index 7db5e3a..c4ce8e1 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -24,13 +24,20 @@ count. So a build reaches people only when you publish it, after testing it.
4. Publish:
```sh
+ scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
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
+ The script checks that the tag is on GitHub and that all eight installers
+ are attached, each with the SHA-256 digest GitHub records. It attaches
+ `update.json` (the version, the notes, the release page and each
+ installer's digest), then publishes the release and marks it latest. It
+ uses only the REST API: `gh release view` can't find a draft whose
+ `tag_name` still reads `untagged-…`, and GraphQL is often rate-limited.
+ Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
+ followed by `: subtitle`) and tied to the tag when it's published. The release page in `update.json` is always
+ `releases/tag/`, because a draft's own address (`releases/tag/untagged-…`)
+ stops working once it's published. 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).
diff --git a/docs/streaming.md b/docs/streaming.md
index 773e698..05a95c6 100644
--- a/docs/streaming.md
+++ b/docs/streaming.md
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
Mac with keyboard, mouse, and clipboard.
+**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
+Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
+Mode password opens a Plasma (X11) desktop within about 6 seconds.
+
+- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
+ `www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
+ fills in `steamos` there on Windows, Remmina and FreeRDP.
+- The desktop is a separate login session (Xorg on display `:10`), not the
+ headset's view. It uses about 1.3 GB of the Frame's memory.
+- Closing the client leaves the session running, and the next login
+ reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
+ and run `loginctl terminate-session `. That doesn't touch the headset's
+ gamescope or SteamVR session.
+
## B. Show the Mac's desktop inside the Frame
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
@@ -247,6 +261,16 @@ How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
-window_id -i :` works on gamescope's redirected windows,
while grabbing the root gives black. It streams as H.264 like the headset view
(about 30 fps at 720p).
+- **When no panel is found**, `frame_touch.py panels` says why (`"why"`), and the
+ Desktop view shows that instead of a bare "no app panel": X tools missing,
+ gamescope listing windows that aren't X11 windows on any display, the desktop
+ running but not listed, or nothing open. It reads gamescope's properties from
+ `:0` first (one `xprop` call) and probes the other displays, 2 s each, only if `:0`
+ has none. A whole run stops after 12 s, inside the server's 20 s, and says the headset
+ was too slow. Added for
+ [#73](https://github.com/saphid/frame-control/issues/73) (SteamOS 0.4.5, desktop
+ open but not found). The likely cause there was the page itself: a second
+ `loadPanels` (the panel switcher's) replaced the Desktop view's, fixed in #84.
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
text box in the panel and the text field typed into it; a trackpad move went
exactly (+40, +25).
diff --git a/docs/testing.md b/docs/testing.md
index 82a1826..7234cd7 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -15,6 +15,29 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
python3 -m unittest discover -s tests
```
+The Control Center is covered by `tests/test_control_center.py` and its Node
+behavior suite `tests/control_center.cjs` (also run by unittest discovery).
+They exercise production search, metrics, navigation and action handlers with
+mocked API/DOM boundaries: volume Apply/mute/readback, offline and failed reads,
+stale samples, selected-headset capture, duplicate actions and validation before
+SSH. Integration regressions run the production shared refresh, Home renderer,
+host callback and Control Center bootstrap: a pre-command status request must be
+drained before audio readback starts, and delayed startup retains sample receipt
+age, failed-read state and early host capabilities. The actual Flatpak install
+redraw cannot revive a failed sample; a new successful poll restores metrics and
+audio after either redraw or delayed mount. Draft regressions cover a pending headset
+switch and mute followed by Apply. Static asset HTTP tests and desktop/iPhone bundle checks catch missing CSS
+or JavaScript. A focused run is:
+
+```sh
+python3 -m unittest discover -s tests -p test_control_center.py -v
+node --test tests/control_center.cjs
+node --check ui/control-center.js
+```
+
+Responsive layout, actual browser rendering and keyboard/touch behavior still
+need browser verification. No Control Center test connects to a real headset.
+
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
diff --git a/docs/vr-apks.md b/docs/vr-apks.md
index 38ba7ff..240bb91 100644
--- a/docs/vr-apks.md
+++ b/docs/vr-apks.md
@@ -214,9 +214,16 @@ source-search endpoint remain unverified (the public install seam is tested).
- **Meta entitlement.** Apps that call the Oculus Platform SDK
(`libovrplatformloader.so`) to check the Quest store licence need Meta's
- services. Frame Control won't work around that.
+ services. Frame Control won't work around that: no patching, no stub
+ libraries, no OVRPort or FramePort. Linking the SDK doesn't mean an app
+ enforces the check (Meta's VRC.Quest.Security.1 has only been
+ *Recommended* since 2021, and a failed check does nothing by itself; the app
+ decides), so [Import from my Quest](quest-import.md) and [My Meta
+ library](meta-library.md) install these with a warning: they may quit,
+ hang, or run without online features.
- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
- not a patch.
+ not a patch. They install with a warning that they may not run: some
+ OpenXR games ship `libvrapi.so` without depending on it.
## Frame Control does this for you
@@ -249,6 +256,10 @@ the app wants 1.1 and enables the extensions that became 1.1 core; maps
and keeps the current refresh rate when SteamVR refuses a requested one.
`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
+If this copy of Frame Control is missing the layer's library (an incomplete
+install, or a file removed after installing), VR apps install without it and
+the install message says so; OpenXR 1.0 apps still run. Release builds fail
+to package without it (`app/build/check-resources.js`).
Verified on the headset (2026-09-28):
diff --git a/frame/openxr-compat/README.md b/frame/openxr-compat/README.md
index 5978979..2dba589 100644
--- a/frame/openxr-compat/README.md
+++ b/frame/openxr-compat/README.md
@@ -190,8 +190,11 @@ ctest --test-dir frame/openxr-compat/build-host --output-on-failure
The script produces arm64-v8a / android-24, C++17, `-O2`, static libc++, hidden
internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
-The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
-Its SHA-256 is recorded below with the APK checks.
+The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz`,
+gzipped (`gzip -9 -n`) so the Windows installer carries it intact: electron-builder's
+7-Zip compresses a bare arm64 ELF with its ARM64 filter, which the installer's
+extractor skips. Frame Control decompresses it when injecting the layer. The
+SHA-256 of the uncompressed library is recorded below with the APK checks.
[Host test output](evidence/ctest.txt): two tests passed, covering pure logic
and the actual layer with a fake next layer and host-only log/JNI shims.
diff --git a/frame/openxr-compat/build.sh b/frame/openxr-compat/build.sh
index 3b08089..ed3232b 100755
--- a/frame/openxr-compat/build.sh
+++ b/frame/openxr-compat/build.sh
@@ -8,5 +8,8 @@ cmake -S "$here" -B "$here/build-android" -G Ninja \
-DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
cmake --build "$here/build-android"
mkdir -p "$here/prebuilt/arm64-v8a"
-cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
-shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
+# Committed and shipped gzipped: electron-builder's 7-Zip applies its ARM64
+# branch filter to a bare arm64 ELF, which the Windows installer's extractor
+# can't decode, so the installed app silently lacked the library.
+shasum -a 256 "$here/build-android/libXrApiLayer_FRAME_compat.so"
+gzip -9 -n -c "$here/build-android/libXrApiLayer_FRAME_compat.so" > "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz"
diff --git a/frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so b/frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so
deleted file mode 100755
index 608cdd1..0000000
Binary files a/frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so and /dev/null differ
diff --git a/frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz b/frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz
new file mode 100644
index 0000000..fef87a7
Binary files /dev/null and b/frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz differ
diff --git a/ios/scripts/make_frame_bundle.py b/ios/scripts/make_frame_bundle.py
index ef487a5..fdbb4d5 100644
--- a/ios/scripts/make_frame_bundle.py
+++ b/ios/scripts/make_frame_bundle.py
@@ -18,7 +18,7 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
-PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
+PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh
index 82b5c10..9101c45 100755
--- a/scripts/publish-release.sh
+++ b/scripts/publish-release.sh
@@ -3,23 +3,65 @@
# (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
+# Usage: scripts/publish-release.sh [--dry-run] v0.4.0
+#
+# Everything goes through the REST API (gh api), not `gh release view/edit`:
+# those look a draft up by its tag, and a draft can show tag_name
+# "untagged-..." until it's published, so they report "release not found"
+# (and GraphQL is often rate-limited). A draft's html_url is an untagged-...
+# link that dies on publishing, so update.json's page is built from the tag.
set -eu
-tag="${1:?usage: $0 vX.Y.Z}"
+dry=""
+[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
+tag="${1:?usage: $0 [--dry-run] 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"
+page="https://github.com/$repo/releases/tag/$tag"
-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)"
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+
+# Publishing sets tag_name; if the tag didn't exist GitHub would create it on
+# the default branch, which isn't what was built and tested.
+gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
+ || { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
+
+# Every release, drafts included, one JSON object per line.
+gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
+# The release whose tag_name is the tag; failing that, the one untagged-... draft
+# titled "Frame Control X.Y.Z" (release.yml's title), optionally ": subtitle".
+python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF'
+import json, re, sys
+tag, path = sys.argv[1], sys.argv[2]
+rels = [json.loads(line) for line in open(path) if line.strip()]
+hits = [r for r in rels if r.get("tag_name") == tag]
+if not hits:
+ # Exactly this version: "Frame Control 0.4.0", or that followed by ": ".
+ # Never "Frame Control 0.4.0-rc.1" or "0.4.00", and only drafts with no real tag.
+ title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(: .*)?", re.S)
+ hits = [r for r in rels if r.get("draft") and str(r.get("tag_name") or "").startswith("untagged-")
+ and title.fullmatch(r.get("name") or "")]
+if len(hits) != 1:
+ why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits))
+ sys.exit("found %s for %s; expected one draft" % (why, tag))
+r = hits[0]
+if not r.get("draft"):
+ print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr)
+json.dump(r, sys.stdout)
+EOF
+id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
+echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
+
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")
+ digest=$(python3 -c 'import json,sys
+d=json.load(open(sys.argv[1])); n=sys.argv[2]
+print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$tmp/release.json" "$name")
case "$digest" in
sha256:*) echo "ok $name" ;;
absent) echo "MISSING $name"; missing=1 ;;
@@ -30,16 +72,32 @@ done
# 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],
+python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
+import json, sys
+d = json.load(open(sys.argv[1]))
+tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split())
+print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[: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
+ for a in d["assets"] if a["name"] in names]}, indent=1))
+EOF
+old=$(python3 -c 'import json,sys
+d=json.load(open(sys.argv[1]))
+print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))' "$tmp/release.json")
+
+if [ -n "$dry" ]; then
+ echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
+ cat "$tmp/update.json"
+ echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
+ exit 0
+fi
+
+for asset in $old; do
+ gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
+done
+gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
+ -H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
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"
+gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
+ -f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
+echo "running copies will offer $tag at their next check"
diff --git a/scripts/timezones.py b/scripts/timezones.py
new file mode 100644
index 0000000..e11a9ab
--- /dev/null
+++ b/scripts/timezones.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
+
+From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
+names that link to them), as installed on macOS and most Linux systems. It prints the
+old names left out because their zone is shared by several countries: check any new
+one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
+
+Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
+"""
+import sys
+from pathlib import Path
+
+src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
+country = {}
+for line in (src / 'zone.tab').read_text().splitlines():
+ if line and not line.startswith('#'):
+ cc, _, zone = line.split('\t')[:3]
+ country[zone] = cc
+links = {}
+for line in (src / 'tzdata.zi').read_text().splitlines():
+ if line.startswith('L '):
+ _, target, alias = line.split()
+ links[alias] = target
+def resolve(z):
+ for _ in range(5):
+ z = links.get(z, z)
+ return z
+
+
+# An old name links to a zone with the same clocks, which can be in another country
+# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
+# zone.tab zone sharing that target is in one country.
+countries_of = {}
+for zone, cc in country.items():
+ countries_of.setdefault(resolve(zone), set()).add(cc)
+# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
+GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
+# Places that have left zone.tab, so their target's group looks like another country's.
+PLACES = {'Pacific/Johnston': 'UM'}
+dropped = []
+for alias in links:
+ if alias in country or alias in GENERIC or alias.startswith('Etc/'):
+ continue
+ if alias in PLACES:
+ country[alias] = PLACES[alias]
+ continue
+ ccs = countries_of.get(resolve(alias), set())
+ if len(ccs) == 1:
+ country[alias] = next(iter(ccs))
+ else:
+ dropped.append(alias)
+# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
+OLD_SPELLINGS = {
+ 'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
+ 'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
+ 'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
+ 'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
+ 'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
+ 'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
+ 'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
+ 'Pacific/Yap': 'Pacific/Chuuk'}
+for alias, zone in OLD_SPELLINGS.items():
+ if alias in links and zone in country:
+ country[alias] = country[zone]
+ dropped.remove(alias)
+version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
+rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
+out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
+out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
+the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
+Zones that aren't in one country, such as UTC, aren't here."""
+COUNTRY = {{
+{rows}}}
+''')
+print(f'{len(country)} zones, tz {version} -> {out}')
+print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
diff --git a/tests/apk_fixtures.py b/tests/apk_fixtures.py
new file mode 100644
index 0000000..20cf7bf
--- /dev/null
+++ b/tests/apk_fixtures.py
@@ -0,0 +1,87 @@
+"""Small APKs built in memory (binary manifest plus resource table), for test_frame_apk
+and the end-to-end tests. Deliberately doesn't import sandbox: tests/e2e runs in a
+throwaway container and keeps its real home, which sandbox would move."""
+import io
+import os
+import struct
+import sys
+import zipfile
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
+import frame_apk # noqa: E402
+
+
+def pool(strings, utf8=False):
+ """A ResStringPool chunk."""
+ data, offsets = b'', []
+ for s in strings:
+ offsets.append(len(data))
+ if utf8:
+ b = s.encode()
+ data += bytes([len(s), len(b)]) + b + b'\0'
+ else:
+ data += struct.pack('.
+
+ package_raw=False drops the package's raw string (as some repackers do);
+ foreign_label adds a non-android `label` attribute after android:label.
+ """
+ strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
+ 'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
+ resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
+ resmap = struct.pack(' ({time:Date.now()/1000, hostname:'Test Frame', battery:{percent:99,status:'Discharging',watts:0,tempC:33,health:'Good'},
+ power:{type:'C PD [PD_PPS]',watts:18}, disk:{home:{total:256*1073741824,free:128*1073741824}},
+ memory:{total:16*1073741824,available:6*1073741824}, wifi:{ssid:'Test LAN',signal:80},
+ performance:{compositorFps:90,appFps:45,frameMs:11.11,gpuMs:3,cpuPercent:12,gpuMHz:600},
+ volume:{level:.4,muted:false}, os:{version:'0.4.1',build:'test-build'},services:{steamvr:true}, thermalAlerts:[],games:[],flatpaks:[]});
+function fixture() {
+ const elements = new Map();
+ function element(id) {
+ if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
+ classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
+ return elements.get(id);
+ }
+ const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
+ const buttons = cc.categories.map(([id])=>{const e=element('category-'+id);e.dataset.ccCategory=id;e.querySelector=()=>element('count-'+id);return e;});
+ const metrics = Object.keys(cc.metrics(null)).map(id=>{const e=element('metric-'+id);e.dataset.ccMetric=id;return e;});
+ const page = element('controlCenter');
+ page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
+ page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
+ return {document:{getElementById:element},element,cards,buttons};
+}
+function mounted(options = {}) {
+ const f = fixture(), calls = [];
+ let context = {online:true,generation:1,device:'frame-a'}, refreshes = 0, opened = 0;
+ const ui = cc.mount({document:f.document,platform:'darwin',api:async(...args)=>{calls.push(args);return {message:'Volume updated'};},
+ refresh:async()=>{refreshes++;},getContext:()=>context,openConnection:()=>opened++,...options});
+ return {...f,ui,calls,get context(){return context;},set context(v){context=v;},get refreshes(){return refreshes;},get opened(){return opened;}};
+}
+const pageSource = fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
+const deferred = () => { let resolve, reject; const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject}; };
+const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
+// Execute the actual shared refresh/deduplication, host callback and mount
+// bootstrap, including the real Home-to-Control status handoff. Only unrelated
+// rendering helpers, timers and transport are stubbed.
+function sharedPage({productionApi=false} = {}) {
+ const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
+ const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
+ Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
+ battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
+ runJob(title,id){context.installing.add(id);return job.promise;},
+ api:(path,body,device)=>{
+ if(path==='/api/host')return host.promise;
+ if(path==='/api/status'){const request=deferred();requests.push(request);return request.promise;}
+ writes.push({path,body,device});return Promise.resolve({message:'Volume updated'});
+ },renderPerformance(){},setConn(){},setOnline(ok){vm.runInContext(`online = ${ok}`,context);},log(){},failed(){},
+ watchConnection(){context.watched=true;}};
+ vm.createContext(context);
+ const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
+ vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
+ const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
+ const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
+ vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
+ if(productionApi) {
+ // Keep api/onConnection/setOnline intact: defer fetch itself, not api(), so
+ // rejection classification and the real switch/reset notifications run.
+ context.fetch=(path,opts)=>{
+ if(path==='/api/host')return host.promise.then(data=>({ok:true,status:200,json:async()=>data}));
+ const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
+ };
+ Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
+ resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
+ vm.runInContext(`const UI_KEY = 'test-only';
+ const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
+ const disp = {}, gm = {seq:0}, cat = {}, wi = {};
+ let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
+ const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
+ const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
+ const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
+ vm.runInContext(pageSource.slice(apiStart,apiEnd)+pageSource.slice(onlineStart,onlineEnd)+pageSource.slice(connectionStart,connectionEnd),context);
+ }
+ const installStart=pageSource.indexOf('function installFlatpak(id) {'),installEnd=pageSource.indexOf('$("fpInstall").onclick',installStart);
+ vm.runInContext(pageSource.slice(installStart,installEnd),context);
+ const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('',hostStart);
+ vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
+ const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('',bootstrapStart);
+ return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
+ switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
+ installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
+ mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
+}
+const response = data => ({ok:true,status:200,json:async()=>data});
+for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
+ test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
+ const f=sharedPage({productionApi:true});
+ if(!delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
+ const old=f.refresh(),body=deferred();
+ assert.equal(f.requests[0].path,'/api/status');
+ if(rejection==='body') {
+ f.requests[0].resolve({ok:true,status:200,json:()=>body.promise});await flush();
+ }
+ f.switchHeadset();
+ assert.equal(f.requests.length,2,'real connected B callback starts its own status fetch');
+ const next=sample();next.hostname='Second Frame';next.battery.percent=20;next.volume.level=.2;
+ f.requests[1].resolve(response(next));await flush();
+ const receipt=vm.runInContext('stateReceivedAt',f.context);
+ assert.equal(vm.runInContext('stateRead.valid && online && devGen === 2',f.context),true);
+ if(!delayedMount)assert.equal(f.element('metric-battery').textContent,'20%');
+ (rejection==='body' ? body : f.requests[0]).reject(Error('Old A request failed'));await old;
+ assert.equal(vm.runInContext('stateRead.valid',f.context),true,'A must not invalidate B');
+ assert.equal(vm.runInContext('stateRead.error',f.context),null);
+ assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
+ if(delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
+ const assertB=()=>{
+ assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
+ assert.equal(f.element('metric-battery').textContent,'20%');
+ assert.equal(f.element('ccVolume').value,20);
+ for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,false);
+ assert.doesNotMatch(f.element('ccSampleState').textContent,/No live sample|local server|Old A/);
+ };
+ assertB();vm.runInContext('render(state)',f.context);assertB();
+ assert.equal(f.requests.length,2,'preservation and redraw need no recovery poll');
+ // An ordinary failure on B must still invalidate the current sample.
+ const failed=f.refresh();
+ if(rejection==='body')f.requests[2].resolve({ok:true,status:200,json:async()=>{throw Error('B body failed');}});
+ else f.requests[2].reject(Error('B transport failed'));
+ await failed;
+ assert.equal(vm.runInContext('stateRead.valid',f.context),false);
+ assert.equal(f.element('metric-battery').textContent,'Unavailable');
+ for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
+ assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
+ });
+}
+test('production headset-free API keeps transport errors and responses across a switch', async t => {
+ for(const failure of [false,true]) {
+ const f=sharedPage({productionApi:true}),ui=f.mount();t.after(()=>ui.destroy());
+ const pending=vm.runInContext('api("/api/devices")',f.context);
+ const result=pending.then(value=>({value}),error=>({error}));
+ f.switchHeadset();f.requests[1].resolve(response(sample()));await flush();
+ if(failure)f.requests[0].reject(Error('Device list transport failed'));
+ else f.requests[0].resolve(response({devices:['frame-b']}));
+ const settled=await result;
+ if(failure){assert.equal(settled.error.stale,undefined);assert.match(settled.error.message,/local server isn't running/);}
+ else assert.deepEqual(settled.value,{devices:['frame-b']});
+ assert.equal(vm.runInContext('stateRead.valid',f.context),true);
+ }
+});
+test('search finds settings by aliases, all words, category and guidance', () => {
+ const find = (q,category='all')=>cc.catalog.filter(item=>cc.matches(item,q,category)).map(item=>item.id);
+ assert.ok(find('DPI').includes('display'));
+ assert.ok(find('sleep inactivity').includes('power'));
+ assert.ok(find('MOTION SICKNESS').includes('playspace'));
+ assert.ok(find('font','apps').includes('display'));
+ assert.deepEqual(find('font','vr'),[]);
+ assert.deepEqual(find(''),[]);
+ assert.ok(find(' ').length > 20);
+});
+test('metrics use real fields, preserve zero, and distinguish unavailable values', () => {
+ const m = cc.metrics(sample());
+ assert.equal(m.battery,'99%');
+ assert.equal(m.power,'0 W');
+ assert.match(m.batteryDetail,/not charging/);
+ assert.equal(m.fps,'90'); assert.equal(m.appFps,'45');
+ assert.equal(m.storage,'128 GiB'); assert.equal(m.memory,'10 GiB');
+ assert.equal(m.thermal,'No hot / critical trip reported');
+ assert.equal(cc.metrics(null).fps,'Unavailable');
+ assert.equal(cc.metrics({performance:{appFps:null,gpuMHz:0,cpuPercent:0}}).appFps,'Unavailable');
+ assert.equal(cc.metrics({performance:{cpuPercent:0}}).cpu,'0%');
+ assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
+ assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
+});
+test('audio controller validates before any API call and rejects offline or expired readings', async () => {
+ let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
+ const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
+ for (const change of [{level:-.1},{level:1.1},{level:NaN},{level:Infinity},{level:'0.5'},{level:null},{muted:'false'},{unknown:true},{}]) {
+ await assert.rejects(controller.audio(change),/valid mute state/);
+ }
+ context.online=false;await assert.rejects(controller.audio({level:.5}),/Connect and refresh/);
+ context.online=true;context.fresh=false;await assert.rejects(controller.audio({muted:true}),/Connect and refresh/);
+ assert.equal(calls.length,0);
+});
+test('audio uses the captured device, prevents duplicate writes, and ignores switched-headset success', async () => {
+ let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},complete,calls=[];
+ const controller=cc.createController({api:(...args)=>{calls.push(args);return new Promise(r=>complete=r);},getContext:()=>context});
+ const pending=controller.audio({level:.7});
+ await assert.rejects(controller.audio({muted:true}),/still in progress/);
+ assert.deepEqual(calls,[['/api/volume',{level:.7},'frame-a']]);
+ context={...context,device:'frame-b',generation:2};complete({message:'Done'});
+ assert.equal(await pending,null);assert.equal(controller.busy,false);
+});
+test('controller surfaces real API errors and allows a deliberate retry', async () => {
+ let calls=0;
+ const controller=cc.createController({api:async()=>{if(++calls===1)throw Error('SSH connection lost');return {message:'Done'};},
+ getContext:()=>({online:true,fresh:true,sample:sample(),device:'frame-a',generation:1})});
+ await assert.rejects(controller.audio({muted:true}),/SSH connection lost/);
+ assert.equal(controller.busy,false);
+ assert.deepEqual(await controller.audio({muted:true}),{message:'Done'});
+});
+test('mounted controls only write on Apply or Mute, show readback, and reset on switch', async t => {
+ const f=mounted();t.after(()=>f.ui.destroy());
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ f.ui.status(sample());
+ assert.equal(f.element('ccApplyVolume').disabled,false);
+ assert.equal(f.element('ccVolume').value,40);
+ f.element('ccVolume').value='65';f.element('ccVolume').oninput();
+ assert.equal(f.calls.length,0);
+ assert.equal(f.element('ccVolumeValue').textContent,'65%');
+ f.ui.status(sample()); // a poll must not overwrite an unapplied value
+ assert.equal(f.element('ccVolume').value,'65');
+ await f.element('ccApplyVolume').onclick();
+ assert.deepEqual(f.calls,[['/api/volume',{level:.65},'frame-a']]);
+ assert.equal(f.refreshes,1);
+ await f.element('ccMute').onclick();
+ assert.deepEqual(f.calls[1],['/api/volume',{muted:true},'frame-a']);
+ f.context={online:null,generation:2,device:'frame-b'};f.ui.reset();
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ assert.equal(f.element('metric-battery').textContent,'Unavailable');
+ assert.equal(f.element('ccAudioResult').textContent,'');
+});
+test('offline/errors clear live readings, disable audio and keep companion navigation usable', async t => {
+ const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
+ f.context.online=false;f.ui.connection(false);
+ assert.equal(f.element('metric-fps').textContent,'Unavailable');
+ assert.equal(f.element('ccVolume').disabled,true);
+ await f.element('ccMute').onclick();
+ assert.equal(f.calls.length,0);
+ assert.match(f.element('ccAudioResult').textContent,/Connect and refresh/);
+ assert.equal(f.cards.find(c=>c.dataset.ccCard==='privacy').hidden,false);
+ f.ui.unavailable(Error('Local server is unavailable'));
+ assert.match(f.element('ccSampleState').textContent,/Local server is unavailable/);
+ f.context.online=true;f.ui.status(sample());
+ assert.equal(f.element('ccVolume').disabled,false);
+});
+test('mounted audio failure is visible and never reports success', async t => {
+ const f=mounted({api:async()=>{throw Error('Permission denied');}});t.after(()=>f.ui.destroy());f.ui.status(sample());
+ await f.element('ccApplyVolume').onclick();
+ assert.match(f.element('ccAudioResult').textContent,/Couldn’t apply audio: Permission denied/);
+ assert.equal(f.element('ccApplyVolume').disabled,false);
+ assert.equal(f.refreshes,0);
+});
+test('audio confirmation depends on fresh readback rather than command acknowledgment', async t => {
+ let expected=.65, mode='confirmed';
+ const f=mounted({refresh:async()=>{
+ if(mode==='offline')return f.ui.unavailable(Error('Headset unreachable'));
+ const reading=sample();reading.volume.level=mode==='different' ? .64 : expected;f.ui.status(reading);
+ }});t.after(()=>f.ui.destroy());f.ui.status(sample());
+ f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
+ assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
+ mode='different';f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
+ assert.match(f.element('ccAudioResult').textContent,/reported output differs: 64%/);
+ mode='offline';await f.element('ccApplyVolume').onclick();
+ assert.match(f.element('ccAudioResult').textContent,/command sent; current output could not be confirmed/);
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+});
+test('pending audio never overwrites the new headset with old success or error', async t => {
+ let complete;
+ const f=mounted({api:()=>new Promise(resolve=>{complete=resolve;})});t.after(()=>f.ui.destroy());f.ui.status(sample());
+ const pending=f.element('ccMute').onclick();
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
+ const newSample=sample();newSample.hostname='Second Frame';newSample.volume.muted=true;f.ui.status(newSample);
+ complete({message:'Old headset muted'});await pending;
+ assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
+ assert.equal(f.element('ccAudioResult').textContent,'');
+ assert.equal(f.refreshes,0);
+ assert.equal(f.element('ccMute').textContent,'Unmute');
+ f.ui.unavailable({stale:true,message:'Switched headsets'});
+ assert.equal(f.element('metric-battery').textContent,'99%');
+ assert.equal(f.element('ccMute').disabled,false);
+});
+test('pending old-headset volume cannot become a draft on the new headset', async t => {
+ for(const failure of [false,true]) {
+ const request=deferred(),f=mounted({api:()=>request.promise});t.after(()=>f.ui.destroy());
+ f.ui.status(sample());f.element('ccVolume').value='65';f.element('ccVolume').oninput();
+ const pending=f.element('ccApplyVolume').onclick();
+ f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
+ const next=sample();next.volume.level=.2;f.ui.status(next);
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ if(failure)request.reject(Error('Old headset failed'));else request.resolve({message:'Done'});
+ await pending;
+ assert.equal(f.element('ccVolume').value,20);
+ assert.equal(f.element('ccVolumeValue').textContent,'20%');
+ assert.equal(f.element('ccAudioReadback').textContent,'Reported 20% · unmuted');
+ assert.equal(f.element('ccApplyVolume').disabled,false);
+ assert.equal(f.element('ccAudioResult').textContent,'');
+ }
+});
+test('production host response works before and after mounting with the actual bridge shape', async t => {
+ for(const early of [true,false]) {
+ const f=sharedPage();let ui;
+ if(!early){ui=f.mount();t.after(()=>ui.destroy());assert.equal(f.element('macLink').hidden,true);}
+ f.host.resolve({os:'macOS',mobile:false});await flush();
+ if(early){ui=f.mount();t.after(()=>ui.destroy());}
+ assert.equal(f.context.watched,true);
+ assert.equal(f.element('macLink').hidden,false);
+ f.element('ccSearch').value='mac';f.element('ccSearch').input();
+ assert.equal(f.element('macLink').hidden,false);
+ ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
+ }
+});
+test('production shared refresh drains a pre-command request then samples after acknowledgment', async t => {
+ const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
+ const prior=f.refresh();assert.equal(f.requests.length,1);
+ assert.equal(f.refresh(),prior); // the ordinary poll/manual deduplication still holds
+ f.element('ccVolume').value='65';f.element('ccVolume').oninput();
+ const command=f.element('ccApplyVolume').onclick();await flush();
+ assert.equal(f.writes.length,1);assert.equal(f.requests.length,1);
+ f.requests[0].resolve(sample());await prior;await flush();
+ assert.equal(f.requests.length,2,'readback must issue a real post-command status request');
+ assert.doesNotMatch(f.element('ccAudioResult').textContent,/Confirmed|reported output differs/);
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ const after=sample();after.volume.level=.65;f.requests[1].resolve(after);await command;
+ assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
+ assert.equal(f.element('ccApplyVolume').disabled,false);
+});
+test('production audio readback survives an old poll failure and treats its own failed read as unconfirmed', async t => {
+ for(const failedReadback of [false,true]) {
+ const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
+ const prior=f.refresh();f.element('ccVolume').value='65';f.element('ccVolume').oninput();
+ const command=f.element('ccApplyVolume').onclick();await flush();
+ f.requests[0].reject(Error('Pre-command poll failed'));await prior;await flush();
+ assert.equal(f.requests.length,2);
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ if(failedReadback)f.requests[1].reject(Error('Readback unavailable'));
+ else {const after=sample();after.volume.level=.64;f.requests[1].resolve(after);}
+ await command;
+ assert.match(f.element('ccAudioResult').textContent,failedReadback ? /command sent; current output could not be confirmed/ : /reported output differs: 64%/);
+ assert.equal(f.element('ccApplyVolume').disabled,failedReadback);
+ }
+});
+test('production Flatpak redraw cannot revive a failed status; only a fresh successful poll recovers', async t => {
+ const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());
+ const first=f.refresh(),reading=sample();f.requests[0].resolve(reading);await first;
+ const receipt=vm.runInContext('stateReceivedAt',f.context);
+ assert.equal(f.element('ccApplyVolume').disabled,false);
+ const failed=f.refresh();f.requests[1].reject(Error('Synthetic status parsing error'));await failed;
+ assert.equal(f.element('metric-battery').textContent,'Unavailable');
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ f.installFlatpak(); // actual production render(state), with job held pending
+ assert.equal(f.requests.length,2,'redraw must not start a replacement reading');
+ assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
+ assert.equal(f.element('metric-battery').textContent,'Unavailable');
+ assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
+ assert.match(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
+ for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
+ const fresh=f.refresh();
+ assert.equal(f.element('ccApplyVolume').disabled,true,'starting a poll does not clear failure');
+ const next=sample();next.battery.percent=98;next.volume.level=.2;
+ f.requests[2].resolve(next);await fresh;
+ assert.equal(f.element('metric-battery').textContent,'98%');
+ assert.equal(f.element('ccVolume').value,20);
+ assert.equal(f.element('ccApplyVolume').disabled,false);
+ assert.doesNotMatch(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
+});
+test('delayed production bootstrap retains failed-read state and fresh-poll recovery', async t => {
+ for(const priorSuccess of [true,false]) {
+ const f=sharedPage();
+ if(priorSuccess){const first=f.refresh();f.requests[0].resolve(sample());await first;}
+ const failed=f.refresh();f.requests.at(-1).reject(Error('Status failed before mount'));await failed;
+ const calls=f.requests.length,ui=f.mount();t.after(()=>ui.destroy());
+ assert.equal(f.requests.length,calls);
+ assert.equal(f.element('metric-battery').textContent,'Unavailable');
+ assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
+ assert.match(f.element('ccSampleState').textContent,/Status failed before mount/);
+ for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
+ const fresh=f.refresh();assert.equal(f.element('ccApplyVolume').disabled,true);
+ f.requests.at(-1).resolve(sample());await fresh;
+ assert.equal(f.element('metric-battery').textContent,'99%');
+ assert.equal(f.element('ccApplyVolume').disabled,false);
+ assert.doesNotMatch(f.element('ccSampleState').textContent,/Status failed before mount/);
+ }
+});
+test('same-object replay preserves invalidation and receipt age after a failed reading', t => {
+ const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();
+ f.ui.status(reading);f.ui.unavailable(Error('Status failure'));
+ f.ui.status(reading,Date.now()+1000);
+ assert.equal(f.element('metric-battery').textContent,'Unavailable');
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ assert.match(f.element('ccSampleState').textContent,/Status failure/);
+ f.ui.status(sample());assert.equal(f.element('ccApplyVolume').disabled,false);
+});
+test('delayed production bootstrap preserves client receipt age despite headset clock skew', async t => {
+ const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
+ const f=sharedPage(),prior=f.refresh();const reading=sample();reading.time-=3600;
+ f.requests[0].resolve(reading);await prior;
+ now+=91000;const ui=f.mount();t.after(()=>ui.destroy());
+ assert.equal(f.element('metric-battery').textContent,'Unavailable');
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ assert.match(f.element('ccSampleState').textContent,/expired/);
+ const fresh=f.refresh();f.requests[1].resolve(sample());await fresh;
+ assert.equal(f.element('ccApplyVolume').disabled,false);
+ assert.equal(f.element('metric-battery').textContent,'99%');
+});
+test('sample replay cannot renew receipt age; fresh receipts tolerate missing or skewed headset time', t => {
+ const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
+ const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();f.ui.status(reading);
+ now+=91000;f.ui.status(reading);
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+ assert.equal(f.element('metric-battery').textContent,'Unavailable');
+ for(const time of [undefined,null,'invalid',NaN,Infinity,now/1000-3600,now/1000+3600]) {
+ f.ui.status({...sample(),time});
+ assert.equal(f.element('ccApplyVolume').disabled,false);
+ assert.doesNotMatch(f.element('ccSampleState').textContent,/Invalid Date/);
+ }
+ for(const receipt of [null,NaN,Infinity,now+1000,now-91000]) {
+ f.ui.status(sample(),receipt);assert.equal(f.element('ccApplyVolume').disabled,true);
+ }
+});
+test('mute-only readback preserves an unapplied volume until Apply sends it', async t => {
+ let reading=sample();const f=mounted({api:async(path,change,device)=>{
+ f.calls.push([path,change,device]);reading={...reading,volume:{...reading.volume,...change}};return {message:'Done'};
+ },refresh:async()=>f.ui.status({...reading})});t.after(()=>f.ui.destroy());f.ui.status(reading);
+ f.element('ccVolume').value='65';f.element('ccVolume').oninput();
+ await f.element('ccMute').onclick();
+ assert.deepEqual(f.calls[0],['/api/volume',{muted:true},'frame-a']);
+ assert.equal(f.element('ccVolume').value,'65');assert.equal(f.element('ccVolumeValue').textContent,'65%');
+ assert.match(f.element('ccAudioReadback').textContent,/Reported 40% · muted/);
+ assert.match(f.element('ccAudioResult').textContent,/Confirmed: 40%, muted/);
+ await f.element('ccApplyVolume').onclick();
+ assert.deepEqual(f.calls[1],['/api/volume',{level:.65},'frame-a']);
+ assert.equal(f.element('ccVolume').value,65);
+ assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, muted/);
+});
+test('live samples expire even when the status poll is paused', t => {
+ const original=Date.now;let now=100000;Date.now=()=>now;t.after(()=>{Date.now=original;});
+ const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
+ now+=91000;
+ // A connection update must also re-check freshness before enabling a control.
+ f.ui.connection(true);
+ assert.equal(f.element('ccApplyVolume').disabled,true);
+});
+test('mounted filters show empty state, counts, reset and host-dependent features', t => {
+ const f=mounted();t.after(()=>f.ui.destroy());
+ f.element('ccSearch').value='dpi';f.element('ccSearch').input();
+ assert.equal(f.cards.find(c=>c.dataset.ccCard==='display').hidden,false);
+ assert.equal(f.element('ccResultCount').textContent,'1 section');
+ f.element('ccSearch').value='nothing-matches-this';f.element('ccSearch').input();
+ assert.equal(f.element('ccEmpty').hidden,false);
+ f.element('ccResetFilter').onclick();assert.equal(f.element('ccEmpty').hidden,true);
+ f.ui.host({os:'Windows'});assert.equal(f.element('macLink').hidden,true);
+ assert.equal(f.cards.find(c=>c.dataset.ccCard==='mac').hidden,false);
+ f.ui.host({os:'macOS'});assert.equal(f.element('macLink').hidden,false);
+ f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
+ f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
+});
+test('every linked workflow selects its real page and section, preserving shortcuts', () => {
+ const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
+ const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
+ const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
+ let activePage,scrolled;
+ const nodes=['home','games','android','tools','devices','control','settings'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
+ const sections=new Map();
+ const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
+ window:{scrollTo(){}},$:id=>{
+ if(!ids.has(id))return null;
+ if(!sections.has(id))sections.set(id,{hidden:false,classList:{toggle(){}},scrollIntoView(){scrolled=id;}});
+ return sections.get(id);
+ },live:false,toggleLive(){},scrollToY(){},loadMacView(){},loadPanelSwitcher(){}};
+ const androidStart=html.indexOf('function showAndroidTab(');
+ const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
+ vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\n'+html.match(/^PAGES\.push\([^\n]+/m)[0],context);
+ vm.runInContext('showPage()',context);assert.equal(activePage,'control');
+ const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
+ power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
+ for(const item of cc.catalog) for(const [href] of item.links||[]) {
+ if(!href.startsWith('#')||href==='#connection')continue;
+ const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
+ context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
+ assert.equal(activePage,expected[id],href);
+ if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
+ if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
+ if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
+ }
+ assert.equal(vm.runInContext('PAGES[4]',context),'devices');
+ assert.equal(vm.runInContext('PAGES[5]',context),'control');
+ assert.equal(vm.runInContext('PAGES[6]',context),'settings');
+});
diff --git a/tests/e2e/harness.py b/tests/e2e/harness.py
index 1328f96..af7a295 100644
--- a/tests/e2e/harness.py
+++ b/tests/e2e/harness.py
@@ -10,6 +10,7 @@ import atexit
import http.client
import json
import os
+import shutil
import socket
import subprocess
import sys
@@ -22,7 +23,22 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
-ENABLED = os.environ.get('FRAME_E2E') == '1'
+# Only inside the compose `host` container (FRAME_E2E_CONTAINER from compose.yaml, plus
+# the container runtime's marker file): these tests drive ui/server.py with the real
+# home, ~/.ssh and cache, which is only safe in a throwaway container.
+REQUESTED = os.environ.get('FRAME_E2E') == '1'
+IN_CONTAINER = os.environ.get('FRAME_E2E_CONTAINER') == '1' and (
+ os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv'))
+ENABLED = REQUESTED and IN_CONTAINER
+if ENABLED:
+ # What tests/sandbox.py does, minus moving HOME: the container's real ~/.ssh is what
+ # points `ssh frame` at the fake Frame, and OpenSSH reads ~/.ssh from the password
+ # database, not $HOME. (These tests used to get this through test_frame_apk.)
+ _data = tempfile.mkdtemp(prefix='frame-control-e2e-')
+ atexit.register(shutil.rmtree, _data, ignore_errors=True)
+ os.environ['FRAME_CONTROL_DATA_DIR'] = _data
+ os.environ['FRAME_CONTROL_TELEMETRY'] = '0'
+ os.environ['FRAME_COMPAT_DB_URL'] = 'http://127.0.0.1:9'
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
HOME = '/home/steamos'
@@ -30,9 +46,14 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
def require():
- """Call at module level: skips the module unless the fake Frame is up."""
- if not ENABLED:
- raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
+ """Skips (the module, at module level; the class, in setUpClass) unless this is
+ scripts/e2e.sh's container with the fake Frame up."""
+ if ENABLED:
+ return
+ if REQUESTED:
+ raise unittest.SkipTest('FRAME_E2E=1 outside the e2e container: these tests use the real home, ~/.ssh '
+ 'and app cache, so they only run in scripts/e2e.sh\'s throwaway container')
+ raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
# ---- the fake Frame's control port --------------------------------------------
@@ -97,6 +118,7 @@ class Server:
@classmethod
def start(cls):
+ require() # never a server on this computer's real data
if cls.proc and cls.proc.poll() is None:
return
with socket.socket() as s:
@@ -193,6 +215,7 @@ class FrameTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
+ require()
Server.start()
def setUp(self):
diff --git a/tests/e2e/test_agents.py b/tests/e2e/test_agents.py
index f02f338..10aa218 100644
--- a/tests/e2e/test_agents.py
+++ b/tests/e2e/test_agents.py
@@ -6,6 +6,8 @@ import sys
import harness
from harness import api, ok, finished, ssh
+harness.require()
+
sys.path.insert(0, str(harness.ROOT / 'ui'))
import frame_mcp
diff --git a/tests/e2e/test_android.py b/tests/e2e/test_android.py
index 2b40489..d2f0cdd 100644
--- a/tests/e2e/test_android.py
+++ b/tests/e2e/test_android.py
@@ -5,7 +5,7 @@ import unittest
import harness
from harness import HOME, exists, ok, state, upload, wait_for
-from test_frame_apk import apk, manifest, resources
+from apk_fixtures import apk, manifest, resources
harness.require()
diff --git a/tests/fakeadb.py b/tests/fakeadb.py
new file mode 100644
index 0000000..395fbf1
--- /dev/null
+++ b/tests/fakeadb.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""A stand-in for Android's adb, for tests/test_quest.py: just enough of `devices -l`,
+`shell`, and `pull` for frame_quest.py, run as a real subprocess.
+
+$FAKEADB_STATE is a JSON file:
+ devices [{"serial", "state", "extra" (the rest of the `devices -l` line), "props" {getprop name: value}}]
+ root a folder standing in for each device's file system (remote /x/y is root/x/y)
+ packages {package: [APK paths, as `pm path` prints them]}
+ fail_pull a remote path whose pull fails
+Every call is appended to $FAKEADB_LOG as a JSON line. Shell commands are the simple
+`; `-separated ones frame_quest sends, each answered here: no real shell runs.
+"""
+import json
+import os
+import shutil
+import sys
+
+args = sys.argv[1:]
+with open(os.environ['FAKEADB_LOG'], 'a') as f:
+ f.write(json.dumps(args) + '\n')
+with open(os.environ['FAKEADB_STATE']) as f:
+ state = json.load(f)
+root = state.get('root', '')
+serial = None
+if args[:1] == ['-s']:
+ serial, args = args[1], args[2:]
+
+
+def fail(message, code=1):
+ sys.stderr.write(message + '\n')
+ sys.exit(code)
+
+
+def local(remote):
+ return os.path.join(root, remote.lstrip('/'))
+
+
+def size_kb(path):
+ total = 0
+ for dirpath, _, files in os.walk(path):
+ total += sum(os.path.getsize(os.path.join(dirpath, n)) for n in files)
+ return max(4, total // 1024)
+
+
+if args == ['devices', '-l']:
+ print('List of devices attached')
+ for d in state.get('devices', []):
+ print(f"{d['serial']:<22} {d['state']} {d.get('extra', '')}".rstrip())
+ sys.exit(0)
+
+device = next((d for d in state.get('devices', []) if d['serial'] == serial), None)
+if serial is None or device is None:
+ fail(f"adb: device '{serial}' not found")
+if device['state'] == 'unauthorized':
+ fail('adb: device unauthorized.\nThis adb server\'s $ADB_VENDOR_KEYS is not set')
+if device['state'] != 'device':
+ fail(f"adb: device {device['state']}")
+
+if args[:1] == ['shell']:
+ out = []
+ for command in ' '.join(args[1:]).split('; '):
+ command = command.replace(' 2>/dev/null', '').strip()
+ words = command.split()
+ if not words or command == 'true':
+ continue
+ if words[0] == 'getprop':
+ out.append(device.get('props', {}).get(words[1], ''))
+ elif words[0] == 'echo':
+ out.append(' '.join(words[1:]))
+ elif command == 'pm list packages -3':
+ out += ['package:' + p for p in state.get('packages', {})]
+ elif words[:2] == ['pm', 'path']:
+ out += ['package:' + p for p in state.get('packages', {}).get(words[2], [])]
+ elif words[:2] == ['du', '-sk']:
+ if os.path.isdir(local(words[2])):
+ out.append(f'{size_kb(local(words[2]))}\t{words[2]}')
+ elif words[:3] == ['stat', '-c', '%s']:
+ out += [str(os.path.getsize(local(p.strip("'")))) for p in words[3:]]
+ else:
+ fail(f'/system/bin/sh: {words[0]}: inaccessible or not found', 127)
+ print('\n'.join(out))
+ sys.exit(0)
+
+if args[:1] == ['pull'] and len(args) == 3:
+ remote, dest = args[1], args[2]
+ if remote == state.get('fail_pull'):
+ fail(f"adb: error: failed to copy '{remote}' to '{dest}': remote Connection reset")
+ src = local(remote)
+ if not os.path.exists(src):
+ fail(f"adb: error: failed to stat remote object '{remote}': No such file or directory")
+ if os.path.isdir(dest):
+ dest = os.path.join(dest, os.path.basename(remote.rstrip('/')))
+ if os.path.isdir(src):
+ shutil.copytree(src, dest)
+ else:
+ shutil.copyfile(src, dest)
+ print(f'{remote}: 1 file pulled.')
+ sys.exit(0)
+
+fail('adb: unknown command ' + ' '.join(args))
diff --git a/tests/fakeframe/compose.yaml b/tests/fakeframe/compose.yaml
index 089321c..d0e492b 100644
--- a/tests/fakeframe/compose.yaml
+++ b/tests/fakeframe/compose.yaml
@@ -43,6 +43,7 @@ services:
FAKEFRAME_CTL: http://fakeframe:9999
FAKEFRAME_HOST: fakeframe
FRAME_E2E: "1"
+ FRAME_E2E_CONTAINER: "1" # tests/e2e/harness.py refuses to run anywhere else
healthcheck:
test: ["CMD", "test", "-f", "/home/tester/.ready"]
interval: 1s
diff --git a/tests/fakegh/gh b/tests/fakegh/gh
new file mode 100755
index 0000000..ec76aff
--- /dev/null
+++ b/tests/fakegh/gh
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and
+tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list,
+drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is
+written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line.
+Anything else fails, so the script under test can't fall back to `gh release ...`
+(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release
+$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
+lagging a fresh draft. With jq installed, --jq filters a response the way gh does
+(raw strings, compact JSON)."""
+import json
+import os
+import shutil
+import subprocess
+import sys
+
+args = sys.argv[1:]
+with open(os.environ["FAKEGH_LOG"], "a") as f:
+ f.write(json.dumps(args) + "\n")
+if not args or args[0] != "api":
+ sys.exit("fakegh: only `gh api` is supported: %r" % args)
+
+method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1
+while i < len(args):
+ a = args[i]
+ if a == "-X":
+ method = args[i + 1]; i += 2
+ elif a in ("-f", "-F"):
+ k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2
+ elif a == "--input":
+ inp = args[i + 1]; i += 2
+ elif a == "--jq":
+ jq = args[i + 1]; i += 2
+ elif a == "-H":
+ i += 2
+ elif a.startswith("-"):
+ i += 1
+ elif endpoint is None:
+ endpoint = a; i += 1
+ else:
+ sys.exit("fakegh: unexpected argument %r" % a)
+
+releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
+repo = "repos/saphid/frame-control/"
+
+
+def answer(response, fallback):
+ """Print response through --jq when jq is here, else the fixed fallback text."""
+ if jq and shutil.which("jq"):
+ out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response),
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
+ sys.stdout.write(out.stdout)
+ if out.returncode:
+ sys.exit("fakegh: jq failed: " + out.stderr)
+ else:
+ print(fallback)
+
+
+if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
+ tag = endpoint.rsplit("/", 1)[1]
+ if tag not in os.environ.get("FAKEGH_TAGS", "").split():
+ sys.exit("gh: Not Found (HTTP 404)")
+ print(json.dumps({"ref": "refs/tags/" + tag}))
+elif method == "GET" and endpoint.startswith(repo + "releases?"):
+ # The fallback is what --jq '.[]' prints.
+ answer(releases, "\n".join(json.dumps(r) for r in releases))
+elif method == "POST" and endpoint == repo + "releases":
+ new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"),
+ "name": fields.get("name"), "draft": fields.get("draft") == "true",
+ "body": fields.get("body"), "assets": [],
+ "html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"}
+ answer(new, json.dumps(new))
+elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
+ pass
+elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
+ with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
+ dst.write(src.read())
+ print("{}")
+elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
+ rid = endpoint[len(repo + "releases/"):]
+ if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
+ sys.exit("gh: Not Found (HTTP 404)")
+ page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
+ answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
+ "published %s at %s" % (fields.get("tag_name"), page))
+else:
+ sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
diff --git a/tests/fakessh/ssh b/tests/fakessh/ssh
index 45b2b17..c197e1f 100755
--- a/tests/fakessh/ssh
+++ b/tests/fakessh/ssh
@@ -3,10 +3,20 @@
each step of a connection, and plays a ControlMaster. What each HostName does comes
from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key), "denied" or
"slow" (hangs after connecting);
-every call is appended to $FAKESSH_LOG as a JSON line. POSIX only."""
+every call is appended to $FAKESSH_LOG as a JSON line. POSIX only.
+
+With $FAKESSH_PINS set, host keys are checked as OpenSSH does against UserKnownHostsFile under
+HostKeyAlias: "wrong" is then just a different key from the others' (a reinstalled headset),
+StrictHostKeyChecking=yes refuses a key not saved, accept-new saves a first one, and a saved key
+that differs is refused. "evil" is a device with its own key that lets anyone in without
+authentication ("none") and asks for the agent: it records in $FAKESSH_DIR what it got
+(agent-forwarded, command-ran). Like ssh, the first value of an option counts."""
+import base64
+import hashlib
import json
import os
import signal
+import subprocess
import sys
import time
@@ -17,12 +27,12 @@ hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
opts = {}
i = 0
while i < len(args) and args[i].startswith("-"):
- if args[i] in ("-o", "-O", "-p", "-l"):
+ if args[i] in ("-o", "-O", "-p", "-l", "-F", "-W"):
key = args[i]
val = args[i + 1]
if key == "-o":
k, _, v = val.partition("=")
- opts[k.lower()] = v
+ opts.setdefault(k.lower(), v)
else:
opts[key] = val
i += 2
@@ -30,6 +40,12 @@ while i < len(args) and args[i].startswith("-"):
opts[args[i]] = True
i += 1
alias = args[i] if i < len(args) else ""
+command = args[i + 1:]
+if opts.get("-F") != "none":
+ # $FAKESSH_CONFIG: what ~/.ssh/config says (JSON, lower-case option -> value), below the
+ # command line's options, as ssh has it; `-F none` reads no config.
+ for k, v in json.loads(os.environ.get("FAKESSH_CONFIG", "{}")).items():
+ opts.setdefault(k, v)
host = opts.get("hostname", alias).replace("%%", "%")
marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
@@ -44,6 +60,23 @@ if opts.get("-O") == "exit":
os.unlink(marker)
sys.exit(0)
+if "-W" in opts: # a jump host's ssh, forwarding to the headset: the call is logged, that's all
+ sys.exit(0)
+if opts.get("proxycommand", "none") != "none":
+ # As OpenSSH: expand %-tokens, then `$SHELL -c "exec "` (here /bin/sh).
+ line, out, n = opts["proxycommand"], "", 0
+ while n < len(line):
+ if line[n] == "%" and n + 1 < len(line):
+ out += {"%": "%", "h": host, "p": opts.get("port", "22"), "r": opts.get("user", "")}.get(
+ line[n + 1], line[n:n + 2])
+ n += 2
+ else:
+ out += line[n]
+ n += 1
+ if subprocess.run(["/bin/sh", "-c", "exec " + out], stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL).returncode:
+ say("kex_exchange_identification: Connection closed by remote host")
+ sys.exit(255)
what = hosts.get(host)
if what is None:
say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
@@ -53,6 +86,66 @@ say("debug1: Connection established.")
if what == "slow":
time.sleep(30)
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
+if os.environ.get("FAKESSH_PINS") and "hostkeyalias" in opts and "userknownhostsfile" in opts:
+ name, path = opts["hostkeyalias"], os.path.expanduser(opts["userknownhostsfile"])
+ blob = base64.b64encode(b"fake-" + {"wrong": b"other", "evil": b"evil"}.get(what, b"headset") + b"-key").decode()
+ fp = base64.b64encode(hashlib.sha256(base64.b64decode(blob)).digest()).decode().rstrip("=")
+ say(f"debug1: Server host key: ssh-ed25519 SHA256:{fp}")
+ try:
+ saved = [ln.split() for ln in open(path, errors="replace").read().splitlines() if ln.strip()]
+ except OSError:
+ saved = []
+ saved = [f[2] for f in saved if len(f) >= 3 and name in f[0].split(",")]
+ strict = opts.get("stricthostkeychecking", "ask")
+ if saved and blob not in saved:
+ say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
+ say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
+ say("Host key verification failed.")
+ sys.exit(255)
+ if not saved and strict != "accept-new":
+ say(f"No ED25519 host key is known for {name} and you have requested strict checking.")
+ say("Host key verification failed.")
+ sys.exit(255)
+ if not saved:
+ try:
+ with open(path, "a") as f:
+ f.write(f"{name} ssh-ed25519 {blob}\n")
+ say(f"Warning: Permanently added '{name}' (ED25519) to the list of known hosts.")
+ except OSError: # ssh doesn't create the folder: it goes on, saving nothing
+ say(f"Failed to add the host to the list of known hosts ({path}).")
+ else:
+ say(f"debug1: Host '{name}' is known and matches the ED25519 host key.")
+ say("debug1: Next authentication method: publickey")
+ if what == "evil":
+ say(f'Authenticated to {host} ([127.0.0.1]:22) using "none".')
+ got = os.path.join(os.environ["FAKESSH_DIR"], "evil-")
+ if opts.get("forwardagent", "no") != "no" and opts.get("identityagent", "") != "none":
+ open(got + "agent-forwarded", "w").close()
+ if "-N" not in opts and (command or opts.get("remotecommand", "none") != "none"):
+ open(got + "command-ran", "w").close()
+ sys.exit(0)
+ time.sleep(30) # -N: holds the session open
+ sys.exit(0)
+ if opts.get("preferredauthentications") == "none":
+ say(f"tester@{host}: Permission denied (publickey,password).")
+ sys.exit(255)
+ if what == "denied":
+ say(f"tester@{host}: Permission denied (publickey).")
+ sys.exit(255)
+ what = "ok"
+ say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
+ if opts.get("controlmaster") != "yes":
+ sys.exit(0)
+ open(marker, "w").close()
+ def bye(*_):
+ if os.path.exists(marker):
+ os.unlink(marker)
+ sys.exit(0)
+ signal.signal(signal.SIGTERM, bye)
+ while True:
+ time.sleep(0.2)
+ if not os.path.exists(marker):
+ sys.exit(0)
say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
if what == "wrong":
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
diff --git a/tests/page/desk_panels.mjs b/tests/page/desk_panels.mjs
new file mode 100644
index 0000000..5ddf353
--- /dev/null
+++ b/tests/page/desk_panels.mjs
@@ -0,0 +1,82 @@
+// The live view's Desktop panel list (#73), run in node against the real functions from
+// ui/index.html with a fake server: the headset's reason when it lists no panels, a failed
+// listing shown (first time too) rather than "Finding…", and recovery after it.
+import { readFileSync } from "fs";
+const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
+const grab = name => {
+ const m = src.match(new RegExp(`(?:\\nconst ${name} = [^\\n]*\\n)|((?:async )?function ${name}\\([\\s\\S]*?\\n}\\n)`));
+ if (!m) throw new Error("not found: " + name);
+ return m[0];
+};
+// The page's scripts share one global scope, so a second loadPanels would silently replace
+// the Desktop view's (what broke #73 until #84): the one grabbed here must be the only one.
+for (const name of ["loadPanels", "noPanelWhy", "deskPanel", "ctrlTarget"]) {
+ const count = (src.match(new RegExp(`\\n(?:async )?function ${name}\\(`, "g")) || []).length;
+ if (count !== 1) { console.log(`FAIL ${name} is defined ${count} times`); process.exit(1); }
+}
+const code = ["desk", "panelKey", "noPanelWhy", "deskPanel", "loadPanels", "ctrlTarget"].map(grab).join("");
+const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
+
+function page(answer) {
+ const calls = [], logged = [];
+ const env = {
+ view: "flat", live: false, ctrlMode: () => "abs", renderPanelPick: () => {}, toggleLive: () => {}, ctrlShow: () => {},
+ log: (m) => logged.push(m),
+ api: async (path, body) => { calls.push([path, body]); return answer(); },
+ };
+ const fns = new Function(...Object.keys(env), `${code}
+ return { desk, noPanelWhy, loadPanels, ctrlTarget };`)(...Object.values(env));
+ return { ...fns, calls, logged };
+}
+
+// The Desktop view asks for the headset's panels with a plain GET (no switcher action).
+{
+ const p = page(() => ({ panels: [], focus: null, why: "The headset's desktop is running, but SteamOS isn't listing it as a panel." }));
+ if (p.ctrlTarget().why !== "Finding the headset's panels…") fail("before loading: " + p.ctrlTarget().why);
+ await p.loadPanels();
+ if (!(p.calls.length === 1 && p.calls[0][0] === "/api/panels" && p.calls[0][1] === undefined))
+ fail("request " + JSON.stringify(p.calls));
+ if (!p.desk.loaded) fail("not marked loaded");
+ if (!p.noPanelWhy().startsWith("The headset's desktop is running")) fail("why for capture: " + p.noPanelWhy());
+ if (!p.ctrlTarget().why.startsWith("The headset's desktop is running")) fail("why for control: " + p.ctrlTarget().why);
+}
+// An older headset script without "why": the generic advice.
+{
+ const p = page(() => ({ panels: [], focus: null }));
+ await p.loadPanels();
+ if (!p.noPanelWhy().startsWith("No app panel is open in the headset. Open the desktop")) fail("generic: " + p.noPanelWhy());
+}
+// The first listing fails: Control and Capture say so (logged once), then recover.
+{
+ let broken = true;
+ const p = page(() => {
+ if (broken) throw new Error("Timed out talking to frame");
+ return { panels: [{ window: 7, display: ":0", name: "Desktop" }], focus: 7, focus_display: ":0" };
+ });
+ await p.loadPanels(); await p.loadPanels();
+ const why = p.ctrlTarget().why;
+ if (why !== "Couldn't list the headset's panels: Timed out talking to frame") fail("initial failure in Control: " + why);
+ if (p.noPanelWhy() !== why) fail("initial failure in Capture: " + p.noPanelWhy());
+ if (p.logged.length !== 1) fail("logged " + p.logged.length + " times");
+ broken = false;
+ await p.loadPanels();
+ if (p.desk.error) fail("error kept after recovery");
+ const t = p.ctrlTarget();
+ if (!t.why || t.why.includes("Couldn't list")) fail("after recovery: " + JSON.stringify(t));
+ if (p.desk.panels.length !== 1) fail("panels after recovery");
+}
+// Waiting for the Frame: shown in Control, not logged every poll. An answer about the
+// previous headset (stale) is ignored.
+{
+ let err;
+ const p = page(() => { throw err; });
+ err = Object.assign(new Error("Can't reach the Frame"), { offline: true });
+ await p.loadPanels(); await p.loadPanels();
+ if (p.logged.length) fail("offline logged: " + p.logged);
+ if (p.ctrlTarget().why !== "Couldn't list the headset's panels: Can't reach the Frame") fail("offline: " + p.ctrlTarget().why);
+ const q = page(() => { throw Object.assign(new Error("old headset"), { offline: true, stale: true }); });
+ await q.loadPanels();
+ if (q.logged.length || q.desk.error) fail("stale answer used: " + q.desk.error);
+ if (q.ctrlTarget().why !== "Finding the headset's panels…") fail("stale: " + q.ctrlTarget().why);
+}
+console.log("ok");
diff --git a/tests/page/meta_rows.mjs b/tests/page/meta_rows.mjs
new file mode 100644
index 0000000..b83b43e
--- /dev/null
+++ b/tests/page/meta_rows.mjs
@@ -0,0 +1,43 @@
+// The Meta library's rows, run in node against the real functions from ui/index.html.
+// Found in a live test with a real account (2026-10-14): sizes were cut off behind the
+// package name, a required data file Frame Control can't place was not mentioned, and
+// apps and environments were counted as "games".
+import { readFileSync } from "fs";
+const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
+const lines = src.split("\n");
+const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
+
+function grab(name) {
+ const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
+ if (start < 0) fail(`no top-level ${name} in index.html`);
+ let depth = 0;
+ for (let i = start; i < lines.length; i++) {
+ for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
+ if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
+ }
+ fail(`unbalanced ${name}`);
+}
+const els = { metaCount: { textContent: "" }, metaApps: { innerHTML: "" } };
+const metaLib = { connected: true, results: new Map(), apps: [
+ { id: "1", title: "The Climb", package: "com.crytek.climb", version: "666.0", version_code: 666, size: 30639455,
+ data_files: 0, skipped_files: ["main.com.crytek.climb.obb"] },
+ { id: "2", title: "Beat Saber", package: "com.beatgames.beatsaber", problem: "Meta's API said HTTP 500", skipped_files: [] },
+] };
+const render = new Function("$", "metaLib", "androidApps", "installing", "queued",
+ grab("esc") + grab("questSize") + "const META_VERDICT = {};\n" + grab("renderMetaApps") + "renderMetaApps();");
+render(id => els[id], metaLib, [], new Set(["meta:1"]), new Set(["meta:1"]));
+const html = els.metaApps.innerHTML;
+if (els.metaCount.textContent !== "2 apps") fail("count: " + els.metaCount.textContent);
+const climb = html.split('class="item"')[1];
+if (!/
29 MB · v666\.0 \(666\) · com\.crytek\.climb]*>Meta's API said HTTP 500Queued<\/span>/.test(climb) || !/data-meta-install="1" disabled/.test(climb)) fail("queued not shown: " + climb);
+if (/Queued|disabled/.test(saber)) fail("queued on the wrong row: " + saber);
+metaLib.apps = metaLib.apps.slice(0, 1);
+render(id => els[id], metaLib, [], new Set(), new Set());
+if (els.metaCount.textContent !== "1 app") fail("singular count: " + els.metaCount.textContent);
+console.log("ok");
diff --git a/tests/page/store_detail.mjs b/tests/page/store_detail.mjs
new file mode 100644
index 0000000..bf740b3
--- /dev/null
+++ b/tests/page/store_detail.mjs
@@ -0,0 +1,109 @@
+// The Android store's app page, run in node against the real functions from ui/index.html.
+// Issue #68: clicking an app opened an empty dialog (a bare horizontal line), because the
+// Devices page's renderDetail replaced the store's. The store's is renderAppDetail now.
+// (tests/test_server.py PageScripts checks no top-level name is declared twice.)
+import { readFileSync } from "fs";
+const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
+const lines = src.split("\n");
+const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
+
+// A top-level declaration, from its first line to where its braces balance.
+function grab(name) {
+ const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
+ if (start < 0) fail(`no top-level ${name} in index.html`);
+ let depth = 0;
+ for (let i = start; i < lines.length; i++) {
+ for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
+ if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
+ }
+ fail(`unbalanced ${name}`);
+}
+const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
+ "GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
+ "updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
+const code = names.map(grab).join("");
+
+// A fake page: elements by id. The install panel's elements exist only while
+// #detailBody holds the real app page, as in the browser.
+function page(api = async () => ({})) {
+ const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
+ const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
+ const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
+ : (els[id] ||= { innerHTML: "", textContent: "", hidden: false, value: 0, removeAttribute() {}, querySelectorAll: () => [] });
+ const sourceState = { apps: [], detail: null, detailRequest: 0, jobs: new Map() };
+ const document = { activeElement: null, querySelector: () => null };
+ const console = { error() {} };
+ const calls = [];
+ const fns = new Function("$", "sourceState", "document", "console", "api", "runJob", "loadAndroid", "act", "window", code + "; return { renderAppDetail, openStoreApp };")(
+ $, sourceState, document, console, (...a) => { calls.push(a); return api(...a); },
+ async (title, key, work) => work(), () => {}, async (title, work) => work(),
+ { open() { fail("an in-app installation opened an external page"); } });
+ return { els, sourceState, calls, ...fns };
+}
+
+const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid", name: "Open Brush", summary: "Paint in VR",
+ description: "Paint in 3D.", version: "2.1", version_code: 21, package: "org.example.brush",
+ artwork: { icon: "/source-image/abc", screenshots: [] }, downloadable: true, verdict: { tone: "works", label: "Works" } };
+
+// An app from the catalogue: its page renders, with its name, install and sources.
+{
+ const p = page();
+ p.sourceState.detail = { app: { name: "Open Brush", offers: [{ ...offer }] }, choice: 0 };
+ if (p.renderAppDetail() !== true) fail("a good listing reported a render failure");
+ const html = p.els.detailBody.innerHTML;
+ if (!html.includes('id="detailTitle">Open Brush<')) fail("app page has no title: " + html.slice(0, 200));
+ if (!html.includes('id="detailInstall"') || !html.includes("Get it from")) fail("app page has no install panel");
+ if (html.includes('role="alert"')) fail("a good listing showed the error state");
+}
+
+// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
+{
+ const p = page();
+ p.sourceState.detail = { app: { name: "Broken ", offers: [] }, choice: 0 };
+ if (p.renderAppDetail() !== false) fail("a broken listing reported success");
+ const html = p.els.detailBody.innerHTML;
+ if (!html.includes('role="alert"') || !html.includes("We couldn't show Broken <App>")) fail("no error state: " + html);
+ if (!html.includes('data-close="appDetail"')) fail("error state has no way to close");
+}
+
+// Opening a listing that can't render: the error stays up, no details request, nothing thrown.
+{
+ const p = page(async () => { throw new Error("offline"); });
+ p.sourceState.apps = [{ name: "Broken", offers: [] }];
+ await p.openStoreApp(0, true);
+ if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error");
+ if (p.calls.length) fail("asked for details of a listing that couldn't render");
+}
+
+// Opening a good listing: the dialog opens with its page, and fresher details fill it in.
+{
+ const p = page(async () => ({ description: "Fresh description." }));
+ p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
+ await p.openStoreApp(0);
+ if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes("Fresh description.")) fail("details didn't fill in the page");
+ if (!String(p.calls[0]?.[0]).startsWith("/api/sources/details?")) fail("no details request: " + JSON.stringify(p.calls));
+}
+
+// A good listing whose details request fails keeps its page, with a note.
+{
+ const p = page(async () => { throw new Error("offline"); });
+ p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
+ await p.openStoreApp(0);
+ if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request");
+ if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request");
+}
+// The featured Install starts a new or failed job, while a job already running or
+// installed opens its details without another install or a launch command.
+for (const [job, expected] of [[null, 1], [{ done: false }, 0],
+ [{ done: true, result: { package: offer.package } }, 0], [{ done: true, error: "offline" }, 1]]) {
+ const p = page(async path => path === "/api/sources/install" ? { package: offer.package } : {});
+ p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
+ if (job) p.sourceState.jobs.set(`${offer.source}:${offer.id}`, job);
+ await p.openStoreApp(0, true);
+ if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('id="detailInstall"')) fail("hero installation didn't open details");
+ const installs = p.calls.filter(([path]) => path === "/api/sources/install");
+ if (installs.length !== expected) fail(`expected ${expected} installation requests, got ${installs.length}`);
+ if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
+ if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
+}
+console.log("ok");
diff --git a/tests/sandbox.py b/tests/sandbox.py
index 5d2a3f0..dd30577 100644
--- a/tests/sandbox.py
+++ b/tests/sandbox.py
@@ -1,12 +1,20 @@
"""Imported first by every test module: nothing a test does reaches this person's
-app data, their telemetry, or the shared compatibility database.
+app data, their ~/.ssh, their telemetry, or the shared compatibility database.
Must run before any ui module is imported, since those read these at import time.
+The variables are set in os.environ, so servers and scripts the tests start
+inherit them too (they pass {**os.environ, ...}).
"""
import atexit
import os
import shutil
import tempfile
+from pathlib import Path
+
+# The environment and real ~/.ssh from before the changes below: test_sandbox
+# reruns tests from that environment and checks ~/.ssh is left alone.
+ORIGINAL_ENV = dict(os.environ)
+REAL_SSH_DIR = Path.home() / ".ssh"
_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
atexit.register(shutil.rmtree, _dir, ignore_errors=True)
@@ -14,3 +22,25 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
# A maintainer's machine holds the database key; send anything that slips through nowhere.
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
+
+# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
+# its lock file) and the other per-user folders some modules use. A server started
+# without FRAME_CONTROL_SSH_DIR used to import the real ~/.ssh/config and seed a pin
+# into the real ~/.ssh/frame-control-hosts on every run.
+#
+# tests/e2e must not import this (directly or through a test_* module): they run in
+# a throwaway container whose real ~/.ssh points `ssh frame` at the fake Frame, and
+# OpenSSH finds ~/.ssh from the password database, not $HOME. Shared helpers they
+# need live in sandbox-free modules such as apk_fixtures.py.
+HOME = os.path.join(_dir, "home")
+SSH_DIR = os.path.join(HOME, ".ssh")
+os.makedirs(SSH_DIR, mode=0o700)
+os.environ["HOME"] = HOME
+os.environ["USERPROFILE"] = HOME # Path.home() on Windows
+os.environ["FRAME_CONTROL_SSH_DIR"] = SSH_DIR # frame_devices.ssh_dir(), frame_connect.SSH_DIR
+# The per-user folders that don't follow HOME when these are set: frame_host.cache_dir()
+# (servers warm indexes there and sweep its staging folders) and data_dir()'s fallbacks.
+for _name, _sub in (("LOCALAPPDATA", "AppData/Local"), ("APPDATA", "AppData/Roaming"),
+ ("XDG_CACHE_HOME", ".cache"), ("XDG_DATA_HOME", ".local/share"),
+ ("XDG_STATE_HOME", ".local/state"), ("XDG_CONFIG_HOME", ".config")):
+ os.environ[_name] = os.path.join(HOME, *_sub.split("/"))
diff --git a/tests/test_agent.py b/tests/test_agent.py
index 931eccb..83d0b2c 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -1,4 +1,5 @@
"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
diff --git a/tests/test_apk_artwork.py b/tests/test_apk_artwork.py
index b036f90..e863afe 100644
--- a/tests/test_apk_artwork.py
+++ b/tests/test_apk_artwork.py
@@ -1,3 +1,4 @@
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
diff --git a/tests/test_apk_more_sources.py b/tests/test_apk_more_sources.py
index f1879b9..f1f3cf7 100644
--- a/tests/test_apk_more_sources.py
+++ b/tests/test_apk_more_sources.py
@@ -1,3 +1,4 @@
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile
from pathlib import Path
from unittest.mock import patch
diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py
index 20cd57e..6b4e3ed 100644
--- a/tests/test_apk_search.py
+++ b/tests/test_apk_search.py
@@ -1,3 +1,4 @@
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
diff --git a/tests/test_comfort.py b/tests/test_comfort.py
index 0b3af36..d2e0e93 100644
--- a/tests/test_comfort.py
+++ b/tests/test_comfort.py
@@ -1,4 +1,5 @@
"""Fake-Frame session clock/actions plus real helper serialization and sensor probes."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import shutil
import json
@@ -255,3 +256,47 @@ class SensorTests(unittest.TestCase):
self.assertIsNone(status.activity_level())
with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
self.assertEqual(status.activity_level(), 3)
+
+
+class DesktopBundleTests(unittest.TestCase):
+ """server.comfort runs the copy cached on the headset; the sources go only when it's missing."""
+
+ @unittest.skipUnless(os.name == "posix", "checks POSIX permissions")
+ def test_sources_are_sent_once_per_version(self):
+ import server
+ with tempfile.TemporaryDirectory() as tmp:
+ here, home = Path(tmp) / 'ui', Path(tmp) / 'frame'
+ here.mkdir()
+ home.mkdir()
+ for name in server.COMFORT_FILES:
+ (here / name).write_text('# ' + name + '\n')
+ (here / 'frame_comfort.py').write_text('import json, sys\nprint(json.dumps({"active": False, "got": sys.argv[1]}))\n')
+ sent = []
+
+ def ssh(remote, *, stdin, timeout): # the headset: the script run under its own home
+ sent.append(stdin)
+ return subprocess.run([sys.executable, '-'], input=stdin, capture_output=True, text=True, check=True,
+ env={**os.environ, 'HOME': str(home)}).stdout
+
+ with patch.object(server, 'HERE', here), patch.object(server, 'ssh', side_effect=ssh):
+ first = server.comfort({'action': 'status'})
+ self.assertEqual(json.loads(first['got']), {'action': 'status'})
+ self.assertEqual(len(sent), 2) # the check, then the sources
+ self.assertNotIn('# frame_steam.py', sent[0])
+ self.assertIn('# frame_steam.py', sent[1])
+ self.assertEqual(server.comfort({'action': 'status'}), first)
+ self.assertEqual(len(sent), 3)
+ self.assertNotIn('# frame_steam.py', sent[2])
+ cached, = (home / '.cache/frame-control/comfort').iterdir()
+ self.assertEqual(sorted(p.name for p in cached.iterdir()), sorted(server.COMFORT_FILES))
+ self.assertEqual(cached.stat().st_mode & 0o777, 0o700)
+ self.assertTrue(all(p.stat().st_mode & 0o777 == 0o600 for p in cached.iterdir()))
+ # A copy cut short is completed; a changed helper is a new version.
+ (cached / 'frame_status.py').unlink()
+ server.comfort({'action': 'status'})
+ self.assertEqual(len(sent), 5)
+ self.assertTrue((cached / 'frame_status.py').exists())
+ (here / 'frame_steam.py').write_text('# frame_steam.py, changed\n')
+ server.comfort({'action': 'status'})
+ self.assertEqual(len(sent), 7)
+ self.assertEqual(len(list((home / '.cache/frame-control/comfort').iterdir())), 2)
diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py
index 4ed21e9..5d4b8e7 100644
--- a/tests/test_comfort_ui.py
+++ b/tests/test_comfort_ui.py
@@ -1,4 +1,5 @@
"""Run the actual shared page's comfort renderer against a minimal DOM/bridge."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import pathlib
import shutil
import subprocess
@@ -100,3 +101,32 @@ assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone
'''
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_poll_waits_while_the_frame_is_unreachable(self):
+ page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
+ start = page.index('async function pollComfort()')
+ code = page[start:page.index('$("comfortForm").onsubmit', start)]
+ js = r'''
+const assert = require('node:assert/strict');
+const el = {textContent: 'Checking session…'};
+const $ = () => el;
+let online = false, comfortBusy = false, calls = 0, timers = 0;
+const api = async () => { calls++; return {}; };
+const renderComfort = () => {};
+const setTimeout = () => { timers++; };
+''' + code + r'''
+(async () => {
+ await pollComfort();
+ assert.equal(calls, 0); // no ssh attempt while the status check can't reach the Frame
+ assert.match(el.textContent, /when the Frame is connected/);
+ assert.equal(timers, 1); // still scheduled, to resume when it's back
+ online = true;
+ await pollComfort();
+ assert.equal(calls, 1);
+ online = null; // a status check that failed for another reason: keep polling
+ await pollComfort();
+ assert.equal(calls, 2);
+})().catch(e => { console.error(e); process.exitCode = 1; });
+'''
+ result = subprocess.run(['node', '-e', js], capture_output=True, text=True)
+ self.assertEqual(result.returncode, 0, result.stderr)
diff --git a/tests/test_contact.py b/tests/test_contact.py
new file mode 100644
index 0000000..882a596
--- /dev/null
+++ b/tests/test_contact.py
@@ -0,0 +1,412 @@
+"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
+withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
+
+Run: python3 -m unittest discover -s tests
+"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import sys
+import threading
+import time
+import unittest
+from pathlib import Path
+from unittest import mock
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+import frame_compat_db as db # noqa: E402
+import frame_contact as fc # noqa: E402
+import frame_report as fr # noqa: E402
+import frame_telemetry as tm # noqa: E402
+from test_telemetry import Base, ReportProblem # noqa: E402
+
+REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
+
+
+class Contact(Base):
+ """Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
+ serve = ReportProblem.serve
+
+ def setUp(self):
+ super().setUp()
+ self.addCleanup(fc._removed.clear)
+ for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
+ p = mock.patch.object(fc, name, value)
+ p.start()
+ self.addCleanup(p.stop)
+ self.got = self.serve()
+
+ def events(self):
+ return [body["batch"][0] for _, body in self.got]
+
+ def offline(self):
+ return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
+
+ # ---- storage and consent flags
+
+ def test_nothing_is_kept_or_sent_until_chosen(self):
+ s = fc.state()
+ self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
+ self.assertFalse(fc.FILE.exists())
+ self.assertEqual(self.got, [])
+
+ def test_an_address_needs_a_choice_and_a_real_address(self):
+ with self.assertRaisesRegex(ValueError, "tick"):
+ fc.save({"email": "me@example.com"})
+ with self.assertRaisesRegex(ValueError, "email address"):
+ fc.save({"email": "not an address", "updates": True})
+ self.assertEqual(fc.load()["email"], "")
+ self.assertEqual(self.got, [])
+
+ def test_only_a_real_true_counts_as_consent(self):
+ for wrong in ("false", "true", 1, 0, [], {}):
+ with self.assertRaisesRegex(ValueError, "true or false"):
+ fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
+ with self.assertRaisesRegex(ValueError, "true or false"):
+ fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
+ self.assertEqual((fc.load()["email"], self.got), ("", []))
+ fc.save({"email": "me@example.com", "updates": True}) # left out is no
+ self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
+
+ def test_each_choice_is_sent_privately_on_its_own(self):
+ fc.save({"email": " me@example.com ", "updates": True})
+ fc.save({"email": "me@example.com", "updates": False, "followup": True})
+ first, second = self.events()
+ self.assertEqual(first["event"], "contact_consent")
+ self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
+ {"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
+ self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
+ self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
+ self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
+ self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
+ (False, True))
+ self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
+
+ def test_sent_whatever_the_analytics_settings(self):
+ tm.update_settings({"usage": False})
+ fc.save({"email": "me@example.com", "followup": True})
+ self.assertEqual(len(self.got), 1)
+
+ def test_saving_the_same_choice_again_sends_nothing(self):
+ fc.save({"email": "me@example.com", "updates": True})
+ fc.save({"email": "me@example.com", "updates": True})
+ self.assertEqual(len(self.got), 1)
+
+ # ---- withdrawal
+
+ def test_removing_the_address_sends_a_withdrawal_without_it(self):
+ fc.save({"email": "me@example.com", "updates": True, "followup": True})
+ s = fc.save({"email": "", "updates": True, "followup": True})
+ self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
+ withdrawal = self.events()[-1]["properties"]
+ self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
+ ("withdraw", "", False, False))
+ self.assertNotIn("me@example.com", fc.FILE.read_text())
+
+ def test_an_address_still_waiting_is_withdrawn_too(self):
+ with self.offline():
+ fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
+ with mock.patch.object(tm, "post") as post:
+ fc.save({"email": ""})
+ self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
+ self.assertFalse(fc.state()["waiting"])
+
+ def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
+ fc.save({"email": "me@example.com", "updates": True})
+ with self.offline():
+ s = fc.save({"email": ""})
+ self.assertTrue(s["waiting"])
+ self.assertFalse(fc._send_pending())
+ self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
+ self.assertTrue(fc._send_pending())
+ self.assertFalse(fc.state()["waiting"])
+ self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
+
+ def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
+ fc.save({"email": "me@example.com", "followup": True})
+ fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
+ self.assertIn("me@example.com", tm.SENT.read_text())
+ fc.save({"email": ""})
+ self.assertNotIn("me@example.com", tm.SENT.read_text())
+ self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
+ if e["event"] == "contact_consent"], ["set", "withdraw"])
+
+ def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
+ fc.save({"email": "me@example.com", "updates": True})
+ fc.save({"email": "new@example.com", "updates": True})
+ fc.save({"email": ""})
+ self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
+
+ def test_a_withdrawal_during_a_send_goes_after_it(self):
+ started, release, order = threading.Event(), threading.Event(), []
+ real = tm.post
+
+ def slow(batch, timeout=20):
+ order.append(batch[0]["properties"]["action"])
+ if len(order) == 1:
+ started.set()
+ release.wait(5)
+ real(batch, timeout)
+
+ with mock.patch.object(tm, "post", side_effect=slow):
+ t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
+ t.start()
+ self.assertTrue(started.wait(5))
+ w = threading.Thread(target=fc.save, args=({"email": ""},))
+ w.start()
+ for _ in range(500): # the withdrawal is saved while the first send is still out
+ if fc.load()["rev"] == 2:
+ break
+ time.sleep(0.01)
+ self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
+ release.set()
+ t.join(5)
+ w.join(5)
+ self.assertEqual(order, ["set", "withdraw"])
+ self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
+ self.assertFalse(fc.state()["waiting"])
+ self.assertNotIn("me@example.com", tm.SENT.read_text())
+
+ def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
+ fc.save({"email": "me@example.com", "followup": True})
+ real = tm.post
+
+ def remove_meanwhile(batch, timeout=20):
+ real(batch, timeout)
+ fc.save({"email": ""}) # removed while the report is on its way, before it's logged
+
+ with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
+ fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
+ self.assertNotIn("me@example.com", tm.SENT.read_text())
+ fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
+ self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
+
+ def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
+ fc._removed["me@example.com"] = 1790000000.3
+ event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
+ before, after = event(), event() # the same whole second as the removal
+ fc.redact_removed(before, 1790000000.1)
+ fc.redact_removed(after, 1790000000.6)
+ self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
+ ("", "me@example.com"))
+
+ def test_saving_during_a_slow_send_returns_at_once(self):
+ busy = fc._send_lock
+ busy.acquire()
+ try:
+ s = fc.save({"email": "me@example.com", "updates": True})
+ finally:
+ busy.release()
+ self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
+ self.assertEqual(self.got, [])
+ self.assertTrue(fc._send_pending())
+ self.assertEqual(len(self.got), 1)
+
+ def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
+ real = fc._send_lock
+
+ class Lock: # a Save lands after the sender found nothing waiting, before it lets go
+ saved = False
+
+ def acquire(self, blocking=True):
+ return real.acquire(blocking)
+
+ def release(self):
+ if not Lock.saved:
+ Lock.saved = True
+ s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
+ s.start()
+ s.join(5)
+ assert not s.is_alive() # the change is saved while the sender still holds the lock
+ real.release()
+
+ with mock.patch.object(fc, "_send_lock", Lock()):
+ self.assertTrue(fc._send_pending())
+ self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
+ self.assertFalse(fc.state()["waiting"])
+
+ # ---- the one-time prompt
+
+ def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
+ self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
+ tm.frame_seen("20260901.1", "3.8")
+ self.assertTrue(fc.state()["showPrompt"])
+ fc.prompt({"prompt": "dismissed"})
+ fc.prompt({"prompt": "shown"}) # a later session can't bring it back
+ self.assertEqual(fc.load()["prompt"], "dismissed")
+ self.assertFalse(fc.state()["showPrompt"])
+ self.assertEqual(self.got, []) # No thanks sends nothing
+ with self.assertRaises(ValueError):
+ fc.prompt({"prompt": "reset"})
+
+ def test_the_prompt_is_shown_once_and_saving_answers_it(self):
+ tm.frame_seen("20260901.1", "3.8")
+ fc.prompt({"prompt": "shown"})
+ self.assertFalse(fc.state()["showPrompt"])
+ fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
+ self.assertEqual(fc.load()["prompt"], "answered")
+
+ # ---- reports and the maintainer's list
+
+ def reports(self):
+ return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
+
+ def test_a_report_carries_the_address_only_with_follow_up_consent(self):
+ fr.send({**REPORT, "contact": "me@example.com"})
+ self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
+ fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
+ without, with_ = self.reports()
+ self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
+ self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
+ self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
+ self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
+ with self.assertRaisesRegex(ValueError, "email address"):
+ fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
+
+ def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
+ fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
+ s = fc.state()
+ self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
+ consent = [e for e in self.events() if e["event"] == "contact_consent"]
+ self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
+ self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
+ fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
+ self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
+ self.assertEqual(self.reports()[1]["contact_rev"], 1)
+ fc.save({"email": ""}) # Remove my email
+ last = self.events()[-1]
+ self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
+ ("withdraw", "", 2))
+ logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
+ self.assertEqual(logged, ["", ""])
+
+ def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
+ """Update notices were agreed for the old address, not the new one (the form says so)."""
+ fc.save({"email": "old@example.com", "updates": True})
+ fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
+ s = fc.state()
+ self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
+ self.assertEqual(self.reports()[0]["contact_rev"], 2)
+ fc.save({"email": "new@example.com", "updates": True, "followup": False})
+ fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
+ s = fc.state()
+ self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
+
+ def test_a_removal_while_the_report_saves_its_address_still_counts(self):
+ """Removed while the report's own consent is on its way: the report keeps that consent's
+ rev (so the removal is newer) and is logged without the address."""
+ post, removed = tm.post, []
+
+ def slow_post(events, **kw):
+ post(events, **kw)
+ if not removed and events[0]["event"] == "contact_consent":
+ removed.append(fc.save({"email": ""})) # Remove my email, mid-send
+ with mock.patch.object(tm, "post", side_effect=slow_post):
+ fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
+ report = self.reports()[0]
+ self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
+ consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
+ for e in self.events() if e["event"] == "contact_consent"]
+ row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
+ fr.mark_withdrawn([row], consents)
+ self.assertEqual(row[10], "withdrawn")
+ logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
+ self.assertEqual(logged, [""])
+
+ def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
+ return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
+ "0.4.0", "Windows", "", "", followup, cid, rev]
+
+ def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
+ reports = [self.report_row(), # removed later
+ self.report_row(cid="other"), # another copy, still agrees
+ self.report_row(rev=3), # sent after the removal
+ self.report_row(cid="moved"), # address changed later
+ self.report_row(cid="news-only"), # follow-up unticked later
+ self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
+ self.report_row(cid="", followup=True), # no contact id: left alone
+ self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
+ consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
+ ["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
+ ["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
+ ["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
+ fr.mark_withdrawn(reports, consents)
+ self.assertEqual([r[10] for r in reports],
+ ["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
+
+ def test_the_change_number_decides_not_the_clock(self):
+ """The clock went back between the report and the removal: the removal still counts."""
+ fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
+ with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
+ fc.save({"email": ""})
+ report = self.reports()[0]
+ row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
+ consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
+ for e in self.events() if e["event"] == "contact_consent"]
+ self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
+ fr.mark_withdrawn([row], consents)
+ self.assertEqual(row[10], "withdrawn")
+
+ def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
+ reports = [self.report_row(), ["short"]]
+ consents = [["copy", "", False, 2]]
+ with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
+ mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
+ mock.patch("builtins.print") as out:
+ fr.main()
+ self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
+ self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
+ printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
+ self.assertIn("follow-up permission since withdrawn", printed)
+ self.assertNotIn("me@example.com", printed)
+ with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
+ fr.inbox()
+ self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
+
+ def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
+ rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
+ ["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
+ ["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
+ ["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
+ with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
+ found = fr.contacts()
+ self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
+ q.call_args.args[0])
+ self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
+ "followup": [("both@example.com", "2026-09-01")]})
+ with mock.patch.object(fr, "contacts", return_value=found), \
+ mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
+ mock.patch("builtins.print") as out:
+ fr.main()
+ printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
+ self.assertIn("both@example.com", printed)
+ self.assertNotIn("news@example.com", printed)
+
+ def test_the_page_can_reach_it(self):
+ import server
+ self.assertIs(server.POST["/api/contact"], fc.save)
+ self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
+
+ def test_saving_is_not_headset_work(self):
+ """A slow send mustn't hold up switching headsets, nor be refused after a switch."""
+ import io
+ import server
+ seen = []
+ for path in ("/api/contact", "/api/contact/prompt"):
+ h = server.Handler.__new__(server.Handler)
+ body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
+ h.path, h.rfile = path, io.BytesIO(body)
+ h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
+ h.local_request = lambda: True
+ h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
+ with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
+ h.do_POST()
+ self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
+
+
+# Run these once, in test_telemetry, not again through the import above.
+del Base, ReportProblem
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_control_center.py b/tests/test_control_center.py
new file mode 100644
index 0000000..5fdf433
--- /dev/null
+++ b/tests/test_control_center.py
@@ -0,0 +1,75 @@
+"""Control Center API guards and shared-page behavior, without a real headset."""
+import sandbox # noqa: F401 (first: isolates app state, SSH and telemetry)
+import importlib.util
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import unittest
+from unittest import mock
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'ui'))
+import server
+import frame_status
+
+
+class AudioValidation(unittest.TestCase):
+ def test_invalid_values_do_not_touch_device_even_with_valid_mute(self):
+ invalid = [{}, {'level': None}, {'level': True}, {'level': '0.5'}, {'level': []},
+ {'level': float('nan')}, {'level': float('inf')}, {'level': -1}, {'level': 2},
+ {'muted': 'false'}, {'muted': 1}, {'muted': None}, {'action': 'mute'},
+ {'level': -1, 'muted': True}, {'level': .5, 'muted': 'yes'}]
+ for body in invalid:
+ with self.subTest(body=body), mock.patch.object(server, 'ssh') as ssh:
+ with self.assertRaises(server.Failure) as raised:
+ server.set_volume(body)
+ self.assertEqual(raised.exception.status, 400)
+ ssh.assert_not_called()
+
+ def test_supported_actions_use_wpctl_with_exact_values(self):
+ for body, commands in [({'level': 0}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.00']),
+ ({'level': 1}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.00']),
+ ({'muted': False}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 0']),
+ ({'level': .65, 'muted': True}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 1',
+ 'wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.65'])]:
+ with self.subTest(body=body), mock.patch.object(server, 'ssh', return_value='') as ssh:
+ self.assertEqual(server.set_volume(body)['message'], 'Volume updated')
+ self.assertEqual([c.args[0] for c in ssh.call_args_list], commands)
+
+ def test_device_error_propagates_without_claiming_success(self):
+ with mock.patch.object(server, 'ssh', side_effect=server.Failure('Headset offline')):
+ with self.assertRaisesRegex(server.Failure, 'Headset offline'):
+ server.set_volume({'level': .5})
+
+
+class Coverage(unittest.TestCase):
+ @unittest.skipUnless(shutil.which('node'), 'Node exercises production page JS')
+ def test_control_center_behavior(self):
+ result = subprocess.run(['node', '--test', str(ROOT / 'tests/control_center.cjs')],
+ capture_output=True, text=True)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+
+ def test_shared_assets_ship_to_iphone_and_desktop(self):
+ spec = importlib.util.spec_from_file_location('frame_bundle_cc', ROOT / 'ios/scripts/make_frame_bundle.py')
+ bundle = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(bundle)
+ files = set(bundle.files())
+ for name in ('control-center.js', 'control-center.css', 'artwork-settings.js'):
+ self.assertIn(ROOT / 'ui' / name, files)
+ config = json.loads((ROOT / 'app/package.json').read_text())
+ ui = next(item for item in config['build']['extraResources'] if item['from'] == '../ui')
+ self.assertIn('*.js', ui['filter'])
+ self.assertIn('*.css', ui['filter'])
+
+ def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
+ # Run the real aggregator with every external probe stubbed.
+ names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
+ 'disk', 'memory', 'max_temp', 'wifi', 'ip_addr', 'volume', 'port_listening', 'games', 'flatpaks')
+ with mock.patch.multiple(frame_status, **{name: mock.DEFAULT for name in names}) as probes, \
+ mock.patch.object(frame_status, 'thermal_alerts', return_value=[{'zone':'battery','tempC':45,'limitC':44}]):
+ probes['read'].return_value = '120 0'
+ probes['process_names'].return_value = set()
+ result = frame_status.status()
+ self.assertEqual(result['thermalAlerts'], [{'zone':'battery','tempC':45,'limitC':44}])
diff --git a/tests/test_devices.py b/tests/test_devices.py
index dde3bea..1705dc5 100644
--- a/tests/test_devices.py
+++ b/tests/test_devices.py
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_devices as fd # noqa: E402
+import frame_host # noqa: E402
CONFIG = """Host lxso1
HostName 192.168.1.109
@@ -263,6 +264,30 @@ class Pins(Base):
self.assertFalse(fd.pinned("d1"))
self.assertFalse(fd.forget_pin("d1"))
+ def test_a_trusted_key_replaces_the_saved_one_and_fingerprints_match_ssh_keygen(self):
+ key = self.dir / "hostkey"
+ frame_host.run_ssh(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], capture_output=True,
+ stdin=subprocess.DEVNULL, check=True, timeout=20)
+ kind, blob = key.with_suffix(".pub").read_text().split()[:2]
+ want = frame_host.run_ssh(["ssh-keygen", "-l", "-E", "sha256", "-f", str(key.with_suffix(".pub"))],
+ capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=10).stdout.split()[1]
+ self.assertEqual(fd.fingerprint(blob), want)
+ self.assertIsNone(fd.fingerprint("not base64!"))
+ (self.ssh / "known_hosts").write_text(f"frame.local {KEY}\n")
+ fd.seed_pin("d1", ["frame.local"])
+ old = fd.pinned_keys("d1")
+ fd.pin_key("d1", kind, blob)
+ self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {kind} {blob}\n") # old key gone
+ self.assertEqual(fd.pinned_keys("d1"), [(kind, want)])
+ self.assertNotEqual(old, fd.pinned_keys("d1"))
+ self.assertTrue(fd.pinned("d1"))
+ for bad in (("ssh-ed25519\nHost *", blob), ("ssh-ed25519", "AAAA AAAA"), ("evil", blob)):
+ with self.assertRaises(fd.DeviceError):
+ fd.pin_key("d1", *bad)
+ # Only plain entries for this headset's alias count.
+ self.assertIsNone(fd.key_entry(f"frame-control-d2 {kind} {blob}", "frame-control-d1"))
+ self.assertIsNone(fd.key_entry(f"# frame-control-d1 {kind} {blob}", "frame-control-d1"))
+
def test_each_headset_has_its_own_file(self):
(self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n")
fd.seed_pin("da", ["a.local"])
@@ -274,7 +299,8 @@ class Pins(Base):
def test_hashed_and_non_default_port_entries(self):
kh = self.ssh / "known_hosts"
kh.write_text(f"[frame.local]:2222 {KEY}\n")
- subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
+ frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
+ stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
@@ -283,7 +309,8 @@ class Pins(Base):
target = fd.known_hosts("d4")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"frame-control-d4 {KEY}\n")
- subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
+ frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
+ stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4"))
diff --git a/tests/test_fdroid_sources.py b/tests/test_fdroid_sources.py
index 4285721..6ec0b2b 100644
--- a/tests/test_fdroid_sources.py
+++ b/tests/test_fdroid_sources.py
@@ -1,4 +1,5 @@
"""Offline authenticated repository fixtures; no tests contact a server."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
@@ -256,6 +257,36 @@ class Repositories(unittest.TestCase):
self.assertEqual(result['icon'], URL + 'icons/legacy.1.png')
self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png'])
+ def test_per_abi_builds_offer_and_download_the_arm64_one(self):
+ import hashlib
+
+ def build(code, name, abis):
+ self.files[name] = name.encode()
+ return {'manifest': {'versionName': '391', 'versionCode': code, 'usesSdk': {'minSdkVersion': 28},
+ 'nativecode': abis},
+ 'file': {'name': '/' + name, 'sha256': hashlib.sha256(name.encode()).hexdigest(), 'size': code},
+ 'added': 0}
+ # One APK per ABI under different codes (the x86_64 one highest, as F-Droid often does),
+ # plus a universal and an arm64-only build sharing a code.
+ builds = [build(3911, 'app-armeabi-v7a.apk', ['armeabi-v7a']), build(3914, 'app-x86_64.apk', ['x86_64']),
+ build(3913, 'app-x86.apk', ['x86']),
+ build(3912, 'app-universal.apk', ['arm64-v8a', 'armeabi-v7a', 'x86_64']),
+ build(3912, 'app-arm64-v8a.apk', ['arm64-v8a'])]
+ raw = self.root / 'splits.json'
+ raw.write_text(json.dumps({'packages': {'com.futo.platformplayer': {
+ 'metadata': {'name': {'en-US': 'Grayjay'}},
+ 'versions': {str(i): b for i, b in enumerate(builds)}}}}))
+ source = {'id': 'test', 'url': URL}
+ app = fdroid._reduce(raw, source)['com.futo.platformplayer']
+ self.assertEqual([v['name'] for v in app['versions']], ['/app-arm64-v8a.apk', '/app-universal.apk'])
+ self.assertEqual((app['version_code'], app['abis']), (3912, ['arm64-v8a']))
+ with patch.object(fdroid, 'details', return_value=app):
+ for code in (None, 3912):
+ fdroid.download(source, 'com.futo.platformplayer', version_code=code)
+ self.assertTrue(self.fetch_mock.call_args[0][0].endswith('/app-arm64-v8a.apk'))
+ with self.assertRaises(SourceError): # the x86_64 build is never offered
+ fdroid.download(source, 'com.futo.platformplayer', version_code=3914)
+
def test_v2_legacy_screenshot_keys_and_limit(self):
meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]},
'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}}
diff --git a/tests/test_frame_android_data.py b/tests/test_frame_android_data.py
index 175762c..251e04b 100644
--- a/tests/test_frame_android_data.py
+++ b/tests/test_frame_android_data.py
@@ -1,3 +1,4 @@
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
@@ -63,7 +64,7 @@ class ObbTests(unittest.TestCase):
with self.assertRaisesRegex(android.FrameError, 'start this app'):
data.install_obb(PKG, [path])
stream.assert_not_called()
- with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
+ with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command')
diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py
index cdf51c0..014696b 100644
--- a/tests/test_frame_android_library.py
+++ b/tests/test_frame_android_library.py
@@ -1,4 +1,5 @@
"""Offline artwork, Steam API and launcher supervision regressions."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import importlib.util
import json
import os
@@ -263,11 +264,14 @@ class ArtworkTests(unittest.TestCase):
def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self):
import frame_steamgriddb as sgdb
from apk_sources import _images
+ deadline = time.monotonic() + 5
with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get:
- self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1])
+ self.assertEqual(sgdb._get('/search/x', 'secret', deadline), [1])
self.assertEqual(get.call_args.kwargs['redirects'], 0)
self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret')
- self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5)
+ # The caller's deadline is passed on, never extended. (Comparing against a fresh
+ # time.monotonic() flaked on Windows, whose clock ticks coarsely.)
+ self.assertLessEqual(get.call_args.kwargs['deadline'], deadline)
def test_supplied_jpeg(self):
data = (FIXTURES / 'icon.jpg').read_bytes()
@@ -419,6 +423,19 @@ class InstallTests(unittest.TestCase):
self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list])
self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list))
+ def test_failed_update_removes_partial_apk_and_keeps_the_app(self):
+ with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
+ patch.object(android, 'read_meta', return_value=dict(self.existing)), \
+ patch.object(android, '_copy', side_effect=android.FrameError('copying x.apk to the Frame timed out')), \
+ patch.object(android, 'ssh', return_value='') as ssh, \
+ patch.object(android, 'shortcut_tool') as api:
+ with self.assertRaisesRegex(android.FrameError, 'timed out'):
+ android._install('x.apk', self.info, 'org.test.vr', False, None, None)
+ commands = [c.args[0] for c in ssh.call_args_list]
+ self.assertIn('rm -f Applications/Android/org.test.vr/app.apk.part', commands)
+ self.assertFalse(any(c.startswith('rm -rf') for c in commands))
+ api.assert_not_called()
+
def test_remove_keeps_data_when_requested_and_survives_steam_failure(self):
with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \
patch.object(android, 'stop'), \
@@ -482,6 +499,49 @@ class InstallTests(unittest.TestCase):
self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
+class CopyAndSshTests(unittest.TestCase):
+ """The commands behind an install fail as FrameErrors with readable words, and a large APK
+ gets the time it needs."""
+
+ def fake(self, stderr, code=1, stdout=b''):
+ """frame_host.run_ssh running a stand-in that writes these bytes, with the caller's options."""
+ script = (f'import sys; sys.stdout.buffer.write({stdout!r}); sys.stderr.buffer.write({stderr!r}); '
+ f'sys.exit({code})')
+ return lambda argv, **kwargs: subprocess.run([sys.executable, '-c', script], **kwargs)
+
+ def test_large_apk_copy_timeout_grows_with_its_size(self):
+ seen = {}
+ with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
+ patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)), \
+ patch.object(android.os.path, 'getsize', return_value=2 * 1024 ** 3):
+ android._copy(f.name, 'Applications/Android/x/app.apk.part')
+ # 2 GB at the slowest rate allowed: 4096 s, not the old fixed 600 (300 MB at 4 Mbit/s).
+ self.assertGreaterEqual(seen['timeout'], 2 * 1024 ** 3 / (512 * 1024))
+ with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
+ patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)):
+ android._copy(f.name, 'x/launch.sh', timeout=120)
+ self.assertEqual(seen['timeout'], 120) # a small file keeps the least time given
+
+ def test_copy_failure_in_another_encoding_is_a_frame_error(self):
+ # A message from a Windows code page, or a file name, isn't UTF-8.
+ with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
+ patch.object(android.shutil, 'which', return_value=None), \
+ patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'scp: Verbindung \x81ber\xff\n')):
+ with self.assertRaisesRegex(android.FrameError, r'\Acopying .* to the Frame failed: scp: Verbindung'):
+ android._copy(f.name, 'x/app.apk.part')
+
+ def test_ssh_failure_messages_are_text(self):
+ with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'mv: \xff cannot move\n')):
+ with self.assertRaisesRegex(android.FrameError, r'\Amv: .* cannot move\Z'):
+ android.ssh('mv a b')
+ with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'cat: write error: No space left on device\n')):
+ with self.assertRaises(android.FrameError) as caught:
+ android.ssh('cat > x', input=b'\x89PNG') # bytes in, bytes out
+ self.assertEqual(str(caught.exception), 'cat: write error: No space left on device')
+ with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'', code=0, stdout=b'ok \xff\n')):
+ self.assertEqual(android.ssh('echo ok').strip(), 'ok �')
+
+
class SteamAPITests(unittest.TestCase):
def test_artwork_api_enums_and_safe_serialization(self):
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
diff --git a/tests/test_frame_apk.py b/tests/test_frame_apk.py
index ebe7b0b..01d29c2 100644
--- a/tests/test_frame_apk.py
+++ b/tests/test_frame_apk.py
@@ -11,82 +11,7 @@ import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_apk # noqa: E402
-
-
-def pool(strings, utf8=False):
- """A ResStringPool chunk."""
- data, offsets = b'', []
- for s in strings:
- offsets.append(len(data))
- if utf8:
- b = s.encode()
- data += bytes([len(s), len(b)]) + b + b'\0'
- else:
- data += struct.pack('.
-
- package_raw=False drops the package's raw string (as some repackers do);
- foreign_label adds a non-android `label` attribute after android:label.
- """
- strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
- 'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
- resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
- resmap = struct.pack(' and you have
+ # requested strict checking." Every command said "yes" before anything was saved, so one
+ # that ran before the connector's handshake saved the key (or without it) failed.
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ r = self.command(d)
+ self.assertEqual(r.returncode, 0, r.stderr) # the first contact saves the key...
+ self.assertIn("Permanently added", r.stderr)
+ self.assertTrue(fd.pinned(d["id"]))
+ self.assertIn("StrictHostKeyChecking=yes", self.link.host_opts(d, "127.0.0.1")) # ...then it's checked
+ self.assertEqual(self.reg.get(d["id"])["identity"], [k[1] for k in fd.pinned_keys(d["id"])]) # and recorded
+ # The connector's first handshake, for another new headset.
+ self.reg.remove_device(d["id"])
+ d = self.device("localhost")
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual(s["phase"], "connected", s["error"])
+ self.assertTrue(fd.pinned(d["id"]))
+ self.assertTrue(self.reg.get(d["id"])["identity"])
+ alias, opts = self.routes[-1]
+ self.assertIn("StrictHostKeyChecking=yes", opts) # every command after it checks strictly
+ self.assertNotIn("StrictHostKeyChecking=accept-new", opts)
+
+ def lose_pin(self, device_id, how):
+ f = fd.known_hosts(device_id)
+ if how == "deleted":
+ f.unlink()
+ elif how == "empty":
+ f.write_text("")
+ elif how == "unreadable":
+ f.chmod(0)
+ self.addCleanup(lambda: f.exists() and f.chmod(0o600))
+ elif how == "another alias":
+ f.write_text(f.read_text().replace(f"frame-control-{device_id}", "frame-control-someone-else"))
+ elif how == "not utf-8":
+ f.write_bytes(b"\xff\xfe" + f.read_bytes())
+
+ def test_a_lost_or_unusable_pin_fails_closed(self):
+ # Review pr85-review-r1: accept-new can't tell a new headset from one whose saved key
+ # went missing; real OpenSSH then trusted a replacement key. Once a headset had an
+ # identity, nothing is trusted again until the person checks it.
+ self.pins()
+ cases = ["deleted", "empty", "another alias", "not utf-8"]
+ if os.name == "posix" and os.geteuid() != 0:
+ cases.append("unreadable") # root reads anything
+ for n, how in enumerate(cases):
+ with self.subTest(how=how):
+ self.hosts({"localhost": "ok"})
+ d = self.reg.add_device(f"frame-l{n}", port=self.port, hosts=[])
+ self.reg.add_address(d["id"], "localhost", kind="lan")
+ self.link.override = None
+ self.reg.set_active(d["id"])
+ self.link.connect(["switch"])
+ self.assertEqual(self.link.snapshot()["phase"], "connected")
+ self.link.close_master()
+ d = self.reg.get(d["id"])
+ self.lose_pin(d["id"], how)
+ self.hosts({"localhost": "wrong"}) # another device, or a reinstalled headset
+ r = self.command(d)
+ self.assertEqual(r.returncode, 255, r.stderr)
+ self.assertIn("Host key verification failed", r.stderr)
+ self.assertNotIn("Permanently added", r.stderr)
+ self.link.connect(["dropped"])
+ s = self.link.snapshot()
+ self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
+ self.assertEqual(s["error"]["hostkey"]["state"], "missing")
+ self.assertIn("missing or can't be read", s["error"]["message"])
+ self.assertEqual(s["error"]["short"], "Saved SSH identity missing")
+ view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"])
+ self.assertTrue(view["identity_lost"])
+ scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
+ self.assertFalse(scan["matches"] or scan["recorded_match"]) # not the key it had
+ self.hosts({"localhost": "ok"})
+ scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
+ self.assertTrue(scan["recorded_match"]) # its own key, as recorded
+ self.assertFalse(scan["matches"]) # but not usable from the file: trusting restores it
+ fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
+ "fingerprint": scan["fingerprint"]}, None)
+ self.link.connect(["switch"])
+ self.assertEqual(self.link.snapshot()["phase"], "connected", self.link.snapshot()["error"])
+ self.link.close_master()
+
+ def test_a_key_check_only_reads_the_key(self):
+ # Review pr85-review-r1: with ForwardAgent yes in ~/.ssh/config, a device that accepts
+ # "none" got the agent and ran a command before anyone trusted it.
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "evil"})
+ os.environ["FAKESSH_CONFIG"] = json.dumps({"forwardagent": "yes", "remotecommand": "touch /tmp/x",
+ "permitlocalcommand": "yes", "requesttty": "force"})
+ self.addCleanup(os.environ.pop, "FAKESSH_CONFIG", None)
+ for proxied in (False, True):
+ route = ("jump", "me@jumpy:2222") if proxied else (None, None)
+ with self.subTest(proxied=proxied), mock.patch.object(fl, "proxy_route", return_value=route):
+ t0 = time.monotonic()
+ scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
+ self.assertLess(time.monotonic() - t0, 10) # stopped once it had the key, not left open
+ self.assertTrue(scan["fingerprint"].startswith("SHA256:"))
+ self.assertEqual([p.name for p in self.dir.iterdir() if p.name.startswith("evil-")], [])
+ call = [c for c in self.calls() if "PreferredAuthentications=none" in c][-1]
+ self.assertEqual(call[call.index("-F") + 1], "none") # never ~/.ssh/config for the device
+ self.assertIn("-N", call)
+ hop = [c for c in call if c.startswith("ProxyCommand=")]
+ if proxied:
+ # Review pr85-review-r2: ProxyJump's own ssh inherits the config (ForwardAgent,
+ # LocalCommand); the scan builds the jump host's ssh itself, hardened.
+ self.assertEqual(len(hop), 1)
+ words = shlex.split(hop[0][len("ProxyCommand="):])
+ self.assertEqual(words[-7:], ["-l", "me", "-p", "2222", "-W", "[%h]:%p", "jumpy"])
+ for o in fl.Link.HOP_OPTS:
+ self.assertIn(o, words)
+ self.assertLess(call.index(hop[0]), call.index("HostKeyAlias=frame-control-" + d["id"]))
+ else:
+ self.assertEqual(hop, [])
+ for o in ("ForwardAgent=no", "IdentityAgent=none", "ClearAllForwardings=yes", "RemoteCommand=none",
+ "PermitLocalCommand=no", "RequestTTY=no", "PubkeyAuthentication=no"):
+ self.assertIn(o, call)
+ self.assertLess(call.index("StrictHostKeyChecking=accept-new"), call.index("HostKeyAlias=frame-control-"
+ + d["id"]))
+ self.assertFalse(fd.pinned(d["id"]))
+ self.assertEqual(sorted(p.name for p in fd.known_hosts(d["id"]).parent.iterdir()), []) # no scan files
+
+ @unittest.skipIf(os.name != "posix", "the stand-in ssh runs ProxyCommand with /bin/sh")
+ def test_the_jump_hosts_command_line_is_quoted_whatever_the_names(self):
+ # Review pr85-review-r3: the hop's words went into the ProxyCommand line unquoted, so an
+ # ssh at /tmp/x/bin$(:>INJECTED)/ssh ran shell code; and an IPv6 jump host broke.
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ for n, name in enumerate(["with space", "bin$(touch INJECTED-0)", "back`touch INJECTED-1`tick",
+ "quote'it\"s", "per%cent %h %%", "semi;touch INJECTED-2"]):
+ with self.subTest(name=name):
+ bindir = self.dir / f"b{n}" / name
+ bindir.mkdir(parents=True)
+ (bindir / "ssh").symlink_to(FAKESSH / "ssh")
+ for spec, at in (("me@jumpy:2222", "jumpy"), ("[::1]:2200", "::1")):
+ before = len(self.calls())
+ with mock.patch.dict(os.environ, {"PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"}), \
+ mock.patch.object(fl, "proxy_route", return_value=("jump", spec)):
+ scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
+ self.assertTrue(scan["fingerprint"].startswith("SHA256:"))
+ hops = [c for c in self.calls()[before:] if "-W" in c]
+ self.assertEqual(len(hops), 1, self.calls()[before:])
+ self.assertEqual(hops[0][-1], at) # the jump host, bare
+ self.assertEqual(hops[0][hops[0].index("-W") + 1], "[localhost]:%d" % self.port)
+ outer = [c for c in self.calls()[before:] if "PreferredAuthentications=none" in c][-1]
+ line = next(c for c in outer if c.startswith("ProxyCommand="))
+ self.assertTrue(line[len("ProxyCommand="):].startswith(shlex.quote(str(bindir / "ssh")
+ .replace("%", "%%"))))
+ self.assertEqual([p.name for p in self.dir.rglob("INJECTED*")], [])
+ self.assertFalse(list(Path.cwd().glob("INJECTED*")))
+
+ def test_a_key_check_refuses_routes_it_cant_harden(self):
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ before = len(self.calls())
+ for route in (("command", "nc %h %p"), ("jump", "a,b"), ("jump", "-oProxyCommand=x"), ("jump", "ssh://a"),
+ ("jump", "a b"), ("jump", "a%h"), ("jump", "jumpy:99999")):
+ with self.subTest(route=route), mock.patch.object(fl, "proxy_route", return_value=route):
+ with self.assertRaises(fd.DeviceError) as caught:
+ fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
+ self.assertIn("can't make safe", str(caught.exception))
+ self.assertFalse(any("PreferredAuthentications=none" in c for c in self.calls()[before:]))
+
+ def test_a_key_read_before_the_address_or_port_changed_isnt_trusted(self):
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ for change in ({"action": "update", "id": d["id"], "port": self.port + 1},
+ {"action": "address-remove", "id": d["id"], "host": "localhost"}):
+ with self.subTest(change=change["action"]):
+ scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
+ fl.devices_action(self.link, change, None)
+ with self.assertRaises(fd.DeviceError):
+ fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
+ "fingerprint": scan["fingerprint"]}, None)
+ self.assertFalse(fd.pinned(d["id"]))
+ self.reg.update_device(d["id"], port=self.port)
+ if not self.reg.get(d["id"])["addresses"]:
+ self.reg.add_address(d["id"], "localhost", kind="lan")
+
+ def test_terminals_judge_identity_by_the_registry_not_a_frozen_route(self):
+ # Review pr85-review-r2: named_route used the device as routed (before its first key was
+ # recorded), so a Terminal window fell back to accept-new once the pin was deleted.
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ self.assertEqual(self.link.snapshot()["phase"], "connected")
+ self.assertFalse(self.link.routed_device.get("identity")) # the frozen copy knows nothing of it
+ fd.known_hosts(d["id"]).unlink()
+ alias, opts = self.link.named_route()
+ self.assertIn("StrictHostKeyChecking=yes", opts)
+ self.assertNotIn("StrictHostKeyChecking=accept-new", opts)
+ self.hosts({"localhost": "wrong"})
+ r = subprocess.run(["ssh", *opts, alias, "true"], capture_output=True, text=True, timeout=20)
+ self.assertEqual(r.returncode, 255, r.stderr)
+ self.assertFalse(fd.pinned(d["id"]))
+
+ def test_a_key_saved_before_a_refused_login_is_kept_and_checked(self):
+ # Review pr85-review-r2: ssh saves the key during the key exchange; if the login is then
+ # refused, it was neither recorded nor protected, and the route stayed accept-new.
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "denied"})
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
+ got = self.reg.get(d["id"])
+ self.assertEqual(got["identity"], [f for _, f in fd.pinned_keys(d["id"])])
+ self.assertTrue(got["identity_established"])
+ if os.name == "posix":
+ self.assertEqual(fd.known_hosts(d["id"]).stat().st_mode & 0o777, 0o600)
+ self.assertIn("StrictHostKeyChecking=yes", self.routes[-1][1])
+ fd.known_hosts(d["id"]).unlink()
+ self.hosts({"localhost": "wrong"})
+ r = self.command(self.reg.get(d["id"]))
+ self.assertEqual(r.returncode, 255)
+ self.assertNotIn("Permanently added", r.stderr)
+
+ def test_a_key_a_command_saved_tightens_the_route(self):
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "denied"})
+ with self.link.route_lock:
+ self.link.routed_device = d
+ self.link.apply(d["alias"], self.link.host_opts(d, "127.0.0.1"))
+ opts = self.routes[-1][1]
+ self.assertIn("StrictHostKeyChecking=accept-new", opts)
+ r = subprocess.run(["ssh", *opts, d["alias"], "true"], capture_output=True, text=True, timeout=20)
+ self.assertIn("Permanently added", r.stderr)
+ self.assertEqual(r.returncode, 255) # saved the key, then refused the login
+ self.link.settle_pin() # what the connector's loop does every tick while accept-new is applied
+ self.assertIn("StrictHostKeyChecking=yes", self.routes[-1][1])
+ self.assertNotIn("StrictHostKeyChecking=accept-new", self.routes[-1][1])
+ self.assertTrue(self.reg.get(d["id"])["identity"])
+
+ def test_editing_the_address_that_worked_keeps_the_identity_history(self):
+ # Review pr85-review-r2: last_ok lives on the address, so editing or removing it made a
+ # headset whose pin was gone (and had no fingerprint recorded) new again.
+ path = self.dir / "legacy.json"
+ path.write_text(json.dumps({"version": 1, "active": "abcd1234", "networks": {}, "devices": [
+ {"id": "abcd1234", "name": "Old", "alias": "frame-old", "user": "steamos", "port": 22,
+ "identity_files": [], "addresses": [{"host": "10.0.0.5", "kind": "lan", "label": "",
+ "networks": [], "last_ok": 1790000000.0, "last_rtt_ms": 3}]}]}))
+ reg = fd.Registry(path)
+ self.assertTrue(reg.get("abcd1234")["identity_established"]) # migrated from last_ok
+ reg.update_address("abcd1234", "10.0.0.5", new_host="10.0.0.6")
+ reg.remove_address("abcd1234", "10.0.0.6")
+ reg.add_address("abcd1234", "10.0.0.7")
+ d = fd.Registry(path).get("abcd1234") # saved, too
+ self.assertFalse(any(a.get("last_ok") for a in d["addresses"]))
+ self.assertTrue(fd.identity_established(d))
+ self.assertEqual(self.link.strictness(d), "yes")
+ # A connection that works sets it as well, for a headset added since.
+ n = self.reg.add_device("frame-new", hosts=["10.0.0.8"])
+ self.reg.record_success(n["id"], "10.0.0.8", None, 1)
+ self.reg.remove_address(n["id"], "10.0.0.8")
+ self.assertTrue(self.reg.get(n["id"])["identity_established"])
+
+ def test_ssh_last_words_arrive_even_when_the_reader_lags(self):
+ # Review pr85-review-r2: ssh's exit with nothing queued yet was taken as the end, and a
+ # late reader lost "Permission denied" ("ssh stopped").
+ self.pins()
+ link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes"], control=None,
+ apply=lambda *a: None, explain=explain)
+ self.addCleanup(link.stop)
+ real = threading.Thread
+
+ class Late(real):
+ def __init__(self, *a, target=None, **kw):
+ if target is not None and getattr(target, "__name__", "") == "tail":
+ inner = target
+
+ def target():
+ time.sleep(1)
+ inner()
+ super().__init__(*a, target=target, **kw)
+ self.device("localhost")
+ self.hosts({"localhost": "denied"})
+ with mock.patch.object(fl.threading, "Thread", Late):
+ link.connect(["start"])
+ s = link.snapshot()
+ self.assertEqual((s["error"]["stage"], s["error"].get("auth")), ("login", "denied"), s["error"])
+ self.assertIn("SSH key", s["error"]["message"])
+
+ def test_forgetting_an_identity_is_refused(self):
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ saved = fd.known_hosts(d["id"]).read_text()
+ with self.assertRaises(fd.DeviceError) as caught:
+ fl.devices_action(self.link, {"action": "forget-identity", "id": d["id"]}, None)
+ self.assertIn("identity-scan", str(caught.exception))
+ self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved)
+
+ def test_without_a_shared_connection_ssh_writes_to_a_file_not_a_pipe(self):
+ # Windows: no ControlMaster, and its OpenSSH can block writing to a stderr pipe.
+ self.pins()
+ tmp = self.dir / "tmp"
+ tmp.mkdir()
+ link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes"], control=None,
+ apply=lambda *a: None, explain=explain)
+ self.addCleanup(link.stop)
+ real = subprocess.Popen
+ seen = []
+
+ def popen(argv, **kw):
+ if "-v" in argv: # the handshake (run_ssh calls go through Popen too)
+ seen.append(kw.get("stderr"))
+ return real(argv, **kw)
+ with mock.patch.object(tempfile, "tempdir", str(tmp)), mock.patch.object(fl.subprocess, "Popen", popen):
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ link.connect(["start"])
+ s = link.snapshot()
+ self.assertEqual(s["phase"], "connected", s["error"])
+ self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
+ self.assertIn("saved this headset's identity", next(st for st in s["stages"] if st["id"] == "identity")["detail"])
+ self.reg.update_device(d["id"], user="nobody")
+ self.hosts({"localhost": "denied"})
+ link.connect(["switch"])
+ s = link.snapshot()
+ self.assertEqual((s["phase"], s["error"]["stage"], s["error"].get("auth")), ("failed", "login", "denied"))
+ self.assertTrue(seen and all(isinstance(x, int) for x in seen)) # a file descriptor, never PIPE
+ self.assertNotIn(subprocess.PIPE, seen)
+ for _ in range(50): # the reader removes its file once ssh has exited
+ if not list(tmp.iterdir()):
+ break
+ time.sleep(0.05)
+ self.assertEqual(list(tmp.iterdir()), [])
+
+ def test_a_changed_key_is_shown_never_accepted_and_trusted_on_request(self):
+ # 0.4.2 on Linux and Windows: "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", with
+ # "Run Set Up Connection again" as the advice, which can't replace the saved key.
+ self.pins()
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ self.assertEqual(self.link.snapshot()["phase"], "connected")
+ saved = fd.known_hosts(d["id"]).read_text()
+ old = fd.pinned_keys(d["id"])
+ self.link.close_master()
+ self.hosts({"localhost": "wrong"}) # SteamOS reinstalled: a new host key
+ self.link.connect(["dropped"])
+ s = self.link.snapshot()
+ self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
+ shown = s["error"]["hostkey"]
+ self.assertEqual((shown["state"], shown["type"], shown["host"]), ("changed", "ssh-ed25519", "localhost"))
+ self.assertTrue(shown["fingerprint"].startswith("SHA256:"))
+ self.assertIn("Devices tab", s["error"]["message"])
+ self.assertEqual(s["error"]["short"], "SSH identity changed")
+ self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved) # never accepted on its own
+ r = self.command(d)
+ self.assertEqual(r.returncode, 255)
+ self.assertIn("REMOTE HOST IDENTIFICATION HAS CHANGED", r.stderr) # nor by any command
+ # Trusting needs the key read first, and exactly the fingerprint shown.
+ with self.assertRaises(fd.DeviceError):
+ fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
+ "fingerprint": shown["fingerprint"]}, None)
+ out = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
+ scan = out["scan"]
+ self.assertEqual(scan["fingerprint"], shown["fingerprint"])
+ self.assertFalse(scan["matches"])
+ self.assertEqual(scan["saved"], [{"type": t, "fingerprint": f} for t, f in old])
+ self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved) # reading it saves nothing
+ self.assertEqual(sorted(p.name for p in fd.known_hosts(d["id"]).parent.iterdir()), [d["id"]]) # no scan files
+ call = [c for c in self.calls() if "PreferredAuthentications=none" in c][-1] # no login offered
+ self.assertLess(call.index("StrictHostKeyChecking=accept-new"), call.index("StrictHostKeyChecking=yes"))
+ self.assertNotIn(f"UserKnownHostsFile={fd.known_hosts_opt(d['id'])}",
+ call[:call.index("StrictHostKeyChecking=yes")]) # a throwaway file, first
+ with self.assertRaises(fd.DeviceError):
+ fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"], "fingerprint": "SHA256:other"}, None)
+ self.link.scans[d["id"]]["at"] -= fl.SCAN_TTL + 1
+ with self.assertRaises(fd.DeviceError): # shown too long ago
+ fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
+ "fingerprint": shown["fingerprint"]}, None)
+ fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
+ with mock.patch.object(self.link, "kick") as kick:
+ out = fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
+ "fingerprint": shown["fingerprint"]}, None)
+ kick.assert_called_with("switch")
+ self.assertEqual(fd.pinned_keys(d["id"]), [("ssh-ed25519", shown["fingerprint"])]) # the old key is gone
+ self.assertEqual(next(x for x in out["devices"] if x["id"] == d["id"])["keys"],
+ [{"type": "ssh-ed25519", "fingerprint": shown["fingerprint"]}])
+ self.link.connect(["switch"])
+ self.assertEqual(self.link.snapshot()["phase"], "connected", self.link.snapshot()["error"])
+ out = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
+ self.assertTrue(out["scan"]["matches"])
+
+ def test_test_now_never_saves_a_key(self):
+ self.pins()
+ d = self.device("127.0.0.1")
+ self.hosts({"127.0.0.1": "ok"})
+ self.link.test(d["id"])
+ rows = self.link.snapshot()["tests"][d["id"]]["rows"]
+ self.assertEqual(rows[0]["ssh"], "unpinned")
+ self.assertFalse(fd.pinned(d["id"]))
+
+ def test_reading_a_key_from_a_headset_that_doesnt_answer_says_why(self):
+ self.pins()
+ d = self.device("nothing.invalid")
+ with self.assertRaises(fd.DeviceError) as caught:
+ fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
+ self.assertIn("Can't find the Frame", str(caught.exception))
+ self.assertEqual([p.name for p in fd.known_hosts(d["id"]).parent.iterdir()], [])
+
def test_ssh_goes_to_the_ipv4_address_that_answered(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
@@ -541,7 +981,7 @@ class Connecting(unittest.TestCase):
{"action": "update", "id": d["id"], "port": 2222},
{"action": "address-remove", "id": d["id"], "host": "localhost"},
{"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"},
- {"action": "forget-identity", "id": d["id"]}):
+ {"action": "identity-trust", "id": d["id"], "fingerprint": "SHA256:x"}):
with self.assertRaises(fd.DeviceError, msg=body):
fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1)
# Renaming, or changing another headset, is fine.
@@ -640,6 +1080,260 @@ class Connecting(unittest.TestCase):
(self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
self.assertEqual(fl.next_alias(self.link), "frame-2")
+ # ---- what a failure leaves for a problem report ----
+ def stderr(self):
+ return quiet_log(self)
+
+ def report(self):
+ import frame_report as fr
+ done = threading.Thread(target=lambda: None)
+ done.start()
+ done.join()
+ with mock.patch.object(fr, "link", self.link), \
+ mock.patch.dict(fr._ssh, {"thread": done, "line": "SSH: system OpenSSH, OpenSSH 9.9p1, LibreSSL 3.3.6"}):
+ return fr.diagnostics()
+
+ def test_each_failure_goes_to_the_server_log_scrubbed(self):
+ err = self.stderr()
+ self.device("steamdeck-jane.invalid", "localhost")
+ self.hosts({"localhost": "denied"})
+ self.link.connect(["start"])
+ line = err.getvalue()
+ self.assertIn("frame_link: login failed: The Frame didn't accept this computer's SSH key.", line)
+ self.assertIn("addresses: hostname unresolved; hostname->ipv4", line)
+ for leaked in ("steamdeck-jane", "127.0.0.1", "localhost"):
+ self.assertNotIn(leaked, line)
+ self.assertEqual(self.link.last_failure["stage"], "login")
+
+ def test_reports_carry_a_connection_summary_in_fixed_words(self):
+ self.stderr()
+ self.device("steamdeck-jane.invalid", "frame-t.invalid")
+ (self.dir / "ssh" / "config").write_text(
+ f"{fd.begin_mark('frame-t')}\nHost frame-t\n HostName 192.168.1.50\n User steamos\n{fd.end_mark('frame-t')}\n"
+ "Host frame-t\n HostName 10.0.0.7\n")
+ self.link.connect(["start"])
+ text = self.report()
+ self.assertIn("Connection: failed at find, attempt 1 (Starting up)", text)
+ self.assertIn("Headsets: 1 saved; active alias custom (saved, 2 address(es))", text)
+ self.assertIn("Error: find, frame_not_set_up", text)
+ self.assertRegex(text, r"Last failure: find, frame_not_set_up, 0 min \d+ s ago")
+ self.assertIn("Addresses tried: hostname unresolved; hostname unresolved", text)
+ self.assertIn("Network: gateway yes, Tailscale not installed", text)
+ self.assertIn("SSH: system OpenSSH, OpenSSH 9.9p1", text)
+ self.assertIn("~/.ssh/config: managed block for the active alias yes (1 managed in all); "
+ "hand-written Host for it yes", text)
+ # No free text from the error or ssh at all, and not the custom alias.
+ for leaked in ("steamdeck-jane", "Could not resolve", "Can't find", "192.168", "10.0.0.7", "steamos",
+ "frame-t", str(self.dir)):
+ self.assertNotIn(leaked, text)
+
+ def test_a_failure_on_the_last_headset_leaves_nothing_of_it_after_switching(self):
+ self.stderr()
+ a = self.device("steamdeck-jane.invalid")
+ self.link.connect(["start"])
+ b = self.reg.add_device("frame-2", port=self.port, hosts=[])
+ self.reg.add_address(b["id"], "localhost", kind="lan")
+ self.hosts({"localhost": "ok"})
+ self.reg.set_active(b["id"])
+ self.link.connect(["switch"])
+ self.assertEqual(self.link.snapshot()["phase"], "connected")
+ text = self.report()
+ self.assertIn("Connection: connected via hostname->ipv4", text)
+ self.assertRegex(text, r"Last failure: find, frame_not_set_up, ")
+ self.assertNotIn("steamdeck-jane", text)
+ self.assertNotEqual(a["id"], b["id"])
+
+ def test_the_headsets_alias_and_name_stay_out_of_the_log(self):
+ import frame_report as fr
+ err = self.stderr()
+ d = self.reg.add_device("jane-office.example.com", name="Jane Doe's work headset", hosts=[])
+ self.reg.set_active(d["id"])
+ self.link.connect(["start"])
+ self.assertIn("find failed: The active headset has no addresses.", err.getvalue())
+ # A message that names it anyway (any case) goes too, in the log and so in a report's log.
+ self.link.note_failure("ssh", "JANE-OFFICE.EXAMPLE.COM: jane doe's work headset stopped answering")
+ log = self.dir / "server.log"
+ log.write_text(err.getvalue())
+ with mock.patch.dict(os.environ, {"FRAME_CONTROL_LOG": str(log)}):
+ with mock.patch.object(fr, "link", self.link), mock.patch.dict(fr._ssh, {"thread": None, "line": "SSH: x"}):
+ text = fr.diagnostics(include_logs=True)
+ self.assertIn("frame_link: ssh failed", text)
+ for leaked in ("jane", "Jane", "JANE"):
+ self.assertNotIn(leaked, err.getvalue())
+ self.assertNotIn(leaked, text)
+
+
+def quiet_log(test):
+ """Catch frame_link's log lines, starting with nothing remembered."""
+ fl._logged.clear()
+ err = io.StringIO()
+ p = mock.patch.object(sys, "stderr", err)
+ p.start()
+ test.addCleanup(p.stop)
+ return err
+
+
+class FailureLog(unittest.TestCase):
+ def test_the_same_failure_isnt_logged_every_retry(self):
+ err = quiet_log(self)
+ for _ in range(3):
+ fl.log_failure("find", "The Frame isn't answering.", "", [{"host": "frame.local", "state": "timeout"}])
+ self.assertEqual(err.getvalue().count("frame_link:"), 1)
+ for v in fl._logged.values():
+ v["at"] -= fl.LOG_REPEAT_EVERY + 1
+ fl.log_failure("find", "The Frame isn't answering.", "", [{"host": "frame.local", "state": "timeout"}])
+ self.assertIn("(and 2 more times)", err.getvalue())
+ self.assertNotIn("frame.local", err.getvalue())
+
+ def test_interleaved_failures_are_throttled_too(self):
+ err = quiet_log(self)
+ for _ in range(4):
+ fl.log_failure("find", "The Frame isn't answering.")
+ fl.log_failure("login", "The Frame didn't accept this computer's SSH key.")
+ self.assertEqual(err.getvalue().count("frame_link:"), 2)
+ for i in range(fl.LOG_REMEMBER + 10): # many different failures: memory stays bounded
+ fl.log_failure("ssh", f"failure number {i}")
+ self.assertLessEqual(len(fl._logged), fl.LOG_REMEMBER)
+
+ def test_hosts_ssh_names_go_known_or_not_and_whatever_the_case(self):
+ err = quiet_log(self)
+ fl.log_failure("ssh", "ssh stopped", "ssh: Could not resolve hostname bastion-jane: Name or service not known")
+ fl.log_failure("ssh", "ssh stopped", "channel 0: open failed: connect to host jane-office.example.com port 22")
+ fl.log_failure("ssh", "ssh stopped", "kex_exchange_identification: Connection reset by steamdeck-jane",
+ hosts=["STEAMDECK-JANE"])
+ fl.log_failure("ssh", "Timed out talking to frame-jane", "")
+ out = err.getvalue()
+ self.assertIn("hostname ", out)
+ self.assertIn("connect to host port 22", out)
+ for leaked in ("bastion-jane", "jane-office", "steamdeck-jane", "frame-jane"):
+ self.assertNotIn(leaked, out)
+
+ def test_headsets_named_like_sshs_words_dont_shield_the_real_host(self):
+ err = quiet_log(self)
+ for saved in ("host", "hostname", "to"):
+ fl.log_failure("ssh", "ssh stopped", "ssh: connect to host bastion-jane port 22: Connection refused",
+ hosts=[saved])
+ fl.log_failure("ssh", "ssh stopped", "ssh: Could not resolve hostname jane-office.example.com: not known",
+ hosts=[saved])
+ out = err.getvalue()
+ self.assertIn("connect to host port 22", out)
+ self.assertIn("hostname ", out)
+ for leaked in ("bastion-jane", "jane-office"):
+ self.assertNotIn(leaked, out)
+ self.assertEqual(fl.hide_hosts(" and frame", ["host", "frame"]), " and ")
+
+ def test_whole_names_go_before_sshs_words_and_only_real_tokens_are_spared(self):
+ for name, said in (("Jane Doe's work headset", "Timed out talking to Jane Doe's work headset"),
+ ("Jane to Doe headset", "Jane to Doe headset stopped answering"),
+ ("", " stopped answering")):
+ out = fl.scrub_failure(said, [name])
+ self.assertTrue(out.startswith("") or out == "Timed out talking to ", out)
+ for leaked in ("Jane", "Doe"):
+ self.assertNotIn(leaked, out)
+ self.assertEqual(fl.scrub_failure("Timed out talking to Jane Doe's work headset", ["Jane Doe's work headset"]),
+ "Timed out talking to ")
+ self.assertEqual(fl.hide_hosts("@: ", ["user", "host", "ip"]), "@: ")
+
+ def test_windows_user_names_with_spaces_go_whole(self):
+ err = quiet_log(self)
+ with mock.patch.object(fl.frame_telemetry, "_user_names", return_value=set()):
+ fl.log_failure("ssh", r"Bad owner or permissions on C:\Users\Jane Doe/.ssh/config", "")
+ fl.log_failure("login", "The Frame didn't accept this computer's SSH key.",
+ "Jane Doe@frame: Permission denied (publickey).")
+ fl.log_failure("login", "refused", r"debug | ssh said: CORP\jane@frame's password: denied")
+ out = err.getvalue()
+ self.assertIn(r"C:\Users\/.ssh/config", out)
+ self.assertIn("@: Permission denied", out)
+ for leaked in ("Jane", "Doe", "jane", "CORP"):
+ self.assertNotIn(leaked, out)
+
+ def test_whole_ssh_user_at_host_fields_and_home_folders_go(self):
+ import frame_telemetry as tm
+ with mock.patch.object(tm, "_user_names", return_value=set()):
+ self.assertEqual(tm.scrub(r"CORP\Jane Doe@jane-office.example.com: Permission denied (publickey)."),
+ "@: Permission denied (publickey).")
+ self.assertEqual(tm.scrub("jane@jane-office.example.com's password: "), "@'s password: ")
+ self.assertEqual(tm.scrub(r"Bad owner on C:\Users\O'Brien/.ssh/config"), r"Bad owner on C:\Users\/.ssh/config")
+ self.assertEqual(tm.scrub(r"c:\users\Zoë Smith.Jr\.ssh\config"), r"c:\users\\.ssh\config")
+ self.assertEqual(tm.scrub("/users/O'Brien/x and /HOME/jane doe/y"), "/users//x and /HOME//y")
+ self.assertEqual(tm.scrub("write to me@example.com today"), "write to today")
+
+
+class ConnectionDiagnostics(unittest.TestCase):
+ def test_address_kinds_never_the_address(self):
+ self.assertEqual(fl.address_kind("frame.local", "fe80::1%eth0"), ".local->ipv6 link-local")
+ self.assertEqual(fl.address_kind("frame.local", "192.168.1.5"), ".local->ipv4")
+ self.assertEqual(fl.address_kind("frame.local"), ".local")
+ self.assertEqual(fl.address_kind("fe80::1%5"), "ipv6 link-local")
+ self.assertEqual(fl.address_kind("2001:db8::1"), "ipv6")
+ self.assertEqual(fl.address_kind("192.168.1.5", "192.168.1.5"), "ipv4")
+ self.assertEqual(fl.address_kind("100.101.102.103"), "tailscale")
+ self.assertEqual(fl.address_kind("frame.tail1234.ts.net", "100.101.102.103"), "tailscale")
+ self.assertEqual(fl.address_kind("steamdeck"), "hostname")
+ self.assertEqual(fl.probes_summary([{"host": "frame", "label": "from ~/.ssh/config", "state": "timeout"}]),
+ "alias hostname timeout")
+
+ def test_hidden_hosts_leave_the_rest(self):
+ self.assertEqual(fl.hide_hosts("frame_link: Could not resolve hostname frame", ["frame"]),
+ "frame_link: Could not resolve hostname ")
+ self.assertEqual(fl.hide_hosts("ssh frame to frame.local", ["frame.local", "frame"], keep=("frame",)),
+ "ssh frame to ")
+ self.assertEqual(fl.hide_hosts("reset by SteamDeck", ["steamdeck"]), "reset by ")
+ self.assertEqual(fl.hide_operands("The headset took too long to answer."), "The headset took too long to answer.")
+
+ def test_ssh_version_is_rebuilt_from_its_numbers(self):
+ import frame_report as fr
+ for said, want in (("OpenSSH_for_Windows_9.5p1, LibreSSL 3.8.2\n", "OpenSSH for Windows 9.5p1, LibreSSL 3.8.2"),
+ ("OpenSSH_9.9p1, LibreSSL 3.3.6\n", "OpenSSH 9.9p1, LibreSSL 3.3.6"),
+ ("OpenSSH_8.9p1 Ubuntu-3ubuntu0.10, OpenSSL 3.0.2 15 Mar 2022\n", "OpenSSH 8.9p1"),
+ ("OpenSSH_9.6p1, OpenSSL 3.0.13 30 Jan 2024\n", "OpenSSH 9.6p1, OpenSSL 3.0.13"),
+ ("OpenSSH_9.9p1-Jane-Doe-Laptop, LibreSSL 3.3.6\n", "unknown"),
+ ("OpenSSH_9.9p1, OpenSSL jane-laptop\n", "OpenSSH 9.9p1"),
+ (r"C:\Users\Jane\OpenSSH-portable\ssh.exe: not found", "unknown"),
+ ("", "unknown")):
+ with mock.patch.object(fr.frame_host, "run_ssh", return_value=subprocess.CompletedProcess([], 0, "", said)):
+ self.assertEqual(fr.ssh_version("ssh"), want, said)
+
+ def test_only_the_default_alias_is_named(self):
+ import frame_report as fr
+ self.assertEqual(fr.alias_kind("frame"), 'default ("frame")')
+ self.assertEqual(fr.alias_kind("jane-office.example.com"), "custom")
+
+ def test_a_slow_path_never_holds_up_a_report(self):
+ import frame_report as fr
+ release = threading.Event()
+
+ def slow():
+ release.wait(5)
+ return []
+ with mock.patch.dict(fr._ssh, {"thread": None, "line": None}), mock.patch.object(fr, "_ssh_on_path", slow), \
+ mock.patch.object(fr, "link", None), mock.patch.object(fr.shutil, "which", return_value="/usr/bin/ssh"), \
+ mock.patch.object(fr.frame_host, "run_ssh",
+ return_value=subprocess.CompletedProcess([], 0, "", "OpenSSH_9.9p1, LibreSSL 3.3.6\n")):
+ t0 = time.monotonic()
+ self.assertIn("Connection: no connector", fr.diagnostics())
+ self.assertIn("SSH: still being checked", fr.ssh_line())
+ self.assertLess(time.monotonic() - t0, 2)
+ thread = fr._ssh["thread"]
+ release.set()
+ thread.join(5)
+ self.assertTrue(fr._ssh["line"].startswith("SSH: "))
+ self.assertNotEqual(fr.ssh_line(), "SSH: still being checked")
+
+ def test_ssh_kinds(self):
+ import frame_report as fr
+ for path, kind in ((r"C:\WINDOWS\System32\OpenSSH\ssh.exe", "Windows OpenSSH (System32)"),
+ (r"C:\Program Files\OpenSSH\ssh.exe", "OpenSSH in Program Files"),
+ (r"C:\Program Files\Git\usr\bin\ssh.exe", "Git for Windows"),
+ ("/usr/bin/ssh", "system OpenSSH"), ("/opt/homebrew/bin/ssh", "Homebrew or /usr/local"),
+ ("/home/jane/bin/ssh", "other"), (None, "not found")):
+ self.assertEqual(fr.ssh_path_kind(path), kind)
+
+ def test_no_connector_says_so(self):
+ import frame_report as fr
+ with mock.patch.object(fr, "link", None):
+ self.assertIn("Connection: no connector", fr.diagnostics())
+
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
class ServerConnection(unittest.TestCase):
diff --git a/tests/test_link_ui.py b/tests/test_link_ui.py
new file mode 100644
index 0000000..2ea9ba1
--- /dev/null
+++ b/tests/test_link_ui.py
@@ -0,0 +1,137 @@
+"""The connection pill, the offline banner and the Devices tab's selection: the page's own
+functions from ui/index.html, run in Node against a minimal DOM (#87)."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import pathlib
+import re
+import shutil
+import subprocess
+import unittest
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+PAGE = (ROOT / "ui/index.html").read_text(encoding="utf-8")
+
+
+def between(start, end):
+ return PAGE[PAGE.index(start):PAGE.index(end)]
+
+
+SETUP = r'''
+const assert = require('node:assert/strict');
+const elements = new Map();
+const $ = id => {
+ if (!elements.has(id)) elements.set(id, {id, textContent: '', innerHTML: '', title: '', hidden: true, value: ''});
+ return elements.get(id);
+};
+const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
+const serverNow = () => 1000;
+const activeStage = s => (s.stages || []).find(x => x.state === 'active');
+const ago = () => 'a minute ago';
+const openTrust = () => {};
+const window = {};
+let online = false;
+const link = {live: true, s: null};
+const dv = {list: [], sel: null, data: null, found: 'x', editing: 'y'};
+let rendered = 0;
+const renderDevices = () => { rendered++; };
+let refuse = false;
+const act = async (label, fn) => refuse ? undefined : fn();
+const api = async () => ({ok: true});
+const STAGES = [{id: 'find', label: 'Finding the headset', state: 'done'},
+ {id: 'identity', label: "Checking the headset's identity", state: 'failed'}];
+const changed = {phase: 'failed', retry_at: 1012, device: {id: 'd1', name: 'Office Frame'}, network: {name: 'Home'},
+ stages: STAGES, probes: [{host: 'frame.local', detail: 'SSH identity changed'}],
+ error: {stage: 'identity', short: 'SSH identity changed', hostkey: {state: 'changed'},
+ message: 'This address answered with a different SSH identity from the one saved for Office Frame. '
+ + 'If not, a different device may be answering here.'}};
+'''
+
+
+@unittest.skipUnless(shutil.which("node"), "Node runs the page's own functions")
+class LinkUI(unittest.TestCase):
+ def run_page(self, checks):
+ code = (between("function netLabel(s) {", "function renderConnDlg() {")
+ + between("async function useDevice(id) {", "async function devAction(")
+ + between("function deviceStatus(d) {", "function renderDevices() {"))
+ script = SETUP + code + "\n(async () => {\n" + checks + "\n})().catch(e => { console.error(e); process.exitCode = 1; });"
+ r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_the_pill_keeps_the_failure_detail_in_its_tooltip(self):
+ # The connection redesign keeps the state and retry countdown in the pill,
+ # with the full failure explanation in its tooltip and details dialog.
+ self.run_page(r'''
+ link.s = changed;
+ renderPill();
+ assert.match($('conn').innerHTML, /Office Frame<\/b>Offline · retrying in 12 s<\/span>/);
+ assert.ok($('conn').title.includes(changed.error.message));
+ // Without a short reason: the stage that failed.
+ link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
+ renderPill();
+ assert.match($('conn').innerHTML, /Offline<\/span>/);
+ assert.ok($('conn').title.includes('Something.'));
+''')
+
+ def test_the_banner_never_doubles_a_full_stop(self):
+ # "…may be answering here.." and "…(Developer Mode must be on).." (#87).
+ self.run_page(r'''
+ link.s = changed;
+ renderBanner();
+ assert.equal($('offDetail').textContent, 'Office Frame on Home. Tried frame.local: SSH identity changed. Trying again in 12 s.');
+ link.s = {...changed, probes: [
+ {host: 'frame.local', detail: "The Frame didn't accept this computer's SSH key. Set Up Connection pairs this computer with it again (Developer Mode must be on)."},
+ {host: '100.64.0.2', detail: 'No answer'}]};
+ renderBanner();
+ const text = $('offDetail').textContent;
+ assert.ok(!/\.\./.test(text), text);
+ assert.ok(text.includes("frame.local: the Frame didn't accept"), text);
+ assert.ok(text.includes('must be on); 100.64.0.2: no answer. Trying'), text);
+ assert.equal(asClause('SSH identity changed.'), 'SSH identity changed'); // abbreviations keep their case
+ assert.equal(asClause('Answered in 3 ms'), 'answered in 3 ms');
+''')
+
+ def test_the_devices_list_keeps_the_whole_message_within_reach(self):
+ self.run_page(r'''
+ link.s = changed;
+ const [dot, text, full] = deviceStatus({active: true, addresses: []});
+ assert.equal(dot, 'off');
+ assert.equal(text, 'SSH identity changed');
+ assert.equal(full, changed.error.message); // the row's tooltip
+''')
+
+ def test_using_a_headset_selects_it(self):
+ # After "Use this headset" on Office Frame, the last headset's details stayed on show.
+ self.run_page(r'''
+ dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd1', name: 'Office Frame', active: false}];
+ dv.sel = 'd0';
+ await useDevice('d1');
+ assert.equal(dv.sel, 'd1');
+ assert.equal(dv.found, null);
+ assert.equal(dv.editing, null);
+ assert.equal(rendered, 1);
+ // Refused (an install is running): the selection stays, and the header shows the one in use.
+ dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd2', name: 'Spare', active: false}];
+ dv.sel = 'd0'; refuse = true;
+ await useDevice('d2');
+ assert.equal(dv.sel, 'd0');
+ assert.equal($('devSel').value, 'd0');
+''')
+
+
+class HeaderLayout(unittest.TestCase):
+ def test_the_devices_tab_explanation_wraps(self):
+ # It was cut to one line with no way to read the rest.
+ self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
+ self.assertEqual(len(re.findall(r'
', PAGE)), 2)
+
+ def test_the_header_buttons_dont_give_way(self):
+ # Refresh overhung the right edge at 1280 px: the pill (and the headset picker) shrink, not the buttons.
+ header = between("", "")
+ end = header[header.index('
'):]
+ for el in ('id="conn"', 'id="devSel"', 'id="battChip"', 'id="reportBtn"', 'id="refreshAll"'):
+ self.assertIn(el, end)
+ self.assertIn(".hdr-end > :not(.pill, .devsel) { flex: none; }", PAGE)
+ self.assertRegex(PAGE, r"\.hdr-end \{[^}]*min-width: 0;[^}]*margin-left: auto;")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_macview.py b/tests/test_macview.py
index e636927..16e0de5 100644
--- a/tests/test_macview.py
+++ b/tests/test_macview.py
@@ -7,6 +7,7 @@ input reaching the source, and closing.
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import base64
import http.client
import json
diff --git a/tests/test_media.py b/tests/test_media.py
index f277341..99c2931 100644
--- a/tests/test_media.py
+++ b/tests/test_media.py
@@ -1,4 +1,5 @@
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import argparse
import io
import json
diff --git a/tests/test_meta.py b/tests/test_meta.py
new file mode 100644
index 0000000..db1c4f9
--- /dev/null
+++ b/tests/test_meta.py
@@ -0,0 +1,1266 @@
+"""My Meta library, Experimental (ui/frame_meta.py), against a fake Meta (GraphQL answers and
+download server built from the shapes the public tools document) and a fake Frame. Offline:
+nothing here reaches Meta, a headset or the compatibility database."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import io
+import json
+import os
+import re
+from pathlib import Path
+import sys
+import threading
+import unittest
+from unittest.mock import patch
+import urllib.parse
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'ui'))
+import frame_android as android # noqa: E402
+import frame_meta as meta # noqa: E402
+import frame_quest as quest # noqa: E402
+import frame_telemetry # noqa: E402
+import test_quest # noqa: E402 (its APK builder; importing a module adds no tests here)
+
+PKG = test_quest.PKG
+TOKEN = 'OCAtestToken1234567890abcdefGHIJ-_xyz'
+OTHER_TOKEN = 'FRLAnotherToken0987654321zyxwvuts'
+APK = test_quest.apk_bytes(files=test_quest.OPENXR, version_code=100, version_name='1.0')
+OBB = b'obb data ' * 100
+PUBLIC = {'157.240.22.35'}
+REAL_SEND = meta._send # before any test replaces it with a fake Meta
+REAL_SSH = android.ssh
+PRINTABLE = ''.join(chr(c) for c in range(32, 127))
+
+
+def entitlements(apk=APK, obb=OBB, platform_sdk=False):
+ binary = {'id': '9001', 'package_name': PKG, 'version': '1.0', 'version_code': 100, 'size': str(len(apk)),
+ 'obb_binary': {'id': '9002', 'file_name': 'game-data.obb', 'size': len(obb)},
+ 'asset_files': {'nodes': [{'id': '9003', 'file_name': 'level1.pak', 'size': 3, 'is_required': True},
+ {'id': '9004', 'file_name': 'dlc.pak', 'size': 3, 'is_required': False}]}}
+ return {'data': {'viewer': {'user': {'active_entitlements': {'nodes': [
+ {'active_state': 'ACTIVE', 'grant_reason': 'PURCHASE',
+ 'item': {'id': '111', 'display_name': 'Quest Game', 'platform': 'ANDROID_6DOF', 'latest_supported_binary': binary}},
+ # Details come from a second query (only the binary id in the list).
+ # A real account's purchases come back PERMANENT.
+ {'active_state': 'PERMANENT', 'item': {'id': '222', 'display_name': 'Another Game', 'platform': 'ANDROID_6DOF',
+ 'latest_supported_binary': {'id': '9100'}}},
+ {'active_state': 'ACTIVE', 'item': {'id': '333', 'display_name': 'A Rift Game', 'platform': 'PC'}},
+ {'active_state': 'ACTIVE', 'expiration_time': 1000, 'item': {'id': '444', 'display_name': 'Expired',
+ 'platform': 'ANDROID_6DOF'}},
+ {'active_state': 'INACTIVE', 'item': {'id': '555', 'display_name': 'Refunded', 'platform': 'ANDROID_6DOF'}},
+ ]}}}}}
+
+
+class Body(io.BytesIO):
+ pass
+
+
+class FakeMeta:
+ """graph.oculus.com and securecdn.oculus.com (which redirects to a CDN host), in memory."""
+
+ def __init__(self, test, token=TOKEN, apk=APK, obb=OBB, redirect=True):
+ self.token, self.redirect, self.requests = token, redirect, []
+ self.files = {'9001': apk, '9002': obb, '9003': b'pak', '9004': b'dlc'}
+ self.entitlements = entitlements(apk, obb)
+ self.details = {'9100': {'data': {'node': {'id': '9100', 'package_name': 'org.example.another', 'version': '2',
+ 'version_code': '7', 'size': 10, 'obb_binary': None,
+ 'asset_files': {'edges': []}}}}}
+ self.addresses = {} # host -> addresses its (fake) DNS gives
+ self.hold = None # an Event the "who am I" answer waits for
+ self.cdn_headers = {}
+ self.location = 'https://scontent.oculuscdn.com/v/{id}?sig=abc'
+ self.lookups = []
+ for p in (patch.object(meta, '_send', side_effect=self.send),
+ patch.object(meta.socket, 'getaddrinfo', side_effect=self.getaddrinfo)):
+ p.start()
+ test.addCleanup(p.stop)
+
+ def getaddrinfo(self, host, port, *args, **kwargs):
+ self.lookups.append(host)
+ return [(10 if ':' in a else 2, 1, 6, '', (a, port)) for a in sorted(self.addresses.get(host, PUBLIC))]
+
+ def answer(self, status, body, headers=None):
+ raw = body if isinstance(body, bytes) else json.dumps(body).encode()
+ return status, dict(headers or {}), Body(raw)
+
+ def send(self, url, data=None, headers=None, timeout=30):
+ self.requests.append({'url': url, 'data': data, 'headers': dict(headers or {})})
+ p = urllib.parse.urlsplit(url)
+ if p.hostname == 'graph.oculus.com':
+ form = dict(urllib.parse.parse_qsl(data.decode()))
+ if form.get('access_token') != self.token:
+ return self.answer(400, {'error': {'type': 'OAuthException', 'code': 190, 'message':
+ f"Invalid OAuth access token {form.get('access_token')}"}})
+ doc = form['doc_id']
+ if doc == meta.DOC_VIEWER:
+ if self.hold:
+ self.hold.wait(5)
+ return self.answer(200, {'data': {'viewer': {'user': {'id': '1234', 'alias': 'player_one'}}}})
+ if doc == meta.DOC_ENTITLEMENTS:
+ return self.answer(200, self.entitlements)
+ if doc == meta.DOC_BINARY:
+ return self.answer(200, self.details[json.loads(form['variables'])['binaryID']])
+ return self.answer(200, {'errors': [{'message': 'unknown doc'}]})
+ q = dict(urllib.parse.parse_qsl(p.query))
+ if p.hostname == 'securecdn.oculus.com':
+ if q.get('access_token') != self.token:
+ return self.answer(401, b'')
+ if q['id'] not in self.files:
+ return self.answer(404, b'')
+ if self.redirect:
+ return self.answer(302, b'', {'Location': self.location.format(id=q['id'], token=self.token)})
+ return self.answer(200, self.files[q['id']], {'Content-Length': str(len(self.files[q['id']]))})
+ if p.hostname == 'scontent.oculuscdn.com':
+ body = self.files[p.path.rsplit('/', 1)[-1]]
+ headers = dict({'Content-Length': str(len(body))}, **self.cdn_headers)
+ return self.answer(200, body, {k: v for k, v in headers.items() if v is not None})
+ return self.answer(404, b'')
+
+ def downloads(self):
+ return [r for r in self.requests if 'oculus.com/binaries' in r['url'] or 'oculuscdn' in r['url']]
+
+
+def connected(test, **kw):
+ fake = FakeMeta(test, **kw)
+ meta.connect(TOKEN)
+ test.addCleanup(meta.disconnect)
+ return fake
+
+
+class Tokens(unittest.TestCase):
+ def tearDown(self):
+ meta.disconnect()
+
+ def test_token_format(self):
+ self.assertEqual(meta.check_token(f' {TOKEN}\n'), TOKEN)
+ self.assertEqual(meta.check_token('Bearer ' + OTHER_TOKEN), OTHER_TOKEN)
+ for bad, why in (('', 'Paste'), ('hello world', 'unusual'), ('ABCDEFGHIJKLMNOPQRSTUVWX', 'OC or FRL'),
+ ('OC' + '%7C' * 10, 'URL-encoded'), ('OC|752908224809889|', 'app token'),
+ ('OC123456789012345', 'incomplete')):
+ with self.assertRaisesRegex(meta.MetaError, why):
+ meta.check_token(bad)
+
+ def test_connect_checks_then_stores_owner_only(self):
+ fake = FakeMeta(self)
+ result = meta.connect(TOKEN)
+ self.assertTrue(result['connected'])
+ self.assertEqual(result['user'], {'name': 'player_one'})
+ self.assertNotIn(TOKEN, json.dumps(result))
+ self.assertNotIn(TOKEN, json.dumps(meta.status()))
+ path = meta.account_path()
+ self.assertEqual(json.loads(path.read_text())['token'], TOKEN)
+ if os.name != 'nt':
+ self.assertEqual(path.stat().st_mode & 0o777, 0o600)
+ self.assertFalse([p for p in path.parent.iterdir() if p.name.startswith('.meta-account-')]) # no temp left
+ # The token went to Meta's API host only, over HTTPS, in the POST body (not the address).
+ request = fake.requests[0]
+ self.assertTrue(request['url'].startswith('https://graph.oculus.com/'))
+ self.assertNotIn(TOKEN, request['url'])
+ self.assertIn(urllib.parse.quote(TOKEN, safe=''), request['data'].decode())
+ self.assertEqual(dict(urllib.parse.parse_qsl(request['data'].decode()))['doc_id'], meta.DOC_VIEWER)
+
+ def test_rejected_token_is_not_stored_or_echoed(self):
+ FakeMeta(self, token=OTHER_TOKEN)
+ with self.assertRaises(meta.TokenRejected) as e:
+ meta.connect(TOKEN)
+ self.assertNotIn(TOKEN, str(e.exception))
+ self.assertFalse(meta.account_path().exists())
+ self.assertFalse(meta.status()['connected'])
+
+ def test_disconnect_deletes_the_file(self):
+ FakeMeta(self)
+ meta.connect(TOKEN)
+ meta.disconnect()
+ self.assertFalse(meta.account_path().exists())
+ with self.assertRaisesRegex(meta.MetaError, 'Connect your Meta account first'):
+ meta.library()
+ meta.disconnect() # twice is fine
+
+ def test_disconnect_wins_over_a_connect_in_flight(self):
+ fake = FakeMeta(self)
+ fake.hold = threading.Event()
+ errors = []
+
+ def connect():
+ try:
+ meta.connect(TOKEN)
+ except meta.MetaError as e:
+ errors.append(e)
+ thread = threading.Thread(target=connect)
+ thread.start()
+ for _ in range(500):
+ if fake.requests:
+ break
+ threading.Event().wait(0.01)
+ meta.disconnect() # while Meta is still answering "who am I"
+ fake.hold.set()
+ thread.join(10)
+ self.assertEqual(len(errors), 1)
+ self.assertIn('disconnected', str(errors[0]))
+ self.assertFalse(meta.account_path().exists())
+ self.assertFalse(meta.status()['connected'])
+
+
+class SignInAttempts(unittest.TestCase):
+ """Sign in with Meta (app/meta-signin.js): a token from an attempt the app gave up on is
+ never kept, whether the cancel comes while it is checked or just after it was saved."""
+
+ def tearDown(self):
+ meta.disconnect()
+
+ def held_connect(self, fake, attempt):
+ """Start connect(TOKEN, attempt) and wait until Meta is answering "who am I"."""
+ fake.hold = threading.Event()
+ errors = []
+
+ def connect():
+ try:
+ meta.connect(TOKEN, attempt=attempt)
+ except meta.MetaError as e:
+ errors.append(e)
+ thread = threading.Thread(target=connect)
+ thread.start()
+ for _ in range(500):
+ if fake.requests:
+ break
+ threading.Event().wait(0.01)
+ return thread, errors
+
+ def test_cancelling_while_meta_answers_wins(self):
+ fake = FakeMeta(self)
+ thread, errors = self.held_connect(fake, 'attempt-during-0003')
+ self.assertIn('attempt-during-0003', meta._attempts)
+ meta.cancel_signin('attempt-during-0003') # the window closed while Meta was answering
+ fake.hold.set()
+ thread.join(10)
+ self.assertEqual(len(errors), 1)
+ self.assertIsInstance(errors[0], meta.SignInCancelled)
+ self.assertFalse(meta.account_path().exists())
+ self.assertFalse(meta.status()['connected'])
+ self.assertEqual(meta._attempts, {})
+
+ def test_other_cancels_cant_push_out_one_in_flight(self):
+ fake = FakeMeta(self)
+ thread, errors = self.held_connect(fake, 'attempt-held-0004')
+ meta.cancel_signin('attempt-held-0004')
+ for i in range(300):
+ meta.cancel_signin(f'attempt-noise-{i:04d}')
+ fake.hold.set()
+ thread.join(10)
+ self.assertIsInstance(errors[0], meta.SignInCancelled)
+ self.assertFalse(meta.account_path().exists())
+ self.assertEqual(meta._attempts, {}) # unknown ids left nothing behind
+
+ def test_a_cancel_just_after_the_save_removes_that_token(self):
+ FakeMeta(self)
+ self.assertTrue(meta.connect(TOKEN, attempt='attempt-saved-0005')['connected'])
+ self.assertFalse(meta.cancel_signin('attempt-saved-0005')['connected'])
+ self.assertFalse(meta.account_path().exists())
+
+ def test_a_cancel_between_the_save_and_the_cleanup_removes_that_token(self):
+ # The connect has saved and let go of the lock, but hasn't yet dropped its attempt
+ # from the in-flight list when the cancel arrives.
+ FakeMeta(self)
+ real_status = meta.status
+ seen = {}
+
+ def cancel_in_the_gap():
+ # status() runs after the save; put the attempt back as still listed, as it is
+ # in the moment before connect's cleanup, and cancel there.
+ if 'done' not in seen and meta._saved_by[0] == 'attempt-gap-0011':
+ seen['done'] = True
+ with meta._lock:
+ meta._attempts['attempt-gap-0011'] = {'cancelled': False, 'running': 1}
+ meta.cancel_signin('attempt-gap-0011')
+ seen['listed'] = 'attempt-gap-0011' in meta._attempts
+ with meta._lock:
+ del meta._attempts['attempt-gap-0011']
+ return real_status()
+ with patch.object(meta, 'status', side_effect=cancel_in_the_gap):
+ meta.connect(TOKEN, attempt='attempt-gap-0011')
+ self.assertTrue(seen.get('listed'))
+ self.assertFalse(meta.account_path().exists())
+ self.assertFalse(meta.status()['connected'])
+
+ def test_a_cancel_while_still_listed_marks_and_never_touches_another_account(self):
+ FakeMeta(self)
+ meta.connect(TOKEN) # a pasted token owns the account
+ with meta._lock:
+ meta._attempts['attempt-listed-0012'] = {'cancelled': False, 'running': 1}
+ try:
+ meta.cancel_signin('attempt-listed-0012')
+ self.assertTrue(meta._attempts['attempt-listed-0012']['cancelled'])
+ finally:
+ with meta._lock:
+ meta._attempts.pop('attempt-listed-0012', None)
+ self.assertTrue(meta.status()['connected'])
+
+ def test_a_cancel_never_removes_a_later_account(self):
+ FakeMeta(self)
+ meta.connect(TOKEN, attempt='attempt-first-0006')
+ meta.connect(TOKEN, attempt='attempt-second-0007') # a later sign-in owns the account now
+ meta.cancel_signin('attempt-first-0006')
+ self.assertTrue(meta.status()['connected'])
+ meta.connect(TOKEN) # a pasted token
+ meta.cancel_signin('attempt-second-0007')
+ self.assertTrue(meta.status()['connected'])
+ meta.disconnect()
+ meta.connect(TOKEN, attempt='attempt-third-0008')
+ meta.disconnect()
+ meta.connect(TOKEN)
+ meta.cancel_signin('attempt-third-0008') # its account was disconnected already
+ self.assertTrue(meta.status()['connected'])
+
+ def test_unknown_attempts_change_nothing(self):
+ FakeMeta(self)
+ meta.cancel_signin('attempt-unknown-0009')
+ self.assertTrue(meta.connect(TOKEN, attempt='attempt-unknown-0009')['connected'])
+ self.assertEqual(meta._attempts, {})
+
+ def test_a_rejected_token_leaves_no_attempt_behind(self):
+ FakeMeta(self, token=OTHER_TOKEN)
+ with self.assertRaises(meta.TokenRejected):
+ meta.connect(TOKEN, attempt='attempt-rejected-0010')
+ self.assertEqual(meta._attempts, {})
+
+ def test_attempt_ids_are_checked(self):
+ FakeMeta(self)
+ for bad in ('short', 'x' * 65, 'has space here', 'a/b/c/d/e', 123, ''):
+ with self.assertRaisesRegex(meta.MetaError, 'attempt'):
+ meta.connect(TOKEN, attempt=bad)
+ with self.assertRaisesRegex(meta.MetaError, 'attempt'):
+ meta.cancel_signin(bad)
+ with self.assertRaisesRegex(meta.MetaError, 'attempt'):
+ meta.cancel_signin(None)
+ self.assertFalse(meta.account_path().exists())
+
+
+class Redaction(unittest.TestCase):
+ def setUp(self):
+ FakeMeta(self)
+ meta.connect(TOKEN)
+ self.addCleanup(meta.disconnect)
+
+ def test_every_form_of_the_token(self):
+ def encoded(text, fmt):
+ return ''.join(fmt % ord(c) for c in text)
+ forms = [TOKEN, TOKEN.upper(), TOKEN.lower(), TOKEN.swapcase(), urllib.parse.quote(TOKEN, safe=''),
+ urllib.parse.quote_plus(TOKEN)]
+ # Fully percent-encoded, of the token recased too, with either case of hex digits, and mixed.
+ for text in (TOKEN, TOKEN.lower(), TOKEN.upper(), TOKEN.swapcase()):
+ forms += [encoded(text, '%%%02X'), encoded(text, '%%%02x')]
+ forms.append(''.join(c if i % 2 else '%%%02x' % ord(c.lower()) for i, c in enumerate(TOKEN)))
+ # Percent-encoded twice (%25XX), as an error quoting an encoded URL shows it: recased too.
+ once = encoded(TOKEN, '%%%02X')
+ forms += [urllib.parse.quote(once, safe=''), urllib.parse.quote(urllib.parse.quote(TOKEN, safe=''), safe='')]
+ for text in (TOKEN, TOKEN.lower(), TOKEN.swapcase()):
+ forms += [encoded(text, '%%25%02X'), encoded(text, '%%25%02x')]
+ forms.append(''.join(('%%25%02x' if i % 3 == 0 else '%%%02X' if i % 3 == 1 else '%s') % (ord(c) if i % 3 < 2 else c)
+ for i, c in enumerate(TOKEN.swapcase())))
+ for form in forms:
+ out = meta.redact(f'GET https://example.invalid/x?id=1&tok={form} failed')
+ self.assertNotIn(form, out)
+ self.assertIn(meta.REDACTED, out)
+ plain = urllib.parse.unquote(urllib.parse.unquote(out)).lower()
+ self.assertNotIn(TOKEN[2:12].lower(), plain)
+ self.assertNotIn(TOKEN[-10:].lower(), plain) # the whole credential, not just its start
+ # Any start of it of 8+ characters (as a cut elsewhere leaves), however written.
+ for n in (8, 12, 20):
+ for prefix in (TOKEN[:n], TOKEN[:n].lower(), encoded(TOKEN[:n].lower(), '%%%02x'),
+ encoded(TOKEN[:n].swapcase(), '%%25%02X'), encoded(TOKEN[:n], '%%25%02x')):
+ self.assertNotIn(prefix, meta.redact(f'cut: {prefix}', token=TOKEN))
+
+ def test_token_shaped_values_that_are_not_stored(self):
+ for text in (f'access_token={OTHER_TOKEN}&x=1', f'"access_token": "{OTHER_TOKEN}"',
+ f'Authorization: Bearer {OTHER_TOKEN}', f'OAuth {OTHER_TOKEN}', f'error near {OTHER_TOKEN} here',
+ f'ACCESS%5FTOKEN%3D{OTHER_TOKEN}'):
+ self.assertNotIn(OTHER_TOKEN[3:15], meta.redact(text), text)
+ self.assertEqual(meta.redact('com.oculus.systemactivities is missing'), 'com.oculus.systemactivities is missing')
+ self.assertEqual(meta.redact(None), '')
+
+ def test_a_token_sharing_the_known_tokens_start_is_redacted_whole(self):
+ known, other = 'OCabcdefX123456789012', 'OCabcdefY123456789012'
+ for text in (other, f'see {other} here', f'{other}{known}', f'{known} {other}'):
+ out = meta.redact(text, token=known)
+ self.assertNotIn('123456789012', out, text)
+ self.assertNotIn('abcdef', out, text)
+
+ def test_a_known_token_with_oc_inside_its_start_is_redacted_whole(self):
+ known = 'OCaOCabcdef123456789012'
+ enc = ''.join('%%%02x' % ord(c) for c in known[:3]) + known[3:]
+ for text in (f'error near {known} here', f'error near %4fCaOCabcdef123456789012 here',
+ f'error near oCaOCabcdef123456789012 here', f'error near {enc} here',
+ f'error near {known.swapcase()} here', f'x {known}{known} y'):
+ out = meta.redact(text, token=known)
+ self.assertTrue(out.startswith('error near ') or out.startswith('x '), out)
+ for frag in ('Ca', '4fCa', 'abcdef', '123456789012'):
+ self.assertNotIn(frag, out.replace('error near', '').replace(meta.REDACTED, ''), (text, out))
+
+ def test_a_partial_match_before_the_token_cannot_hide_its_start(self):
+ known = 'OCabcdefOCabcdefg1234567890'
+ for text in (known[:8].lower() + known.lower(), known[:9] + known, 'xx' + known[:8] + known + 'yy'):
+ out = meta.redact(text, token=known)
+ self.assertNotIn('1234567890', out, (text, out))
+ self.assertNotIn('g123', out, (text, out))
+ import time
+ t0 = time.monotonic(); meta.redact(('OCabcdef' * 12000)[:100000], token=known)
+ self.assertLess(time.monotonic() - t0, 2.0)
+
+ def test_redacted_before_truncation(self):
+ for cut in range(1, len(TOKEN) + 1):
+ text = 'x' * 50 + TOKEN + ' tail'
+ out = meta.redact(text, limit=50 + cut)
+ self.assertLessEqual(len(out), 50 + cut)
+ self.assertNotIn(TOKEN[:6], out)
+ self.assertEqual(meta.redact('short', limit=100), 'short')
+
+
+class Hosts(unittest.TestCase):
+ def setUp(self):
+ self.fake = FakeMeta(self)
+
+ def test_only_plain_https(self):
+ for url in ('http://securecdn.oculus.com/x', 'https://user:pw@securecdn.oculus.com/x',
+ 'https://securecdn.oculus.com:8443/x', 'ftp://securecdn.oculus.com/x'):
+ with self.assertRaisesRegex(meta.MetaError, 'plain HTTPS'):
+ meta.check_url(url)
+ self.assertEqual(meta.check_url('https://scontent.oculuscdn.com/v/1'), 'https://scontent.oculuscdn.com/v/1')
+ self.assertEqual(self.fake.lookups, []) # the URL check never looks anything up
+
+ def test_public_addresses_only(self):
+ for ip in ('10.0.0.5', '127.0.0.1', '169.254.169.254', '100.64.0.1', '192.0.0.8', '0.0.0.0',
+ '224.0.0.1', '::1', '::', 'fd00::1', 'fe80::1', 'ff02::1', 'fec0::1', '64:ff9b::a00:1',
+ '::ffff:10.0.0.1', '2002:a00:1::1', '2001::1', '2001:db8::1', 'not an ip'):
+ self.assertFalse(meta.public_address(ip), ip)
+ for ip in ('157.240.22.35', '8.8.8.8', '2a03:2880:f12f:83:face:b00c:0:25de'):
+ self.assertTrue(meta.public_address(ip), ip)
+ self.fake.addresses['cdn.example.com'] = {'157.240.22.35', 'ff02::1'}
+ with self.assertRaisesRegex(meta.MetaError, 'private or local'):
+ meta.resolve_public('cdn.example.com')
+ self.assertEqual(meta.resolve_public('scontent.oculuscdn.com'), [('157.240.22.35', 443)])
+
+ def test_connects_only_to_the_checked_address(self):
+ self.fake.addresses['scontent.oculuscdn.com'] = {'157.240.22.35', '157.240.22.36'}
+ tried = []
+
+ def connect(host, address, timeout, deadline):
+ tried.append((host, address, deadline))
+ if address[0].endswith('35'):
+ raise OSError('unreachable')
+ return 'connection'
+ with patch.object(meta, '_connect', side_effect=connect):
+ self.assertEqual(meta._public_connection('scontent.oculuscdn.com', 60), 'connection')
+ self.assertEqual([a for _, a, _ in tried], [('157.240.22.35', 443), ('157.240.22.36', 443)])
+ self.assertEqual(len({d for _, _, d in tried}), 1) # one shared deadline
+ self.assertEqual(self.fake.lookups, ['scontent.oculuscdn.com']) # resolved once
+ self.fake.addresses['evil.example'] = {'10.1.1.1'}
+ with patch.object(meta, '_connect') as never:
+ with self.assertRaises(meta.MetaError):
+ meta._public_connection('evil.example', 60)
+ never.assert_not_called()
+
+ def test_send_uses_the_checked_connection_and_closes_it(self):
+ calls = []
+
+ class Conn:
+ def request(self, method, target, body=None, headers=None):
+ calls.append((method, target, body, headers))
+
+ def getresponse(self):
+ r = Body(b'{}')
+ r.status, r.headers = 200, {}
+ return r
+
+ def close(self):
+ calls.append('closed')
+ with patch.object(meta, '_public_connection', return_value=Conn()) as connect:
+ status, _, body = REAL_SEND('https://graph.oculus.com/graphql?x=1', data=b'a=1')
+ with body:
+ self.assertEqual(body.read(), b'{}')
+ self.assertEqual(status, 200)
+ connect.assert_called_once_with('graph.oculus.com', meta.TIMEOUT)
+ self.assertEqual(calls[0][:3], ('POST', '/graphql?x=1', b'a=1'))
+ self.assertEqual(calls[-1], 'closed')
+ with self.assertRaisesRegex(meta.MetaError, 'plain HTTPS'):
+ REAL_SEND('http://graph.oculus.com/graphql')
+
+ def test_redirects_off_meta_never_carry_the_token(self):
+ start = f'https://securecdn.oculus.com/binaries/download/?id=1&access_token={TOKEN}'
+ self.assertEqual(meta.next_hop(start, 'https://scontent.oculuscdn.com/v/1?sig=x', TOKEN),
+ 'https://scontent.oculuscdn.com/v/1?sig=x')
+ same = meta.next_hop(start, f'/binaries/download/?id=2&access_token={TOKEN}', TOKEN)
+ self.assertIn(TOKEN, same) # still Meta's download host
+ quoted = urllib.parse.quote(TOKEN, safe='')
+ lower_hex = ''.join('%%%02x' % ord(c) for c in TOKEN.lower())
+ for target in (f'https://cdn.example/v/1?sig={TOKEN}', f'https://cdn.example/{TOKEN}/file',
+ f'https://cdn.example/v/1#{TOKEN}', f'https://cdn.example/v/1?sig={quoted}',
+ f'https://cdn.example/{lower_hex}', f'https://cdn.example/v?s={TOKEN.swapcase()}',
+ f'https://{TOKEN[:12].lower()}.cdn.example/v', f'https://cdn.example/v?x={TOKEN[:9]}',
+ 'https://cdn.example/v?ACCESS_TOKEN=other'):
+ with self.assertRaisesRegex(meta.MetaError, 'carrying your token'):
+ meta.next_hop(start, target, TOKEN)
+ self.assertEqual(self.fake.lookups, []) # refused before any lookup (a token in a host name)
+ with self.assertRaisesRegex(meta.MetaError, 'plain HTTPS'):
+ meta.next_hop(start, 'http://evil.example/steal', TOKEN)
+
+ def test_download_redirect_with_the_token_is_refused_before_connecting(self):
+ meta.connect(TOKEN)
+ self.addCleanup(meta.disconnect)
+ token, generation = meta._session()
+ work = meta.cache_root() / 'refuse'
+ work.mkdir(parents=True, exist_ok=True)
+ self.addCleanup(lambda: __import__('shutil').rmtree(work, ignore_errors=True))
+ self.fake.location = 'https://cdn.example/{token}/v/{id}'
+ with self.assertRaisesRegex(meta.MetaError, 'carrying your token'):
+ meta.fetch('9002', work / 'x.obb', meta.MAX_DATA_FILE, token, generation)
+ self.assertFalse([r for r in self.fake.requests if 'cdn.example' in r['url']])
+ self.assertEqual(list(work.iterdir()), [])
+
+ def test_download_redirect_sends_no_token_to_the_cdn(self):
+ meta.connect(TOKEN)
+ self.addCleanup(meta.disconnect)
+ token, generation = meta._session()
+ dest = Path(meta.cache_root()) / 'redirect-test'
+ dest.mkdir(parents=True, exist_ok=True)
+ self.addCleanup(lambda: [p.unlink() for p in dest.iterdir()] and dest.rmdir())
+ got = meta.fetch('9002', dest / 'x.obb', meta.MAX_DATA_FILE, token, generation, expected=len(OBB))
+ self.assertEqual(Path(got['path']).read_bytes(), OBB)
+ cdn = [r for r in self.fake.requests if 'oculuscdn' in r['url']]
+ self.assertEqual(len(cdn), 1)
+ self.assertNotIn(TOKEN, json.dumps(cdn[0]))
+ self.assertNotIn('access_token', cdn[0]['url'].lower())
+ self.assertNotIn('Authorization', cdn[0]['headers'])
+
+ def test_graphql_redirect_not_followed(self):
+ with patch.object(meta, '_send', return_value=(302, {'Location': 'https://evil.example/'}, Body(b''))):
+ with self.assertRaisesRegex(meta.MetaError, 'redirected'):
+ meta.graphql(TOKEN, meta.DOC_VIEWER)
+
+ def test_network_errors_are_redacted(self):
+ error = OSError(f'connection to https://graph.oculus.com/?access_token={TOKEN} reset')
+ with patch.object(meta, '_send', side_effect=error):
+ with self.assertRaises(meta.MetaError) as e:
+ meta.graphql(TOKEN, meta.DOC_VIEWER)
+ self.assertNotIn(TOKEN, str(e.exception))
+ self.assertIsNone(e.exception.__cause__)
+ self.assertTrue(e.exception.__suppress_context__)
+
+
+class Library(unittest.TestCase):
+ def test_owned_quest_apps_with_download_facts(self):
+ fake = connected(self)
+ apps = {a['id']: a for a in meta.library()}
+ self.assertEqual(set(apps), {'111', '222'}) # no Rift app, nothing expired or inactive
+ game = apps['111']
+ self.assertEqual((game['title'], game['package'], game['version'], game['version_code'], game['size']),
+ ('Quest Game', PKG, '1.0', 100, len(APK)))
+ self.assertEqual(game['binary_id'], '9001')
+ self.assertEqual(game['obb'], {'id': '9002', 'name': 'game-data.obb', 'size': len(OBB), 'sha256': None,
+ 'required': True})
+ self.assertEqual([(f['id'], f['required']) for f in game['assets']], [('9003', True), ('9004', False)])
+ other = apps['222'] # filled in from the binary query
+ self.assertEqual((other['package'], other['version_code'], other['size']), ('org.example.another', 7, 10))
+ queries = len(fake.requests)
+ meta.library()
+ self.assertEqual(len(fake.requests), queries) # kept in memory
+ public = meta._public(game)
+ self.assertEqual(public['data_files'], 1) # the OBB; level1.pak isn't a standard OBB name
+ self.assertEqual(public['skipped_files'], ['level1.pak'])
+ self.assertNotIn(TOKEN, json.dumps(public))
+ meta.disconnect()
+ with self.assertRaises(meta.MetaError):
+ meta.library()
+
+ def test_binary_query_when_the_list_doesnt_say_what_data_there_is(self):
+ fake = connected(self)
+ binary = fake.entitlements['data']['viewer']['user']['active_entitlements']['nodes'][0]['item'][
+ 'latest_supported_binary']
+ fake.details['9001'] = {'data': {'node': dict(binary)}}
+ for key in ('obb_binary', 'asset_files'):
+ binary.pop(key)
+ game = next(a for a in meta.library(refresh=True) if a['id'] == '111')
+ self.assertEqual(game['obb']['id'], '9002') # from the binary query
+ asked = [json.loads(dict(urllib.parse.parse_qsl(r['data'].decode()))['variables']).get('binaryID')
+ for r in fake.requests if r['data'] and meta.DOC_BINARY in r['data'].decode()]
+ self.assertIn('9001', asked)
+ with patch.object(meta, 'MAX_DETAILS', 0): # beyond the cap: filled in when chosen
+ game = next(a for a in meta.library(refresh=True) if a['id'] == '111')
+ self.assertIsNone(game['obb'])
+ self.assertEqual(meta.owned('111')['obb']['id'], '9002')
+
+ def test_cli(self):
+ FakeMeta(self)
+ self.addCleanup(meta.disconnect)
+ out = io.StringIO()
+ with patch.object(sys, 'argv', ['frame_meta.py', 'connect', '--token-stdin']), \
+ patch.object(sys, 'stdin', io.StringIO(TOKEN + '\n')), patch('sys.stdout', out), \
+ patch('sys.stderr', io.StringIO()):
+ meta.main()
+ self.assertTrue(json.loads(out.getvalue())['connected'])
+ out = io.StringIO()
+ with patch.object(sys, 'argv', ['frame_meta.py', 'list']), patch('sys.stdout', out), \
+ patch.object(meta, 'with_verdicts', side_effect=lambda apps: apps):
+ meta.main()
+ listed = json.loads(out.getvalue())
+ self.assertEqual({a['id'] for a in listed}, {'111', '222'})
+ self.assertNotIn(TOKEN, out.getvalue())
+ with patch.object(sys, 'argv', ['frame_meta.py', 'connect', TOKEN]):
+ with self.assertRaises(SystemExit) as e:
+ meta.main()
+ self.assertNotIn(TOKEN, str(e.exception.code)) # a token on the command line is refused, not echoed
+
+ def test_only_owned_apps_download(self):
+ fake = connected(self)
+ with self.assertRaisesRegex(meta.MetaError, "isn't in your Meta library"):
+ meta.owned('999')
+ with self.assertRaisesRegex(meta.MetaError, 'invalid'):
+ meta.owned('../1')
+ with self.assertRaisesRegex(meta.MetaError, "isn't in your Meta library"):
+ meta.download('333') # a Rift app the account owns isn't a Quest app
+ self.assertEqual(fake.downloads(), [])
+
+ def test_expired_token_while_listing(self):
+ fake = connected(self)
+ fake.token = OTHER_TOKEN # Meta signed it out
+ with self.assertRaises(meta.TokenRejected) as e:
+ meta.library(refresh=True)
+ self.assertIn('get a new one', str(e.exception))
+
+
+class Downloads(unittest.TestCase):
+ def test_apk_and_obb_into_the_cache(self):
+ fake = connected(self)
+ r = meta.download('111')
+ self.addCleanup(lambda: __import__('shutil').rmtree(r['folder'], ignore_errors=True))
+ self.assertEqual(Path(r['apk']).read_bytes(), APK)
+ self.assertEqual([Path(p).name for p in r['obb']], [f'main.100.{PKG}.obb'])
+ self.assertEqual(Path(r['obb'][0]).read_bytes(), OBB)
+ self.assertEqual(r['skipped'], ['level1.pak'])
+ self.assertTrue(r['folder'].startswith(str(meta.cache_root())))
+ ids = [dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(d['url']).query)).get('id')
+ for d in fake.downloads() if 'securecdn' in d['url']]
+ self.assertEqual(ids, ['9001', '9002']) # never the optional DLC, nor the non-OBB asset
+
+ def test_declared_size_over_the_limit_refused_before_downloading(self):
+ fake = connected(self)
+ token, generation = meta._session()
+ work = meta.cache_root() / 'limits'
+ work.mkdir(parents=True, exist_ok=True)
+ self.addCleanup(lambda: __import__('shutil').rmtree(work, ignore_errors=True))
+ before = len(fake.requests)
+ with self.assertRaisesRegex(meta.MetaError, 'more than Frame Control downloads'):
+ meta.fetch('9002', work / 'a.obb', 100, token, generation, expected=1000)
+ self.assertEqual(len(fake.requests), before)
+ fake.entitlements['data']['viewer']['user']['active_entitlements']['nodes'][0]['item'][
+ 'latest_supported_binary']['obb_binary']['size'] = meta.MAX_TOTAL + 1
+ meta.library(refresh=True)
+ with patch.object(meta.shutil, 'disk_usage', return_value=type('U', (), {'free': 10**15})()):
+ with self.assertRaisesRegex(meta.MetaError, 'more than Frame Control downloads'):
+ meta.download('111')
+ with patch.object(meta.shutil, 'disk_usage', return_value=type('U', (), {'free': 10})()):
+ with self.assertRaisesRegex(meta.MetaError, 'GB free on this computer'):
+ meta.download('111')
+
+ def test_declared_and_actual_sizes(self):
+ fake = connected(self)
+ token, generation = meta._session()
+ work = meta.cache_root() / 'sizes'
+ work.mkdir(parents=True, exist_ok=True)
+ self.addCleanup(lambda: __import__('shutil').rmtree(work, ignore_errors=True))
+ with self.assertRaisesRegex(meta.MetaError, 'not the size'):
+ meta.fetch('9002', work / 'a.obb', 50, token, generation) # Content-Length over the limit
+ fake.cdn_headers = {'Content-Length': None} # no length given: the limit stops the stream
+ with self.assertRaisesRegex(meta.MetaError, 'larger than'):
+ meta.fetch('9002', work / 'a.obb', 50, token, generation)
+ fake.cdn_headers = {'Content-Length': '5'}
+ with self.assertRaisesRegex(meta.MetaError, 'not the size Meta said'):
+ meta.fetch('9002', work / 'b.obb', 10**6, token, generation, expected=len(OBB))
+ fake.cdn_headers = {'Content-Length': None}
+ with self.assertRaisesRegex(meta.MetaError, 'incomplete'):
+ meta.fetch('9003', work / 'c.obb', 10**6, token, generation, expected=10)
+ with self.assertRaisesRegex(meta.MetaError, 'checksum'):
+ meta.fetch('9003', work / 'd.obb', 10**6, token, generation, expected=3, sha256='0' * 64)
+ self.assertEqual(list(work.iterdir()), []) # nothing left behind, not even .part files
+
+ def test_disconnect_stops_a_download(self):
+ connected(self)
+ token, generation = meta._session()
+ work = meta.cache_root() / 'stop'
+ work.mkdir(parents=True, exist_ok=True)
+ self.addCleanup(lambda: __import__('shutil').rmtree(work, ignore_errors=True))
+ meta.disconnect()
+ with self.assertRaisesRegex(meta.MetaError, 'disconnected'):
+ meta.fetch('9002', work / 'a.obb', 10**6, token, generation)
+ self.assertEqual(list(work.iterdir()), [])
+
+ def test_one_budget_for_every_file_including_unknown_sizes(self):
+ # Sizes Meta doesn't give count as they arrive: three files of unknown size that each fit
+ # the per-file limit still can't add up past the install's budget.
+ fake = connected(self)
+ node = fake.entitlements['data']['viewer']['user']['active_entitlements']['nodes'][0]['item'][
+ 'latest_supported_binary']
+ node.pop('size')
+ node['obb_binary'].pop('size')
+ node['obb_binary']['file_name'] = f'main.100.{PKG}.obb'
+ node['asset_files']['nodes'][0].update(file_name=f'patch.100.{PKG}.obb')
+ node['asset_files']['nodes'][0].pop('size')
+ fake.files['9003'] = OBB
+ fake.cdn_headers = {'Content-Length': None}
+ meta.library(refresh=True)
+ budget = len(APK) + len(OBB) + len(OBB) // 2 # the second OBB overflows half way
+ with patch.object(meta, 'MAX_TOTAL', budget):
+ with self.assertRaisesRegex(meta.MetaError, 'past the'):
+ meta.download('111')
+ self.assertEqual([p for p in meta.cache_root().iterdir() if p.name.startswith(PKG)], []) # all removed
+ with patch.object(meta, 'MAX_TOTAL', len(APK) + 2 * len(OBB)):
+ r = meta.download('111') # exactly enough
+ self.addCleanup(lambda: __import__('shutil').rmtree(r['folder'], ignore_errors=True))
+ self.assertEqual(len(r['obb']), 2)
+
+ def test_not_an_apk(self):
+ connected(self, apk=b'sign in')
+ with self.assertRaisesRegex(meta.MetaError, 'not an APK'):
+ meta.install('111')
+ self.assertEqual(list(meta.cache_root().iterdir()) if meta.cache_root().is_dir() else [], [])
+
+
+class Install(unittest.TestCase):
+ """The handoff to Import from my Quest's checks, install and two-step game data."""
+ META = dict(test_quest.Import.META)
+
+ def setUp(self):
+ self.order, self.running, self.installed = [], False, None
+
+ def install(path, source=None, **kw):
+ self.order.append(('install', Path(path).name, source))
+ self.assertTrue(android._install_lock._is_owned())
+ self.assertEqual(Path(path).read_bytes()[:2], b'PK')
+ return dict(self.META)
+
+ def add_data(pkg, checked, obb, folder, report, warnings=(), source=''):
+ self.order.append(('data', [Path(p).name for p in obb], [Path(p).read_bytes() for p in obb]))
+ self.running = False
+ return {'message': 'Added the game data.', 'obb': [{'name': Path(p).name} for p in obb], 'warnings': list(warnings),
+ 'app': dict(self.META), 'needs_game_data': False, 'closed': True, 'split': False}
+
+ def launch(pkg):
+ self.order.append(('launch',))
+ self.running = True
+
+ for target, name, effect in ((android, 'install', install), (android, 'launch', launch),
+ (android, 'read_meta', lambda pkg: self.installed),
+ (quest, '_running', lambda pkg: self.running),
+ (quest, '_sleep', lambda s: None),
+ (quest, 'add_game_data', add_data)):
+ p = patch.object(target, name, side_effect=effect)
+ p.start()
+ self.addCleanup(p.stop)
+
+ def cache_left(self):
+ root = meta.cache_root()
+ return [p for p in root.iterdir() if p.name.startswith(PKG)] if root.is_dir() else []
+
+ def test_install_then_add_game_data_on_the_second_install(self):
+ connected(self)
+ result = meta.install('111')
+ self.assertEqual(self.order, [('install', 'base.apk', 'Meta Horizon Store')])
+ self.assertTrue(result['needs_game_data'])
+ self.assertIn('choose Install again', result['message'])
+ self.assertIn('level1.pak', result['message']) # not copied, and said so
+ self.assertEqual(result['source'], 'meta-store')
+ self.assertEqual(self.cache_left(), []) # nothing kept after the install
+ # Second Install with the game open: the OBB, under its standard name, then closed.
+ self.running, self.installed = True, dict(self.META)
+ result = meta.install('111')
+ self.assertEqual(self.order[-1], ('data', [f'main.100.{PKG}.obb'], [OBB]))
+ self.assertEqual(self.cache_left(), [])
+
+ def test_install_and_test_adds_the_data_itself_then_tests(self):
+ connected(self)
+ with patch.object(meta, 'run_test', side_effect=lambda pkg, **kw: self.order.append(('test', pkg)) or
+ {'message': 'Test: Quest Game runs.', 'outcome': 'runs'}):
+ result = meta.install('111', test=True)
+ self.assertEqual([o[0] for o in self.order], ['install', 'launch', 'data', 'test'])
+ self.assertFalse(result['needs_game_data'])
+ self.assertEqual(result['test']['outcome'], 'runs')
+ self.assertIn('Test: Quest Game runs.', result['message'])
+
+ def test_licence_check_installs_with_the_warning(self):
+ apk = test_quest.apk_bytes(files=dict(test_quest.OPENXR, **{'lib/arm64-v8a/libovrplatformloader.so': b''}),
+ version_code=100, version_name='1.0')
+ connected(self, apk=apk)
+ result = meta.install('111')
+ self.assertEqual(self.order[0][0], 'install')
+ self.assertTrue(result['platform_sdk'])
+ self.assertIn("Uses Meta's licence check", result['message'])
+ self.assertIn('may quit, hang, or run without online features', result['message'])
+
+ def test_still_refused_32_bit_and_newer_android(self):
+ connected(self, apk=test_quest.apk_bytes(files={'lib/armeabi-v7a/libgame.so': b''}, version_code=100))
+ with self.assertRaisesRegex(quest.QuestError, '32-bit'):
+ meta.install('111')
+ meta.disconnect()
+ connected(self)
+ real = android.apk_info
+ with patch.object(android, 'apk_info', side_effect=lambda p: dict(real(p), min_sdk=31)):
+ with self.assertRaisesRegex(android.FrameError, 'API 31'):
+ meta.install('111')
+ self.assertEqual(self.order, [])
+ self.assertEqual(self.cache_left(), [])
+
+ def test_version_mismatch_and_one_at_a_time(self):
+ connected(self, apk=test_quest.apk_bytes(files=test_quest.OPENXR, version_code=99))
+ with self.assertRaisesRegex(meta.MetaError, 'version code 100, but the APK it sent is 99'):
+ meta.install('111')
+ with meta._install_lock:
+ with self.assertRaisesRegex(meta.MetaError, 'Another My Meta library install'):
+ meta.install('111')
+
+
+LOGS = {
+ 'runs': 'I FrameXrCompat: created instance\nI Unity: XR_SESSION_STATE_FOCUSED\n',
+ 'issues': 'I Unity: XR_SESSION_STATE_FOCUSED\nE Adreno: GL_INVALID_OPERATION in glDrawArrays\n',
+ 'licence': 'I Unity: ovr_Entitlement_GetIsViewerEntitled\nE Unity: Entitlement check failed, quitting\n',
+ 'crash': ('E AndroidRuntime: FATAL EXCEPTION: main\n'
+ 'E AndroidRuntime: java.lang.NullPointerException: ClipboardManager.addPrimaryClipChangedListener\n'),
+ 'native': 'F libc: Fatal signal 11 (SIGSEGV), code 1 in tid 4242 (UnityMain)\n',
+ 'anr': 'E ActivityManager: ANR in com.example.questgame\n',
+ 'extension': 'E OpenXR: xrCreateInstance: XR_ERROR_EXTENSION_NOT_PRESENT XR_FB_passthrough\n',
+ 'device': 'E Unity: Failed to launch SystemActivities\n',
+ 'gpu-crash': 'E zink: DEVICE LOST\nF libc: Fatal signal 6 (SIGABRT) in libgallium_dri.so\n',
+ 'java': ('E AndroidRuntime: FATAL EXCEPTION: main\n'
+ f'E AndroidRuntime: java.lang.IllegalStateException: bad session {TOKEN} for user 1234\n'),
+ 'licence-ok': ('I Unity: ovr_Entitlement_GetIsViewerEntitled called\n'
+ 'I Unity: IsUserEntitledToApplication: true\nI Unity: entitlement check passed\n'),
+}
+LOGS['extension-session'] = LOGS['extension'] + 'I Unity: XR_SESSION_STATE_FOCUSED\n'
+LOGS['licence-ok-session'] = LOGS['licence-ok'] + LOGS['runs']
+
+
+class Classify(unittest.TestCase):
+ def test_outcomes_from_logs(self):
+ cases = [
+ ('runs', True, 'runs', 'runs', '', ''),
+ ('issues', True, 'runs-with-issues', 'runs', 'Graphics errors', ''),
+ ('licence', False, 'quits', 'crashes', "Meta's licence check failed", ''),
+ # Open with a failed check but no sign of its VR scene: not enough to call it stuck.
+ ('licence', True, 'inconclusive', None, "isn't recorded", ''),
+ ('crash', False, 'crashes', 'crashes', 'clipboard service', ''),
+ ('native', False, 'crashes', 'crashes', 'native crash signal', 'SIGSEGV'),
+ ('java', False, 'crashes', 'crashes', 'Java exception', 'java.lang.IllegalStateException'),
+ ('anr', True, 'hangs', 'crashes', 'stopped responding', ''),
+ ('extension', True, 'inconclusive', None, 'OpenXR feature', 'XR_FB_passthrough'),
+ ('extension-session', True, 'runs-with-issues', 'runs', 'OpenXR feature', 'XR_FB_passthrough'),
+ ('device', False, 'quits', 'crashes', 'Meta Quest', ''),
+ ('gpu-crash', False, 'crashes', 'crashes', 'Graphics errors', ''),
+ ('licence-ok', True, 'inconclusive', None, "can't tell whether it runs", ''),
+ ('licence-ok-session', True, 'runs', 'runs', '', ''),
+ ]
+ for log, alive, outcome, result, cause, detail in cases:
+ v = meta.classify(LOGS[log], alive)
+ self.assertEqual((v['outcome'], v['result']), (outcome, result), log)
+ self.assertIn(cause, v['cause'], log)
+ self.assertEqual(v['detail'], detail, log)
+ # Causes are fixed texts, never log text.
+ self.assertTrue(v['cause'] == '' or v['cause'].startswith(tuple(meta.CAUSES.values())), log)
+ self.assertNotIn('licence', meta.classify(LOGS['licence-ok-session'], True)['signals']['matched'])
+ quiet = meta.classify('I Unity: loading\n', True)
+ self.assertEqual((quiet['outcome'], quiet['result']), ('inconclusive', None))
+ gone = meta.classify('', False, instance_up=False)
+ self.assertEqual(gone['outcome'], 'quits')
+ self.assertIn('stopped before its log could be read', gone['cause'])
+ never = meta.classify('', False, instance_up=False, started=False)
+ self.assertEqual((never['outcome'], never['result']), ('instance-failed', 'instance_failed'))
+ self.assertTrue(meta.classify(LOGS['runs'], True)['signals']['xr_session'])
+ self.assertEqual(meta.classify(LOGS['licence'], False)['signals']['matched'], ['licence'])
+
+
+class Speed(unittest.TestCase):
+ """A hostile or broken log can't make classifying or redacting slow: 100 KB adversarial
+ lines, each step well under 200 ms."""
+ LINES = {
+ 'no .so': 'a' * 100000,
+ 'dots': 'a.' * 50000,
+ 'dotted names': ('a.' * 40 + ' ') * 1235,
+ 'OC-': 'OC-' * 33334,
+ 'OC run': ' OC' + 'a-' * 50000,
+ 'zink': 'zink' * 25000,
+ 'entitle': 'entitle' * 14286,
+ 'entitlement calls': 'ovr_Entitlement' * 6667,
+ 'platform': 'libovrplatform.so' * 5883,
+ 'crash marker': '>>> ' + 'a' * 100000,
+ 'access_token': 'access_token=' * 7693,
+ 'percent': '%4f%43' * 16667,
+ 'words': ('libx.so XR_FB_y java.lang.ZException SIGSEGV ' * 2300)[:100000],
+ }
+
+ def timed(self, fn, *args):
+ import time
+ start = time.perf_counter()
+ fn(*args)
+ return time.perf_counter() - start
+
+ def test_adversarial_lines(self):
+ for name, line in self.LINES.items():
+ self.assertTrue(99000 <= len(line) <= 101000, name)
+ for alive in (True, False):
+ self.assertLess(self.timed(meta.classify, line + '\nFATAL EXCEPTION\n', alive), 0.2, name)
+ self.assertLess(self.timed(meta.redact, line, None, TOKEN), 0.2, name)
+ for key in ('extension', 'library', 'crash-java', 'crash-native'):
+ self.assertLess(self.timed(meta._excerpt, key, line), 0.2, (name, key))
+
+ def test_names_are_still_found(self):
+ self.assertEqual(meta._excerpt('library', 'E linker: dlopen failed: library "libfoo.so" not found'), 'libfoo.so')
+ self.assertEqual(meta._excerpt('extension', 'XR_ERROR_EXTENSION_NOT_PRESENT: XR_FB_passthrough.'),
+ 'XR_FB_passthrough')
+ self.assertEqual(meta._excerpt('crash-java', 'AndroidRuntime: java.lang.IllegalStateException: x'),
+ 'java.lang.IllegalStateException')
+ self.assertEqual(meta._excerpt('crash-native', 'Fatal signal 11 (SIGSEGV)'), 'SIGSEGV')
+ self.assertEqual(meta.redact('error near ' + OTHER_TOKEN + ' here'), 'error near here')
+
+ def test_every_accepted_token_is_redacted(self):
+ # Property: whatever check_token accepts, the generic pattern redacts in full, wherever
+ # it appears, without knowing the stored token.
+ import random
+ rng = random.Random(20261013)
+ allowed = ''.join(chr(c) for c in range(33, 127)
+ if re.fullmatch('[%s]' % meta.TOKEN_CHARS, chr(c)))
+ self.assertEqual(set(allowed), set('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=_-'))
+ accepted = 0
+ for _ in range(3000):
+ prefix = rng.choice(('OC', 'FRL'))
+ body = ''.join(rng.choice(allowed) for _ in range(rng.randint(meta.TOKEN_MIN - len(prefix), 60)))
+ try:
+ token = meta.check_token(prefix + body)
+ except meta.MetaError:
+ continue
+ accepted += 1
+ for left, right in (('', ''), ('error near ', ' here'), ('library "', '.so" not found'),
+ ('url?tok=', '&x=1'), ('(', ')'), ('', '_FLAG'), ('access_token=', '')):
+ self.assertGone(token, left, right)
+ # Random neighbours from all of printable ASCII, on either side.
+ for _ in range(5):
+ self.assertGone(token, ''.join(rng.choice(PRINTABLE) for _ in range(rng.randint(1, 3))),
+ ''.join(rng.choice(PRINTABLE) for _ in range(rng.randint(1, 3))))
+ self.assertEqual(meta._excerpt('library', f'dlopen failed: library "{token}.so" not found'), '')
+ self.assertGreater(accepted, 2000)
+
+ def assertGone(self, token, left, right):
+ """redact(left + token + right) is some start of left, one , and some end of
+ right: every character of the token is gone, whatever surrounds it."""
+ out = meta.redact(left + token + right)
+ at = out.find(meta.REDACTED)
+ self.assertGreaterEqual(at, 0, (left, token, right))
+ before, after = out[:at], out[at + len(meta.REDACTED):]
+ self.assertTrue(left.startswith(before) and right.endswith(after), (left, token, right, out))
+
+ def test_round6_gaps(self):
+ # 1. All upper case is refused, so an "OC..._FLAG" constant can't be an accepted token.
+ for bad in ('OCABCDEFGHIJKLMNOPQR', 'OCABCDEFGHIJ_KLMNOPQRS', 'FRL0123456789ABCDEFGHIJ'):
+ with self.assertRaisesRegex(meta.MetaError, "doesn't look like a Meta token"):
+ meta.check_token(bad)
+ self.assertEqual(meta.redact('OCABCDEFGHIJKLMNOPQR_FLAG'), 'OCABCDEFGHIJKLMNOPQR_FLAG') # a name, kept
+ self.assertEqual(meta.redact('OCAbcdefGhijkLmnopQr_FLAG'), meta.REDACTED)
+ # 2. Found after any character, word characters included.
+ for left in ('https://example.invalid/_', 'x-', '=', '/', 'lib', '9', 'Z'):
+ self.assertEqual(meta.redact(left + 'OCAbcdefGhijkLmnopQr'), left + meta.REDACTED, left)
+ # 3. Whole-token redaction comes first, so no prefix survives a parameter match.
+ meta.check_token('OCaccess_token=123456')
+ self.assertEqual(meta.redact('OCaccess_token=123456'), meta.REDACTED)
+ self.assertEqual(meta.redact('x access_token=abc&y=1'), f'x access_token={meta.REDACTED}&y=1')
+ # Anything with other characters is refused at connect, with a clear message.
+ for bad in ('OCAbcdef.GhijkLmnopQrstUvwx', 'OCAbcdefGhijk~LmnopQrstUvwx', 'FRLAbcdef!GhijkLmnopQrstU',
+ 'OCAbcdefGhijk#LmnopQrstUvwx', 'OCABCDEFGHIJ_KLMNOPQRS_TUV'):
+ with self.assertRaisesRegex(meta.MetaError, "doesn't look like a Meta token"):
+ meta.check_token(bad)
+
+ def test_dotted_value_regression(self):
+ # Accepted before (dots allowed) but not redacted, so it showed as a library name.
+ line = 'E linker: dlopen failed: library "OCAbcdef.GhijkLmnopQrstUvwx.so" not found'
+ with self.assertRaises(meta.MetaError):
+ meta.check_token('OCAbcdef.GhijkLmnopQrstUvwx')
+ # It can't be a connected token now; as a stored token it would be redacted by _clean too.
+ self.assertNotIn('OCAbcdef', meta.redact(line, token='OCAbcdef.GhijkLmnopQrstUvwx'))
+
+ def test_digit_free_token_shapes_are_redacted(self):
+ # check_token accepts tokens without digits, so the generic pattern must not need one.
+ shape = 'OCAbcdefGhijkLmnopQrstUvwx'
+ meta.check_token(shape)
+ meta.check_token('FRLAbcdefGhijkLmnopQrs')
+ meta.check_token('OCAbcdefGhijkLmnopQr') # 20 characters: the shortest accepted
+ with self.assertRaisesRegex(meta.MetaError, 'incomplete'):
+ meta.check_token('OCAbcdefGhijkLmnopQ') # 19: refused, so never needs redacting
+ for text in (shape, 'FRLAbcdefGhijkLmnopQrs', 'OC-' + 'x' * 20, 'OCabcdefghijklmnopqr', 'OCAbcdefGhijkLmnopQr'):
+ out = meta.redact(f'error near {text} here')
+ self.assertEqual(out, 'error near here', text)
+ line = f'E linker: dlopen failed: library "{shape}.so" not found'
+ self.assertEqual(meta._excerpt('library', line), '')
+ v = meta.classify(line + '\n', False)
+ self.assertEqual((v['cause_key'], v['detail']), ('library', ''))
+ self.assertNotIn(shape[:10], json.dumps(v))
+ # Ordinary identifiers stay: upper-case constants, short words, package names, too-short runs.
+ for text in ('OCULUS_PLATFORM_SDK_VERSION_NAME', 'OCULUS_EXT_HAND_TRACKING_2', 'OCR', 'OCULUS',
+ 'com.oculus.systemactivities', 'OCAbcdefGhijkLmnopQ', 'libOculusPlatform.so'):
+ self.assertEqual(meta.redact(f'error near {text} here'), f'error near {text} here', text)
+ self.assertEqual(meta._excerpt('library', 'dlopen failed: library "libOculusPlatformLoader.so" not found'),
+ 'libOculusPlatformLoader.so')
+ long_line = 'x' * (meta.LINE_SCAN + 10) + ' Entitlement check failed'
+ self.assertNotIn('licence', meta.classify(long_line, False)['signals']['matched']) # only the start is scanned
+
+
+class RunTest(unittest.TestCase):
+ META = dict(test_quest.Import.META)
+
+ def setUp(self):
+ self.reports, self.alive, self.log, self.running = [], True, LOGS['runs'], False
+ ctr = f"lepton-steamlaunch-{self.META['instance']}"
+
+ def ssh(cmd, input=None, timeout=120):
+ if 'pidof' in cmd:
+ return '4242\n' if self.alive else ''
+ if 'logcat' in cmd:
+ return self.log
+ raise AssertionError(cmd)
+
+ def launch(pkg):
+ self.running = True
+ for target, name, effect in ((android, '_meta_or_fail', lambda pkg: dict(self.META)),
+ (android, 'launch', launch), (android, 'ssh', ssh),
+ (android, 'running_instances', lambda strict=False: {ctr: '5555'} if self.running else {}),
+ (quest, '_running', lambda pkg: self.running),
+ (quest, '_close', lambda pkg: (setattr(self, 'running', False), (True, None))[1]),
+ (quest, '_sleep', lambda s: None)):
+ p = patch.object(target, name, side_effect=effect)
+ p.start()
+ self.addCleanup(p.stop)
+ import frame_catalog
+ p = patch.object(frame_catalog, 'add_report', side_effect=lambda *a, **kw: self.reports.append((a, kw)) or
+ {'id': 'r1'})
+ p.start()
+ self.addCleanup(p.stop)
+
+ def test_runs_and_report_carries_results_only(self):
+ connected(self)
+ self.log = LOGS['runs'] + f'I Game: debug url https://graph.oculus.com/?access_token={TOKEN}\n'
+ v = meta.run_test(PKG, wait=20)
+ self.assertEqual(v['outcome'], 'runs')
+ self.assertEqual(v['signals']['seconds'], 20)
+ (args, kwargs), = self.reports
+ self.assertEqual(args, (PKG, '1.0'))
+ self.assertEqual(kwargs['source'], 'meta-store')
+ self.assertEqual(kwargs['result'], 'runs')
+ self.assertEqual(kwargs['via'], 'probe')
+ self.assertIn('version code 100', kwargs['notes'])
+ self.assertEqual(set(kwargs), {'result', 'notes', 'via', 'label', 'source'})
+ payload = json.dumps([args, kwargs])
+ self.assertNotIn(TOKEN, payload)
+ self.assertNotIn(str(meta.cache_root()), payload)
+ self.assertNotIn('.apk', payload)
+ self.assertNotIn('.obb', payload)
+
+ def test_quit_with_licence_check_is_reported_as_it_happened(self):
+ connected(self)
+ self.alive, self.log = False, LOGS['licence']
+ v = meta.run_test(PKG, wait=30)
+ self.assertEqual((v['outcome'], v['result']), ('quits', 'crashes'))
+ self.assertIn("Meta's licence check failed", v['message'])
+ self.assertEqual(self.reports[0][1]['result'], 'crashes')
+ self.assertIn('Meta library test: quits', self.reports[0][1]['notes'])
+
+ def test_instance_never_starts(self):
+ connected(self)
+ with patch.object(android, 'launch'): # nothing comes up
+ v = meta.run_test(PKG, wait=5)
+ self.assertEqual(v['outcome'], 'instance-failed')
+ self.assertEqual(self.reports[0][1]['result'], 'instance_failed')
+
+ def test_hang_is_closed_and_no_report_when_asked(self):
+ connected(self)
+ self.log = LOGS['anr']
+ v = meta.run_test(PKG, wait=5, report_result=False)
+ self.assertEqual(v['outcome'], 'hangs')
+ self.assertFalse(self.running)
+ self.assertEqual(self.reports, [])
+
+ def test_crash_text_never_reaches_reports_or_messages(self):
+ connected(self)
+ self.alive, self.log = False, LOGS['java']
+ v = meta.run_test(PKG, wait=10)
+ self.assertEqual((v['outcome'], v['cause_key']), ('crashes', 'crash-java'))
+ self.assertIn('(java.lang.IllegalStateException)', v['message'])
+ (args, kwargs), = self.reports
+ everything = json.dumps([v, args, kwargs])
+ for fragment in (TOKEN, TOKEN[:8], 'bad session', 'user 1234'):
+ self.assertNotIn(fragment, everything)
+ self.assertNotIn('IllegalStateException', kwargs['notes']) # reports: fixed texts only
+ self.assertIn(meta.CAUSES['crash-java'].rstrip('.'), kwargs['notes'])
+
+ def test_long_token_in_a_library_name_is_never_shown(self):
+ connected(self)
+ long_token = TOKEN + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0U1v2W3x4Y5z6'
+ meta.disconnect()
+ fake = FakeMeta(self, token=long_token)
+ meta.connect(long_token)
+ self.alive = False
+ for line in (f'E linker: dlopen failed: library "{long_token}.so" not found',
+ f'E linker: dlopen failed: library "lib{long_token[-40:]}.so" not found',
+ 'E linker: dlopen failed: library "libZq9Xw3Rt7Yu1Io5Pa8Sd2Fg6.so" not found'):
+ self.log = line + '\n'
+ self.reports.clear()
+ v = meta.run_test(PKG, wait=5)
+ self.assertEqual(v['cause_key'], 'library')
+ self.assertEqual(v['detail'], '', line)
+ everything = json.dumps([v, self.reports])
+ for fragment in (long_token[-30:], long_token[-60:-30], long_token[:8], 'Zq9Xw3Rt7Yu1Io5Pa8Sd2Fg6'):
+ self.assertNotIn(fragment, everything)
+ self.log = 'E linker: dlopen failed: library "libfoo_engine.so" not found\n'
+ self.assertEqual(meta.run_test(PKG, wait=5)['detail'], 'libfoo_engine.so') # real names still shown
+ self.assertEqual(fake.token, long_token)
+
+ def test_log_read_failure_never_shows_the_ssh_output(self):
+ # A failed ssh command with empty stderr: frame_android.ssh raises its stdout, which
+ # here is the game's log, carrying a token. Neither the test, the CLI nor the job says it.
+ import subprocess
+ import server
+ connected(self)
+ leaked = f'I Game: url https://graph.oculus.com/?x=1&tok={TOKEN[5:]} {OTHER_TOKEN[3:]}\n' * 3
+
+ def run_ssh(cmd, **kw):
+ remote = cmd[-1]
+ if 'logcat' in remote:
+ return subprocess.CompletedProcess(cmd, 1, stdout=leaked, stderr='')
+ return subprocess.CompletedProcess(cmd, 0, stdout='4242\n' if 'pidof' in remote else '', stderr='')
+ with patch.object(android, 'ssh', REAL_SSH), patch.object(android.frame_host, 'run_ssh', side_effect=run_ssh):
+ with self.assertRaises(meta.MetaError) as e:
+ meta.run_test(PKG, wait=5)
+ self.assertEqual(str(e.exception), meta.LOG_UNREADABLE)
+ self.assertIsNone(e.exception.__cause__)
+ self.assertTrue(e.exception.__suppress_context__)
+ # The standalone Test route (a background job).
+ with patch.object(server, 'ensure_master'), patch.object(server, 'start_job',
+ return_value={'job': 'j'}) as job:
+ server.meta({'action': 'test', 'package': PKG})
+ with self.assertRaises(meta.MetaError) as e:
+ job.call_args.args[1]()
+ self.assertEqual(str(e.exception), meta.LOG_UNREADABLE)
+ # The CLI.
+ with patch.object(sys, 'argv', ['frame_meta.py', 'test', PKG, '--wait', '5']):
+ with self.assertRaises(SystemExit) as cli:
+ meta.main()
+ self.assertEqual(cli.exception.code, 'error: ' + meta.LOG_UNREADABLE)
+ for fragment in (TOKEN[5:20], OTHER_TOKEN[3:15]):
+ self.assertNotIn(fragment, str(e.exception) + str(cli.exception.code))
+ self.assertEqual(self.reports, [])
+ # Anything else that goes wrong in a test job is a fixed text too.
+ with patch.object(server, 'ensure_master'), patch.object(server, 'start_job', return_value={'job': 'j'}) as job:
+ server.meta({'action': 'test', 'package': PKG})
+ with patch.object(meta, 'run_test', side_effect=KeyError(TOKEN)):
+ with self.assertRaises(meta.MetaError) as e:
+ job.call_args.args[1]()
+ self.assertEqual(str(e.exception), 'The test stopped unexpectedly (KeyError); try again.')
+
+ def test_inconclusive_is_not_reported_or_closed(self):
+ connected(self)
+ self.log = 'I Unity: ovr_Entitlement_GetIsViewerEntitled called\nI Unity: loading\n'
+ v = meta.run_test(PKG, wait=10)
+ self.assertEqual((v['outcome'], v['result']), ('inconclusive', None))
+ self.assertEqual(self.reports, [])
+ self.assertIn('skipped', v['report'])
+ self.assertTrue(self.running) # left open: nothing says it is stuck
+
+ def test_must_close_a_running_game_before_testing(self):
+ connected(self)
+ self.running = True
+ launched = []
+ with patch.object(quest, '_close', return_value=(False, 'it is still running')), \
+ patch.object(android, 'launch', side_effect=launched.append):
+ with self.assertRaisesRegex(meta.MetaError, "couldn't close it, so it wasn't tested.*Close it from Steam"):
+ meta.run_test(PKG, wait=5)
+ self.assertEqual(launched, [])
+ self.assertEqual(self.reports, [])
+
+ def test_failed_hang_cleanup_is_said(self):
+ connected(self)
+ self.log = LOGS['anr']
+ with patch.object(quest, '_close', side_effect=[(False, 'Steam is not answering')]):
+ v = meta.run_test(PKG, wait=5)
+ self.assertEqual(v['outcome'], 'hangs')
+ self.assertFalse(v['closed'])
+ self.assertIn('the game may still be running; close it from Steam', v['message'])
+ self.assertEqual(self.reports[0][1]['result'], 'crashes')
+
+ def test_sharing_follows_the_privacy_setting(self):
+ sent = []
+ with patch.object(frame_telemetry, 'enabled', return_value=True), \
+ patch.object(frame_telemetry, 'capture', side_effect=lambda e, p, level: sent.append(p) or True):
+ frame_telemetry.compat_report({'id': 'x', 'package': PKG, 'result': 'runs', 'source': 'meta-store',
+ 'notes': f'access_token={TOKEN}'})
+ self.assertEqual(sent[0]['source'], 'meta-store')
+ with patch.object(frame_telemetry, 'enabled', return_value=False), \
+ patch.object(frame_telemetry, 'capture') as capture:
+ self.assertFalse(frame_telemetry.compat_report({'id': 'y', 'package': PKG, 'result': 'runs'}))
+ capture.assert_not_called()
+
+
+class Server(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ import server
+ cls.server = server
+
+ def test_requests_and_answers_never_carry_the_token(self):
+ s = self.server
+ FakeMeta(self)
+ self.addCleanup(meta.disconnect)
+ for body in ({'action': 'connect', 'token': TOKEN}, {'action': 'connect', 'acknowledged': True},
+ {'action': 'install', 'app': 'abc'}, {'action': 'install', 'app': '111', 'test': 'yes'},
+ {'action': 'test', 'package': '../x'}, {'action': 'nope'}):
+ with self.assertRaises(s.Failure) as e:
+ s.meta(body)
+ self.assertEqual(e.exception.status, 400, body)
+ result = s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True})
+ self.assertTrue(result['connected'])
+ status = s.meta_status('library=1')
+ self.assertEqual({a['id'] for a in status['apps']}, {'111', '222'})
+ self.assertIn('sharing', status)
+ self.assertNotIn(TOKEN, json.dumps([result, status]))
+ with patch.object(s, 'ensure_master'), patch.object(s, 'queue_job', return_value={'job': 'j'}) as job:
+ self.assertEqual(s.meta({'action': 'install', 'app': '111', 'test': True}), {'job': 'j'})
+ work = job.call_args.args[1]
+ with patch.object(meta, 'install', side_effect=KeyError(f'access_token={TOKEN}')):
+ with self.assertRaises(meta.MetaError) as e:
+ work(lambda *a: None)
+ self.assertNotIn(TOKEN, str(e.exception))
+ with self.assertRaises(s.Failure) as e:
+ s.meta({'action': 'connect', 'token': OTHER_TOKEN, 'acknowledged': True})
+ self.assertNotIn(OTHER_TOKEN, str(e.exception))
+ self.assertEqual(e.exception.status, 400)
+ self.assertFalse(s.meta({'action': 'disconnect'})['connected'])
+
+ def test_sign_in_attempts_through_the_server(self):
+ s = self.server
+ FakeMeta(self)
+ self.addCleanup(meta.disconnect)
+ for body in ({'action': 'cancel-signin'}, {'action': 'cancel-signin', 'attempt': 'no'},
+ {'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 5},
+ {'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': '../../x'}):
+ with self.assertRaises(s.Failure) as e:
+ s.meta(body)
+ self.assertEqual(e.exception.status, 400, body)
+ self.assertTrue(s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True,
+ 'attempt': 'server-attempt-0001'})['connected'])
+ result = s.meta({'action': 'cancel-signin', 'attempt': 'server-attempt-0001'})
+ self.assertFalse(result['connected'])
+ self.assertNotIn(TOKEN, json.dumps(result))
+ # A cancel caught while Meta answers is a 409, without the token.
+ with patch.object(meta, 'whoami', side_effect=lambda t: meta.cancel_signin('server-attempt-0002') or
+ {'id': '1', 'name': 'player_one'}):
+ with self.assertRaises(s.Failure) as e:
+ s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 'server-attempt-0002'})
+ self.assertEqual(e.exception.status, 409)
+ self.assertNotIn(TOKEN, str(e.exception))
+ self.assertFalse(meta.status()['connected'])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_page_startup.py b/tests/test_page_startup.py
index 69bd5c1..62de28b 100644
--- a/tests/test_page_startup.py
+++ b/tests/test_page_startup.py
@@ -9,7 +9,7 @@ import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
-STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles']
+STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab']
RUN = r'''
const vm = require('vm');
@@ -43,7 +43,7 @@ class PageStartup(unittest.TestCase):
for n in STUBS if n != '$')
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
- hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#nowhere']
+ hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#control', '#nowhere']
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
capture_output=True, text=True)
diff --git a/tests/test_panels.py b/tests/test_panels.py
index 6cfb241..650bd4b 100644
--- a/tests/test_panels.py
+++ b/tests/test_panels.py
@@ -1,4 +1,5 @@
"""Fake-Frame panel responses and the headset page's real HTTP guards."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
diff --git a/tests/test_publish_release.py b/tests/test_publish_release.py
new file mode 100644
index 0000000..07baece
--- /dev/null
+++ b/tests/test_publish_release.py
@@ -0,0 +1,110 @@
+"""scripts/publish-release.sh against a stand-in gh (tests/fakegh/gh): finds the draft
+through the REST API even when its tag_name still says untagged-..., refuses
+missing installers or digests, writes update.json's page from the tag, and
+publishes with one PATCH. Nothing here talks to GitHub.
+
+Run: python3 -m unittest discover -s tests
+"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import json
+import os
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+SCRIPT = ROOT / "scripts" / "publish-release.sh"
+FAKEGH = ROOT / "tests" / "fakegh"
+INSTALLERS = ["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"]
+
+
+def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7", rid=42, assets=None):
+ if assets is None:
+ assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i}
+ for i, n in enumerate(INSTALLERS)]
+ return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False,
+ "body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name,
+ "assets": assets}
+
+
+class PublishRelease(unittest.TestCase):
+ def run_script(self, releases, *args, tags="v9.8.7"):
+ d = Path(tempfile.mkdtemp())
+ (d / "releases.json").write_text(json.dumps(releases))
+ env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
+ FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
+ FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
+ p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
+ log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
+ upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
+ return p, log, upload
+
+ def test_publishes_an_untagged_draft_by_title_with_the_tag_page(self):
+ old = {"id": 7, "name": "update.json", "size": 1, "digest": None}
+ rel = draft(assets=draft()["assets"] + [old])
+ p, log, upload = self.run_script([rel], "v9.8.7")
+ self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
+ self.assertEqual(upload["page"], "https://github.com/saphid/frame-control/releases/tag/v9.8.7")
+ self.assertEqual(upload["version"], "9.8.7")
+ self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
+ self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
+ patch = next(c for c in log if "PATCH" in c)
+ self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
+ for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
+ self.assertIn(f, patch)
+ self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
+
+ def test_an_untagged_draft_may_carry_a_subtitle(self):
+ p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
+ self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
+ self.assertEqual(upload["version"], "9.8.7")
+
+ def test_prefers_the_release_whose_tag_name_matches(self):
+ p, log, upload = self.run_script([draft(name="Frame Control 9.8.7 old", rid=1),
+ draft(tag_name="v9.8.7", name="Renamed", rid=2)], "v9.8.7")
+ self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
+ self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2")
+
+ def test_refuses_missing_or_unhashed_installers(self):
+ assets = draft()["assets"][1:]
+ assets[0] = dict(assets[0], digest=None)
+ p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7")
+ self.assertNotEqual(p.returncode, 0)
+ self.assertIn("MISSING Frame-Control-mac-arm64.dmg", p.stdout)
+ self.assertIn("NO HASH Frame-Control-mac-arm64.zip", p.stdout)
+ self.assertIsNone(upload)
+ self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log))
+
+ def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self):
+ p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7")
+ self.assertNotEqual(p.returncode, 0)
+ self.assertIn("2 releases", p.stderr)
+ p, _, _ = self.run_script([draft(name="Frame Control 9.8.70")], "v9.8.7")
+ self.assertNotEqual(p.returncode, 0)
+ self.assertIn("no release", p.stderr)
+ # A pre-release's draft, or one bound to an unrelated tag, is never taken for v9.8.7.
+ for rel in (draft(name="Frame Control 9.8.7-rc.1"), draft(name="Frame Control 9.8.7.1"),
+ draft(tag_name="kdeconnect-frame-1"), draft(tag_name="")):
+ p, log, upload = self.run_script([rel], "v9.8.7")
+ self.assertNotEqual(p.returncode, 0, rel)
+ self.assertIn("no release", p.stderr)
+ self.assertIsNone(upload)
+ self.assertFalse(any("PATCH" in c for c in log))
+ p, log, _ = self.run_script([draft()], "v9.8.7", tags="")
+ self.assertNotEqual(p.returncode, 0)
+ self.assertIn("push it first", p.stderr)
+
+ def test_dry_run_changes_nothing(self):
+ p, log, upload = self.run_script([draft()], "--dry-run", "v9.8.7")
+ self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
+ self.assertIn('"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7"', p.stdout)
+ self.assertIsNone(upload)
+ self.assertTrue(all("-X" not in c for c in log))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_quest.py b/tests/test_quest.py
new file mode 100644
index 0000000..ff24bf3
--- /dev/null
+++ b/tests/test_quest.py
@@ -0,0 +1,1144 @@
+"""Import from my Quest (ui/frame_quest.py) against a fake adb (tests/fakeadb.py), and
+split APK merging in the shared install path (frame_android.install(splits=...))."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import io
+import json
+import os
+from pathlib import Path
+from types import SimpleNamespace
+import struct
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+import zipfile
+import zlib
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'ui'))
+import frame_android as android # noqa: E402
+import frame_apk # noqa: E402
+import frame_apk_sign # noqa: E402
+import frame_apk_vr # noqa: E402
+import frame_quest as quest # noqa: E402
+from apk_fixtures import pool # noqa: E402
+
+FAKEADB = str(Path(__file__).resolve().parent / 'fakeadb.py')
+PKG = 'com.example.questgame'
+QUEST = {'serial': '1WMHH000ABCDEF', 'state': 'device', 'extra': 'usb:1-1 product:eureka model:Quest_3 transport_id:2',
+ 'props': {'ro.product.manufacturer': 'Oculus', 'ro.product.model': 'Quest 3'}}
+TUNNEL = {'serial': '127.0.0.1:40123', 'state': 'device', 'extra': 'product:lepton model:Lepton transport_id:1',
+ 'props': {'ro.product.manufacturer': 'Valve', 'ro.product.model': 'Lepton'}}
+PHONE = {'serial': 'R58M000PHONE', 'state': 'device', 'extra': 'usb:1-2 model:SM_G990 transport_id:3',
+ 'props': {'ro.product.manufacturer': 'samsung', 'ro.product.model': 'SM-G990'}}
+
+
+def axml(package, split=None, split_required=False, resmap=False, feature=False, children=(), version_code=None,
+ version_name=None):
+ """A minimal binary AndroidManifest.xml: children.
+
+ resmap: name isSplitRequired by its android resource id (as real builds do), not only by name."""
+ strings = ['isSplitRequired', 'package', 'split', 'manifest', 'application', 'label', package,
+ split or '', 'Quest Game', 'isFeatureSplit', 'versionCode', 'versionName', version_name or '',
+ *children]
+ none = 0xffffffff
+
+ def start(tag, attrs):
+ body = struct.pack('')
+ self.refused(apk_bytes(split='config.arm64_v8a', children=('uses-library',), files=OPENXR), '')
+ self.refused(apk_bytes(split='feature_vr', feature=True, files=OPENXR), 'feature split')
+ self.refused(apk_bytes(split='config.arm64_v8a', files={'kotlin/x.kotlin_builtins': b''}), 'contains kotlin/')
+ self.refused(apk_bytes(split='config.arm64_v8a', files={'lib/arm64-v8a/sub/libx.so': b''}), 'contains lib/')
+
+ def test_resource_tables(self):
+ from apk_fixtures import resources
+ self.refused(apk_bytes(split='config.en', files={'resources.arsc': resources({(1, 'en', 0): {0: 1}})}),
+ 'density or language')
+ base, empty = self.files(apk_bytes(split_required=True),
+ apk_bytes(split='config.arm64_v8a', files=dict(OPENXR, **{'resources.arsc': resources({})})))
+ self.assertEqual(set(android.split_entries(base, [empty])), set(OPENXR))
+
+ def test_zip_bomb_refused_by_headers_and_by_reads(self):
+ bomb = {'assets/bomb.bin': b'\0' * (8 * 1024**2)}
+ split = apk_bytes(split='install_time', files=bomb, deflate=True)
+ self.assertLess(len(split), 64 * 1024) # tiny on disk
+ base, part = self.files(apk_bytes(split_required=True), split)
+ with patch.object(android, 'MAX_SPLIT_MEMBER', 1024**2):
+ with self.assertRaisesRegex(android.FrameError, 'more than Frame Control merges'):
+ android.split_entries(base, [part])
+ with patch.object(android, 'MAX_SPLIT_TOTAL', 4 * 1024**2):
+ with self.assertRaisesRegex(android.FrameError, 'more than 0 GB|more than Frame Control merges'):
+ android.split_entries(base, [part])
+ # Headers that understate the size: the read stops at what they promise, and the
+ # truncated member fails its checksum instead of filling memory.
+ base, forged = self.files(apk_bytes(split_required=True), lie_about_size(split, 'assets/bomb.bin', 1000))
+ self.assertEqual(android.split_size([forged]), 1000)
+ with patch.object(android, 'repack') as repack:
+ with self.assertRaises(android.FrameError):
+ android.merge_splits(base, [forged], os.path.join(os.path.dirname(base), 'out.apk'))
+ repack.assert_not_called()
+
+
+class SplitForgeries(unittest.TestCase):
+ """Members whose headers understate their size, with a checksum that matches the prefix."""
+
+ def merge(self, split):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ base, part = os.path.join(tmp.name, 'base.apk'), os.path.join(tmp.name, 'split.apk')
+ with open(base, 'wb') as f:
+ f.write(apk_bytes(split_required=True))
+ with open(part, 'wb') as f:
+ f.write(split)
+ with patch.object(android, 'repack') as repack:
+ with self.assertRaisesRegex(android.FrameError, 'does not match its header'):
+ android.merge_splits(base, [part], os.path.join(tmp.name, 'out.apk'))
+ repack.assert_not_called()
+
+ def test_stored_and_deflated_truncation_refused(self):
+ content = bytes(range(256)) * 64 # 16 KB
+ prefix = zlib.crc32(content[:1000])
+ for deflate in (False, True):
+ split = apk_bytes(split='install_time', deflate=deflate, files={'assets/level.pak': content})
+ forged = lie_about_size(split, 'assets/level.pak', 1000, crc=prefix)
+ # Python's own reader is fooled: it stops at the declared size and the prefix checksum matches.
+ with zipfile.ZipFile(io.BytesIO(forged)) as z:
+ self.assertEqual(z.read('assets/level.pak'), content[:1000])
+ self.merge(forged)
+
+ def test_honest_members_still_merge(self):
+ content = bytes(range(256)) * 64
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ for deflate in (False, True):
+ part = os.path.join(tmp.name, f'split{deflate}.apk')
+ with open(part, 'wb') as f:
+ f.write(apk_bytes(split='install_time', deflate=deflate, files={'assets/level.pak': content}))
+ self.assertEqual(android.read_member(part, 'assets/level.pak'), content)
+
+ def test_bag_only_resource_table_is_not_empty(self):
+ from apk_fixtures import resources
+ simple = resources({(1, '', 0): {0: 1}})
+ entry = struct.pack(' {payload}")
+ def test_showing_a_shot_needs_it_saved_here(self):
+ status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
+ self.assertEqual(status, 404, payload)
+
def test_screenshot_ids_checked_before_ssh(self):
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
@@ -279,6 +295,80 @@ class OneServer(unittest.TestCase):
r = conn.getresponse()
self.assertEqual(r.status, 403, r.read())
+ @unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
+ def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
+ """The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit,
+ and later, now and then, crash it (SIGSEGV) while background threads were still
+ loading TLS certificates. Run a few times: that crash came about 1 run in 100."""
+ for attempt in range(5):
+ with self.subTest(attempt=attempt):
+ env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
+ "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONFAULTHANDLER": "1"}
+ proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
+ env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT, text=True)
+ try:
+ self.assertIn("Frame Control on", proc.stdout.readline())
+ proc.terminate()
+ self.assertEqual(proc.wait(30), 0, proc.stdout.read())
+ finally:
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait()
+ proc.stdin.close()
+ proc.stdout.close()
+ shutil.rmtree(env["FRAME_CONTROL_DATA_DIR"], ignore_errors=True)
+
+ def test_staging_folders_cleared_when_stopped_mid_transfer(self):
+ """The server leaves without interpreter teardown, so TemporaryDirectory cleanup
+ doesn't run for work still in progress: its own staging folders go on the way out,
+ and a dead server's at the next start."""
+ dead = subprocess.Popen([sys.executable, "-c", "pass"])
+ dead.wait()
+ home = tempfile.mkdtemp(prefix="frame-stop-home-")
+ self.addCleanup(shutil.rmtree, home, ignore_errors=True)
+ env = {**os.environ, "FRAME_CONTROL_DATA_DIR": os.path.join(home, "data"),
+ "FRAME_ALIAS": "frame-control-test.invalid", "HOME": home, "XDG_CACHE_HOME": os.path.join(home, ".cache"),
+ "LOCALAPPDATA": home, "APPDATA": home}
+ index_dir = Path(subprocess.run(
+ [sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_host; "
+ "print(frame_host.cache_dir('apk-sources'))", str(ROOT / "ui")],
+ env=env, capture_output=True, text=True, check=True).stdout.strip())
+ index_dir.mkdir(parents=True)
+ tmp = Path(tempfile.gettempdir())
+
+ def staged(folder, prefix, pid):
+ d = Path(tempfile.mkdtemp(prefix=f"{prefix}{pid}-", dir=folder))
+ self.addCleanup(shutil.rmtree, d, ignore_errors=True)
+ (d / "app.apk").write_bytes(b"\0" * 4096)
+ return d
+
+ # Stopped as the app stops it: by closing stdin (the only way on Windows,
+ # where terminate() is a hard kill) and, elsewhere, by SIGTERM too.
+ for stop in ["stdin"] + (["sigterm"] if os.name != "nt" else []):
+ with self.subTest(stop=stop):
+ left_by_dead = [staged(tmp, "frame-vr-", dead.pid), staged(index_dir, ".download-", dead.pid)]
+ proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
+ env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT, text=True)
+ try:
+ self.assertIn("Frame Control on", proc.stdout.readline())
+ self.assertEqual([d for d in left_by_dead if d.exists()], [])
+ in_flight = [staged(tmp, "frame-vr-", proc.pid), staged(tmp, "frame-agent-", proc.pid),
+ staged(index_dir, ".download-", proc.pid)]
+ if stop == "stdin":
+ proc.stdin.close()
+ else:
+ proc.terminate()
+ self.assertEqual(proc.wait(30), 0, proc.stdout.read())
+ self.assertEqual([d for d in in_flight if d.exists()], [])
+ finally:
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait()
+ proc.stdin.close()
+ proc.stdout.close()
+
class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
@@ -330,6 +420,64 @@ class ArtworkSettings(unittest.TestCase):
self.assertLess(page.index("async function api("), page.index('", html)
+ parts += [(ROOT / "ui" / src).read_text(encoding="utf-8") for src in re.findall(r'"
+ across = "
x
"
+ same_line = ""
+ const_and_function = ""
+ for html in (dup, across, same_line, const_and_function):
+ rc, err = self.module_check(self.page_scripts(html))
+ self.assertNotEqual(rc, 0, html)
+ self.assertIn("has already been declared", err, html)
+ nested = ("")
+ self.assertEqual(self.module_check(self.page_scripts(nested)), (0, ""))
+
+ def test_no_top_level_name_is_declared_twice(self):
+ for name in self.PAGES:
+ source = self.page_scripts((ROOT / "ui" / name).read_text(encoding="utf-8"))
+ self.assertIn("function", source, name)
+ rc, err = self.module_check(source)
+ self.assertEqual(rc, 0, f"{name}: {err}")
+
+ def test_store_app_page_renders(self):
+ """tests/page/store_detail.mjs runs the real store functions from index.html."""
+ r = subprocess.run(["node", str(ROOT / "tests" / "page" / "store_detail.mjs")], capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+ def test_meta_library_rows_render(self):
+ """tests/page/meta_rows.mjs runs the real Meta library row rendering from index.html."""
+ r = subprocess.run(["node", str(ROOT / "tests" / "page" / "meta_rows.mjs")], capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class LocalMode(unittest.TestCase):
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
@@ -401,7 +549,11 @@ class UnreachableMessages(unittest.TestCase):
"ssh: connect to host 192.168.1.9 port 22: No route to host": "isn't answering",
"ssh: connect to host 192.168.1.9 port 22: Connection refused": "refused",
"steamos@192.168.1.9: Permission denied (publickey,password).": "SSH key",
- "Host key verification failed.": "identity changed",
+ "Host key verification failed.": "identity has changed",
+ "@@@@\n@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\nHost key verification failed.":
+ "Check the new key on the Devices tab",
+ "No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict checking.\n"
+ "Host key verification failed.": "isn't saved yet",
"kex_exchange_identification: read: Connection reset by peer": "dropped",
"Timed out talking to frame": "too long",
}
@@ -417,6 +569,47 @@ class UnreachableMessages(unittest.TestCase):
self.assertEqual(self.server.error_body(raw), ({"error": raw}, None), raw)
+class ClipboardDesktopClosed(unittest.TestCase):
+ """The headset's desktop being closed is the person's to fix: a 409, not a 5xx fault for telemetry."""
+
+ @classmethod
+ def setUpClass(cls):
+ sys.path.insert(0, str(ROOT / "ui"))
+ import server
+ cls.server = server
+
+ def paste_failing_with(self, message):
+ s = self.server
+ saved = s.ssh
+
+ def fake_ssh(remote, **kw):
+ raise s.Failure(message)
+ s.ssh = fake_ssh
+ try:
+ s.clipboard({"text": "hello"})
+ finally:
+ s.ssh = saved
+
+ def test_desktop_closed_is_a_409(self):
+ with self.assertRaises(self.server.Failure) as cm:
+ self.paste_failing_with("The headset's desktop isn't open (plasmashell is not running). "
+ "Open the desktop in the headset, then try again.")
+ self.assertEqual(cm.exception.status, 409)
+ self.assertIn("Open the desktop", str(cm.exception))
+ # Older wording (scripts/paste-to-frame.sh) is recognised too.
+ with self.assertRaises(self.server.Failure) as cm:
+ self.paste_failing_with("plasmashell is not running: open the desktop in the headset first.")
+ self.assertEqual(cm.exception.status, 409)
+
+ def test_other_failures_stay_faults(self):
+ with self.assertRaises(self.server.Failure) as cm:
+ self.paste_failing_with("Klipper call failed (bus: none)")
+ self.assertEqual(cm.exception.status, 502)
+
+ def test_paste_script_says_it(self):
+ self.assertIn(self.server.DESKTOP_CLOSED, self.server.PASTE)
+
+
class StatusProbe(unittest.TestCase):
# frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
@unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
diff --git a/tests/test_sidequest.py b/tests/test_sidequest.py
index 11d8d19..37e948a 100644
--- a/tests/test_sidequest.py
+++ b/tests/test_sidequest.py
@@ -1,3 +1,4 @@
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
from pathlib import Path
import sys
diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py
index 4b400c6..7fca09a 100644
--- a/tests/test_telemetry.py
+++ b/tests/test_telemetry.py
@@ -6,6 +6,7 @@ Run: python3 -m unittest discover -s tests
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
+import subprocess
import sys
import tempfile
import threading
@@ -20,9 +21,15 @@ sys.path.insert(0, str(ROOT / "ui"))
import frame_compat_db as db # noqa: E402
import frame_report as fr # noqa: E402
+import frame_host # noqa: E402
import frame_telemetry as tm # noqa: E402
+def link_error(message, kind=RuntimeError):
+ """An error as an ssh helper raises it when ssh couldn't reach the headset."""
+ return frame_host.link_failure(kind(message))
+
+
class Base(unittest.TestCase):
"""A packaged build with a key, its state in a temp folder."""
@@ -92,6 +99,47 @@ class Gates(Base):
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
self.assertEqual(len(self.queued()), 1)
+ def test_connection_failures_are_sent_once_per_session(self):
+ # The status poll meets an asleep or absent headset every few seconds (638 timeouts from
+ # six people in two weeks): one event per kind per session, whatever the address or route,
+ # for errors marked where ssh ran as ssh failing to reach the headset.
+ tm.update_settings({"diagnostics": True})
+ with mock.patch.object(tm.time, "time", return_value=1000.0):
+ for ip in ("192.168.1.20", "192.168.1.21", "10.0.0.5"):
+ for where in ("POST /api/comfort status", "job steam"):
+ tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Connection timed out"))
+ tm.diagnostic(where, link_error(f"ssh: connect to host {ip} port 22: Host is down"))
+ tm.diagnostic(where, link_error("Timed out talking to frame"))
+ tm.diagnostic("POST /api/comfort status",
+ link_error("ssh: Could not resolve hostname frame: No such host is known."))
+ with mock.patch.object(tm.time, "time", return_value=1000.0 + 10 * tm.REPEAT_WINDOW):
+ tm.diagnostic("POST /api/comfort status", link_error("client_loop: send disconnect: Connection reset"))
+ tm.diagnostic("POST /api/comfort status", link_error("ssh: Could not resolve hostname frame"))
+ sent = [e["properties"] for e in self.queued()]
+ self.assertEqual([p["error_category"] for p in sent], ["frame_unreachable", "frame_not_set_up"])
+ self.assertTrue(all(p["$exception_message"].startswith("ssh: ") for p in sent))
+
+ def test_only_marked_errors_are_held_for_the_session(self):
+ # The same words without the mark (from a download, a file name, anything not ssh) keep
+ # the usual 10-minute window.
+ tm.update_settings({"diagnostics": True})
+ tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
+ tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
+ tm.diagnostic("job web", RuntimeError("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
+ tm.diagnostic("job web", RuntimeError("download failed: [Errno 54] Connection reset by peer"))
+ tm.diagnostic("job web", RuntimeError("urlopen error [Errno 60] Operation timed out"))
+ self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
+ ["frame_unreachable", "frame_unreachable", "download_failed", "frame_unreachable"])
+
+ def test_real_errors_are_still_sent_beside_connection_failures(self):
+ tm.update_settings({"diagnostics": True})
+ tm.diagnostic("POST /api/comfort status", link_error("ssh: connect to host 10.0.0.5 port 22: Connection timed out"))
+ tm.diagnostic("POST /api/comfort status", KeyError("battery"))
+ tm.diagnostic("POST /api/android install", RuntimeError("boom"))
+ tm.diagnostic("POST /api/comfort status", RuntimeError("ssh exited 255")) # the command's own exit code?
+ self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
+ ["frame_unreachable", "internal_error", "other", "other"])
+
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)
@@ -111,11 +159,79 @@ class Lifecycle(Base):
e = self.queued()[-1]
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
+ def test_opens_installs_and_updates_carry_the_country(self):
+ with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
+ tm.app_started()
+ with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
+ tm.app_started()
+ self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
+ [("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
+ self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
+
+ def test_only_zones_in_the_tz_table_give_a_country(self):
+ for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
+ ("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
+ ("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
+ ("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
+ ("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
+ ("Japan", "JP"), ("UTC", None), ("alice", None),
+ ("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
+ with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
+ self.assertEqual(tm.country(), want, tz)
+
+ def test_country_from_tz_or_localtime_in_a_checkout(self):
+ with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
+ self.assertEqual(tm.country(), "JP")
+ with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
+ for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
+ ("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
+ ("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
+ with mock.patch.object(tm.os.path, "realpath", return_value=target):
+ self.assertEqual(tm.country(), want, target)
+
+ def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
+ tm.update_settings({"compat": True})
+ self.assertFalse(tm.state()["noticeShown"])
+ tm.update_settings({"noticeShown": True})
+ self.assertTrue(tm.state()["noticeShown"])
+
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)
+ def test_frame_once_per_headset_with_saved_count(self):
+ tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
+ tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
+ tm.frame_seen("20260922.1", "3.8", "fedcba9876543210", 2)
+ props = [e["properties"] for e in self.queued()]
+ self.assertEqual([(p["headset_id"], p["headsets_saved"]) for p in props],
+ [("0123456789abcdef", 2), ("fedcba9876543210", 2)])
+
+ def test_frame_many_headsets_are_not_sent_twice(self):
+ ids = ["%016x" % n for n in range(25)]
+ for _ in range(2):
+ for h in ids:
+ tm.frame_seen("20260922.1", "3.8", h, 25)
+ self.assertEqual(len(self.queued()), 25)
+
+ def test_frame_headset_id_must_be_the_hash(self):
+ tm.frame_seen("20260922.1", "3.8", "/etc/machine-id contents", None)
+ p = self.queued()[0]["properties"]
+ self.assertNotIn("headset_id", p)
+ self.assertNotIn("headsets_saved", p)
+
+
+class HeadsetId(unittest.TestCase):
+ def test_hash_of_machine_id_and_none_without_one(self):
+ import hashlib
+ import frame_status
+ with mock.patch.object(frame_status, "read", return_value="abc123"):
+ self.assertEqual(frame_status.headset_id(),
+ hashlib.sha256(b"frame-control-headset:abc123").hexdigest()[:16])
+ with mock.patch.object(frame_status, "read", return_value=None):
+ self.assertIsNone(frame_status.headset_id())
+
class Sending(Base):
def serve(self, status=200):
@@ -150,6 +266,39 @@ class Sending(Base):
self.assertEqual(self.queued(), [])
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
+ def test_an_older_notice_still_sends(self):
+ got = self.serve()
+ tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
+ tm.capture("app_opened")
+ self.assertEqual(tm.flush(), 1)
+ self.assertEqual(len(got), 1)
+
+ def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
+ got = self.serve()
+ tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
+ "last_version": "9.9.9"})) # as 0.4.5 saved it
+ self.assertFalse(tm.state()["noticeShown"])
+ with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
+ tm.app_started()
+ self.assertEqual(tm.flush(), 1) # the usual statistics still go
+ tm.update_settings({"noticeShown": True})
+ self.assertTrue(tm.state()["noticeShown"])
+ tm._save({**tm.settings(), "last_open_day": None}) # the next day
+ tm.app_started()
+ self.assertEqual(tm.flush(), 1)
+ self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
+
+ def test_turning_usage_on_still_waits_for_the_new_notice(self):
+ got = self.serve()
+ tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
+ "last_version": "9.9.9"})) # 0.4.5, usage off
+ tm.update_settings({"usage": True})
+ self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
+ with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
+ tm.app_started()
+ self.assertEqual(tm.flush(), 1)
+ self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
+
def test_a_failed_send_keeps_the_events(self):
self.serve(status=500)
tm.update_settings({"noticeShown": True})
@@ -180,6 +329,107 @@ class Scrub(unittest.TestCase):
"frame_unreachable")
self.assertEqual(tm.categorize("something new")[0], "other")
+ def test_connection_failures_seen_from_released_versions(self):
+ # Wording from 0.4.0's error reports (addresses replaced), on Windows, macOS and Linux.
+ unreachable = [
+ "ssh: connect to host 192.168.1.20 port 22: Connection timed out",
+ "ssh: connect to host 192.168.1.20 port 22: Operation timed out",
+ "ssh: connect to host 192.168.1.20 port 22: No route to host",
+ "ssh: connect to host 192.168.1.20 port 22: Host is down",
+ "ssh: connect to host 192.168.1.20 port 22: Unknown error",
+ "mux_client_request_session: read from master failed: Broken pipe\n"
+ "ssh: connect to host 192.168.1.20 port 22: Host is down",
+ "client_loop: send disconnect: Connection reset",
+ "banner exchange: Connection to UNKNOWN port -1: Connection refused",
+ "Timed out talking to frame",
+ ]
+ for message in unreachable:
+ self.assertEqual(tm.categorize(message)[0], "frame_unreachable", message)
+ for message in ("ssh: Could not resolve hostname frame: No such host is known.",
+ "ssh: Could not resolve hostname fe80::1%wireless_32773: No such host is known."):
+ self.assertEqual(tm.categorize(message)[0], "frame_not_set_up", message)
+ self.assertEqual(tm.categorize("ssh exited 1")[0], "other")
+ self.assertEqual(tm.categorize("ssh exited 255")[0], "other") # may be the command's own exit code
+ self.assertEqual(tm.categorize("download failed: [Errno 54] Connection reset by peer")[0], "download_failed")
+ self.assertEqual(tm.categorize("frame@10.0.0.2: Permission denied (publickey).")[0], "frame_auth")
+
+ def test_install_failure_categories(self):
+ import frame_android
+ self.assertEqual(tm.categorize(frame_android.LayerMissing(frame_android.LAYER_MISSING))[0], "layer_missing")
+ # The message 0.4.0 sent, so old and new builds land in the same bucket.
+ self.assertEqual(tm.categorize("the OpenXR compatibility layer isn't built; run "
+ "frame/openxr-compat/build.sh")[0], "layer_missing")
+ self.assertEqual(tm.categorize("could not prepare the APK for the Frame: ZIP64 APKs are unsupported")[0],
+ "apk_repack_failed")
+ self.assertEqual(tm.categorize(FileNotFoundError(2, "No such file or directory", "ssh"))[0], "tool_missing")
+ self.assertEqual(tm.categorize("[WinError 2] The system cannot find the file specified")[0], "tool_missing")
+
+ def test_apk_install_failures_that_were_other(self):
+ """What frame_android.install can raise, worded as it is, that 0.4.x sent as 'other'
+ (or as frame_unreachable, when it was the Steam client or a slow copy)."""
+ tail = 'Traceback (most recent call last):\n File "", line 19, in target_ws\n'
+ cases = {
+ "steam_not_running": [
+ "SharedJSContext not found: is the Steam client running?",
+ tail + "urllib.error.URLError: ",
+ tail + "ConnectionRefusedError: [Errno 111] Connection refused",
+ ],
+ "steam_not_responding": [
+ "websocket handshake failed",
+ tail + "TimeoutError: timed out",
+ tail + "socket.timeout: timed out",
+ tail + ' File "", line 45, in _read\nEOFError',
+ ],
+ "steam_js_error": ['JS error: {"exceptionId": 1, "text": "Uncaught", "exception": '
+ '{"description": "Error: Steam artwork API unavailable; installation is incomplete"}}'],
+ "artwork_failed": [
+ "Steam artwork renderer did not produce every slot",
+ tail + "ValueError: incomplete artwork render",
+ tail + "ValueError: hero render is 0 bytes of png",
+ "could not prepare artwork: unknown artwork slot",
+ tail + "ValueError: all five Steam artwork slots are required",
+ ],
+ "copy_timeout": ["copying Big Game (timed out).apk to the Frame timed out",
+ "copying launch.sh to the Frame timed out"],
+ "copy_failed": [
+ "copying game.apk to the Frame failed: rsync: connection unexpectedly closed (0 bytes received "
+ "so far) [sender]\nrsync error: error in rsync protocol data stream (code 12) at io.c(231)",
+ "copying game.apk to the Frame failed: ",
+ ],
+ "apk_unreadable": [
+ "x.apk: not a readable APK: [Errno 2] No such file or directory: 'x.apk'",
+ "x.apk: could not read the APK manifest: Error -3 while decompressing data: invalid block type",
+ "x.apk: the APK manifest has no package name",
+ "x.apk: lib/arm64-v8a/libx.so in the APK uses a compression Android does not",
+ "x.apk: AndroidManifest.xml in the APK is 40 MB, more than a real one (16 MB)",
+ ],
+ }
+ for category, messages in cases.items():
+ for message in messages:
+ self.assertEqual(tm.categorize(message)[0], category, message)
+ # Still where they were: ssh's own failures, and a full disk during a copy.
+ self.assertEqual(tm.categorize("timed out talking to frame")[0], "frame_unreachable")
+ self.assertEqual(tm.categorize("copying a.apk to the Frame failed: ssh: connect to host 10.0.0.2 "
+ "port 22: Connection refused")[0], "frame_unreachable")
+ self.assertEqual(tm.categorize("copying a.apk to the Frame failed: rsync: write failed on "
+ "\"app.apk.part\": No space left on device (28)")[0], "frame_disk_full")
+ self.assertEqual(tm.categorize("download failed: timed out")[0], "download_failed")
+
+ def test_python_errors_are_internal_whatever_they_say(self):
+ import json as _json
+ for error in (KeyError("paths"), TypeError("'NoneType' object is not subscriptable"),
+ AttributeError("x"), _json.JSONDecodeError("Expecting value", "", 0),
+ UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
+ KeyError("timed out")):
+ self.assertEqual(tm.categorize(error)[0], "internal_error", repr(error))
+ self.assertEqual(tm.categorize("KeyError: 'paths'")[0], "other") # only the exception itself
+ self.assertEqual(tm.categorize(OSError(28, "No space left on device"))[0], "frame_disk_full")
+
+ def test_privacy_doc_lists_every_category(self):
+ doc = (ROOT / "docs" / "privacy.md").read_text()
+ for name in {n for n, _ in tm.CATEGORIES} | {"internal_error", "other"}:
+ self.assertIn(f"`{name}`", doc)
+
class Compat(Base):
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
@@ -205,6 +455,278 @@ class Compat(Base):
self.assertEqual([e["properties"]["id"] for e in self.queued() if e["event"] == "compat_report"], ["old1"])
+class LinkFailureProvenance(Base):
+ """Only ssh, where it runs, decides that it couldn't reach the headset; the error report
+ then holds that back for the session. Nothing in a message can claim it."""
+
+ def setUp(self):
+ super().setUp()
+ import frame_android
+ import frame_webinstall
+ import server
+ self.server, self.android, self.web = server, frame_android, frame_webinstall
+ for target, name, kw in ((server, "ensure_master", {}), (server, "LINK", {"new": None}),
+ (server, "repair_ssh_config", {"return_value": False})):
+ p = mock.patch.object(target, name, **kw)
+ p.start()
+ self.addCleanup(p.stop)
+ tm.update_settings({"diagnostics": True})
+
+ def ssh_fails(self, module, returncode, stderr, stdout=""):
+ """Run module.ssh with ssh exiting `returncode`; -> the exception, as the route handler gets it."""
+ done = subprocess.CompletedProcess(["ssh"], returncode, stdout, stderr)
+ with mock.patch.object(frame_host, "run_ssh", return_value=done):
+ try:
+ module.ssh("true")
+ except Exception as e: # noqa: BLE001
+ return e
+ self.fail("ssh did not raise")
+
+ def poll_fails(self):
+ e = self.ssh_fails(self.server, 255, "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
+ tm.diagnostic("POST /api/comfort status", e)
+
+ def test_a_status_poll_that_cannot_reach_the_frame_is_sent_once(self):
+ for _ in range(5):
+ self.poll_fails()
+ for stderr in ("Warning: Permanently added '10.0.0.5' (ED25519) to the list of known hosts.\r\n"
+ "kex_exchange_identification: read: Connection reset by peer\r\n",
+ "banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
+ "\x1b[0mssh: connect to host 10.0.0.5 port 22: Unknown error\n"):
+ tm.diagnostic("POST /api/comfort status", self.ssh_fails(self.server, 255, stderr))
+ with mock.patch.object(frame_host, "run_ssh", side_effect=subprocess.TimeoutExpired("ssh", 30)):
+ with self.assertRaises(self.server.Failure) as caught:
+ self.server.ssh("true")
+ tm.diagnostic("POST /api/comfort status", caught.exception)
+ e = self.ssh_fails(self.android, 255, "ssh: connect to host 10.0.0.5 port 22: Host is down\n")
+ tm.diagnostic("job steam", e)
+ self.assertEqual([p["properties"]["error_category"] for p in self.queued()], ["frame_unreachable"])
+
+ def test_host_key_and_login_refusals_are_sent_once(self):
+ # 0.4.2: each of these came in as a $exception from the status poll, again every 10 minutes.
+ for _ in range(3):
+ for stderr in ("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\r\n"
+ "@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\n"
+ "IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\r\nHost key verification failed.\r\n",
+ "No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict "
+ "checking.\r\nHost key verification failed.\r\n",
+ "jane doe@frame-control-f7b4e9ac: Permission denied (publickey,password).\r\n",
+ "steamos@192.168.1.9: Permission denied (publickey,password).\n"):
+ e = self.ssh_fails(self.server, 255, stderr)
+ self.assertTrue(getattr(e, "frame_link_failed", False), stderr)
+ tm.diagnostic("POST /api/comfort status", e)
+ self.assertEqual([p["properties"]["error_category"] for p in self.queued()], ["frame_auth"])
+ # A command on the Frame that was refused something isn't ssh refusing the headset.
+ for code, stderr in ((1, "steamos@frame: Permission denied (publickey).\n"),
+ (255, "cat: /root/x: Permission denied\n"),
+ (255, "scp: /home/steamos/x: Permission denied\n")):
+ self.assertFalse(getattr(self.ssh_fails(self.server, code, stderr), "frame_link_failed", False), stderr)
+
+ def test_a_command_that_fails_on_the_frame_is_not_marked(self):
+ self.poll_fails()
+ for module in (self.server, self.android):
+ for code, stderr in ((255, ""), (255, "x: ssh: connect to host frame port 22: Connection timed out\n"),
+ (1, "ssh: connect to host 10.0.0.5 port 22: Connection timed out\n")):
+ e = self.ssh_fails(module, code, stderr)
+ self.assertFalse(getattr(e, "frame_link_failed", False), (module.__name__, code, stderr))
+ # Review round 4: the command's own output (stdout) isn't ssh speaking.
+ e = self.ssh_fails(module, 255, "", stdout="ssh: connect to host frame port 22: Connection timed out\n")
+ self.assertFalse(getattr(e, "frame_link_failed", False), module.__name__)
+
+ def request(self, path, body):
+ """POST to the real server, as the page does."""
+ import http.client
+ c = http.client.HTTPConnection("127.0.0.1", self.httpd.server_port)
+ c.request("POST", path, json.dumps(body), {"X-Frame-UI": self.server.UI_KEY, "Content-Type": "application/json"})
+ r = c.getresponse()
+ result = r.status, json.loads(r.read())
+ c.close()
+ return result
+
+ def serve(self):
+ self.httpd = self.server.ThreadingHTTPServer(("127.0.0.1", 0), self.server.Handler)
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
+ self.addCleanup(self.httpd.server_close)
+ self.addCleanup(self.httpd.shutdown)
+
+ def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
+ # Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
+ # Failure; after the first connection failure, theirs are held back too, also past the
+ # 10-minute window.
+ self.serve()
+ down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
+ meta = {"label": "Test App", "game_id": 5, "shortcut": None}
+ with mock.patch.object(frame_host, "run_ssh", return_value=down), \
+ mock.patch.object(self.android, "_meta_or_fail", return_value=meta), \
+ mock.patch.object(self.server.frame_titles, "ensure_utils"):
+ self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 503) # the page shows its offline message
+ for t in (1000.0, 1000.0 + 2 * tm.REPEAT_WINDOW):
+ with mock.patch.object(tm.time, "time", return_value=t):
+ self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 503)
+ self.assertEqual(self.request("/api/titles", {"action": "launch", "id": "mygame"})[0], 503)
+ # Not a link failure: still reported, through the same re-wrap.
+ with mock.patch.object(self.android, "_meta_or_fail", side_effect=self.android.FrameError("boom")):
+ self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 502)
+ sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
+ if e["event"] == "$exception"]
+ # The comfort poll's own failure isn't one (see the next test).
+ self.assertEqual(sent, [("POST /api/android launch", "frame_unreachable"), ("POST /api/android launch", "other")])
+
+ def test_the_comfort_poll_meeting_an_unreachable_frame_is_not_sent(self):
+ # 0.4.2: most reports were this poll (every 5 s) finding the headset asleep, away or
+ # refusing ssh. Like GET /api/status, it isn't reported; a session started or cancelled
+ # by hand still is, once, and a real failure of the poll is too.
+ self.serve()
+ for stderr in ("ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n",
+ "banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
+ "ssh: Could not resolve hostname frame: nodename nor servname provided\r\n",
+ "@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\nHost key verification failed.\r\n",
+ "No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict "
+ "checking.\r\nHost key verification failed.\r\n",
+ "steamos@192.168.1.9: Permission denied (publickey,password).\r\n"):
+ down = subprocess.CompletedProcess(["ssh"], 255, "", stderr)
+ with mock.patch.object(frame_host, "run_ssh", return_value=down):
+ self.assertGreaterEqual(self.request("/api/comfort", {"action": "status"})[0], 500, stderr)
+ self.assertEqual(self.queued(), [])
+ down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Host is down\r\n")
+ with mock.patch.object(frame_host, "run_ssh", return_value=down):
+ self.request("/api/comfort", {"action": "cancel"})
+ broken = subprocess.CompletedProcess(["ssh"], 1, "", "Traceback (most recent call last):\nOSError: boom\n")
+ with mock.patch.object(frame_host, "run_ssh", return_value=broken):
+ self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 502)
+ sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
+ if e["event"] == "$exception"]
+ self.assertEqual(sent, [("POST /api/comfort cancel", "frame_unreachable"), ("POST /api/comfort status", "other")])
+
+ def test_download_failures_after_a_poll_failure_are_still_sent(self):
+ # Review round 3: these file names, in the real checksum message through the web-link worker,
+ # were held back with the poll's connection failures.
+ self.poll_fails()
+ names = ("Connection closed by frame port 22.zip", "Connection reset by frame port 22.apk",
+ "kex_exchange_identification: read.zip", "banner exchange: payload.zip",
+ "ssh: connect to host frame port 22: x.zip", "Timed out talking to frame")
+ for name in names:
+ job = {"phase": "download", "done": 0, "total": None, "detail": "", "message": None, "error": None,
+ "cancel": False}
+ plan = {"name": None, "exe": None, "url": "https://example.com/x.zip", "kind": "zip"}
+ error = self.web.WebInstallError(f"{name} doesn't match the manifest's sha256; not installing it")
+ with mock.patch.object(self.web, "download", side_effect=error):
+ self.server._webinstall_run(plan, job)
+ self.assertEqual(job["phase"], "error")
+ exceptions = [e["properties"] for e in self.queued() if e["event"] == "$exception"]
+ self.assertEqual([p["error_category"] for p in exceptions], ["frame_unreachable"] + ["download_failed"] * len(names))
+ finished = [e["properties"] for e in self.queued() if e["event"] == "install_finished"]
+ self.assertEqual({p["error_category"] for p in finished}, {"download_failed"})
+
+
+class InstallFinished(unittest.TestCase):
+ def test_failure_category_only_no_text(self):
+ """install_finished carries a fixed category for a failure, never the message or a file name."""
+ import frame_android
+ with mock.patch.object(tm, "capture") as capture, mock.patch.object(tm, "diagnostic"):
+ tm.install_finished("apk", False, 0.0, frame_android.LayerMissing(
+ "C:\\Users\\Bob\\My Game.apk: " + frame_android.LAYER_MISSING), catalog=False)
+ props = capture.call_args[0][1]
+ self.assertEqual(props["error_category"], "layer_missing")
+ self.assertNotIn("Bob", repr(props))
+ self.assertEqual(set(props), {"kind", "ok", "seconds", "error_category", "catalog"})
+ tm.install_finished("apk", False)
+ self.assertEqual(capture.call_args[0][1]["error_category"], "other")
+
+
+class ApkInstallJobs(unittest.TestCase):
+ """The whole job path: what the page is told, and what telemetry sends, once."""
+
+ def setUp(self):
+ import frame_android
+ import frame_webinstall
+ import server
+ self.server, self.android, self.web = server, frame_android, frame_webinstall
+ for target, name, kw in ((server, "ensure_master", {}), (server.frame_catalog, "app", {}),
+ (server.frame_catalog, "add_report", {})):
+ p = mock.patch.object(target, name, **kw)
+ p.start()
+ self.addCleanup(p.stop)
+
+ def run_job(self, body):
+ job = self.server.android(body)["job"]
+ for _ in range(500):
+ with self.server._jobs_lock:
+ state = dict(self.server._jobs[job])
+ if state["done"]:
+ return state
+ time.sleep(0.01)
+ self.fail("job did not finish")
+
+ def test_missing_layer_warning_reaches_every_completion_message(self):
+ meta = {"label": "VR", "package": "org.test.vr", "vr_issues": [self.android.LAYER_MISSING_NOTE]}
+ with mock.patch.object(self.server.frame_catalog, "install", return_value=meta):
+ state = self.run_job({"action": "install", "package": "org.test.vr"})
+ self.assertIsNone(state["error"])
+ self.assertIn(self.android.LAYER_MISSING_NOTE, state["message"])
+ with mock.patch.object(self.server.frame_apk_versions, "install", return_value=meta):
+ state = self.run_job({"action": "install", "package": "org.test.vr", "url": "https://example.com/v.apk"})
+ self.assertIn(self.android.LAYER_MISSING_NOTE, state["message"])
+ with mock.patch.object(self.android, "install", return_value=meta):
+ self.assertIn(self.android.LAYER_MISSING_NOTE, self.web.dispatch("/tmp/v.apk")["message"])
+ # A normal install says nothing about the layer.
+ with mock.patch.object(self.server.frame_catalog, "install", return_value=dict(meta, vr_issues=[])):
+ state = self.run_job({"action": "install", "package": "org.test.vr"})
+ self.assertNotIn("OpenXR", state["message"])
+
+ def test_unexpected_failure_is_one_event_and_one_diagnostic(self):
+ info = {"package": "org.test.flat", "label": "Flat", "abis": [], "min_sdk": None, "vr": False,
+ "vr_activity": False, "launchable": True, "repairable": False}
+ sent = []
+ tm._seen_errors.clear()
+ with mock.patch.object(tm, "enabled", return_value=True), \
+ mock.patch.object(tm, "capture", side_effect=lambda e, p=None, level="usage": sent.append((e, p))), \
+ mock.patch.object(self.android, "apk_info", side_effect=lambda path: dict(info)), \
+ mock.patch.object(self.android, "_install",
+ side_effect=FileNotFoundError(2, "No such file or directory", "scp")), \
+ mock.patch.object(self.server.frame_catalog, "install",
+ side_effect=lambda pkg: self.android.install("/tmp/x.apk")):
+ state = self.run_job({"action": "install", "package": "org.test.flat"})
+ self.assertIn("FileNotFoundError", state["error"])
+ events = [e for e, _ in sent]
+ self.assertEqual(events.count("install_finished"), 1)
+ self.assertEqual(events.count("$exception"), 1, events)
+ finished = next(p for e, p in sent if e == "install_finished")
+ self.assertEqual((finished["ok"], finished["error_category"]), (False, "tool_missing"))
+ # The same failure through a web link: one event and one diagnostic there too.
+ sent.clear()
+ tm._seen_errors.clear()
+ job = {"phase": "download", "done": 0, "total": None, "detail": "", "message": None, "error": None,
+ "cancel": False}
+ plan = {"name": None, "exe": None, "url": "https://example.com/x.apk", "kind": "apk"}
+ with mock.patch.object(tm, "enabled", return_value=True), \
+ mock.patch.object(tm, "capture", side_effect=lambda e, p=None, level="usage": sent.append((e, p))), \
+ mock.patch.object(self.android, "apk_info", side_effect=lambda path: dict(info)), \
+ mock.patch.object(self.android, "_install",
+ side_effect=FileNotFoundError(2, "No such file or directory", "scp")), \
+ mock.patch.object(self.server.frame_webinstall, "download",
+ side_effect=lambda plan, tmp, **kw: os.path.join(tmp, "x.apk")):
+ self.server._webinstall_run(plan, job)
+ self.assertEqual(job["phase"], "error")
+ self.assertIn("FileNotFoundError", job["error"])
+ events = [e for e, _ in sent]
+ self.assertEqual(events.count("install_finished"), 1, events)
+ self.assertEqual(events.count("$exception"), 1, events)
+ finished = next(p for e, p in sent if e == "install_finished")
+ self.assertEqual((finished["kind"], finished["error_category"]), ("apk", "tool_missing"))
+ # A FrameError still gets its install diagnostic (the job reports it as well, as before).
+ sent.clear()
+ tm._seen_errors.clear()
+ with mock.patch.object(tm, "enabled", return_value=True), \
+ mock.patch.object(tm, "capture", side_effect=lambda e, p=None, level="usage": sent.append((e, p))), \
+ mock.patch.object(self.android, "apk_info", side_effect=lambda path: dict(info)), \
+ mock.patch.object(self.android, "_install", side_effect=self.android.FrameError("timed out talking to frame")), \
+ mock.patch.object(self.server.frame_catalog, "install",
+ side_effect=lambda pkg: self.android.install("/tmp/x.apk")):
+ self.run_job({"action": "install", "package": "org.test.flat"})
+ self.assertEqual([e for e, _ in sent].count("install_finished"), 1)
+
+
class ApkInstallReports(unittest.TestCase):
"""server.apk_installed: an APK that won't install is reported; connection trouble isn't."""
@@ -224,6 +746,12 @@ class ApkInstallReports(unittest.TestCase):
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_install_without_layer_is_flagged(self):
+ self.server.apk_installed({"package": "com.private.vr", "xr_layer_missing": True}, {}, None, 4.0)
+ self.assertIs(self.install_finished.call_args[1]["xr_layer_missing"], True)
+ self.server.apk_installed({"package": "com.private.vr"}, {}, None, 4.0)
+ self.assertIsNone(self.install_finished.call_args[1]["xr_layer_missing"])
+
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)
@@ -391,14 +919,16 @@ class ReportProblem(Base):
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
got = self.serve()
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
- res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
- "contact": "me@example.com"})
+ with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
+ res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
+ "contact": "me@example.com", "contactFollowup": True})
path, body = got[0]
event = body["batch"][0]
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
props = event["properties"]
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
+ self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
self.assertIn(res["id"], res["message"])
@@ -421,8 +951,9 @@ class ReportProblem(Base):
def test_the_inbox_skips_malformed_reports(self):
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
- "0.4.0", "macOS", "", ""]
- rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
+ "0.4.0", "macOS", "", "", None, None, None]
+ rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
+ ["short"], good]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
mock.patch("builtins.print") as out:
diff --git a/tests/test_touch.py b/tests/test_touch.py
index 2319589..5d0c7c6 100644
--- a/tests/test_touch.py
+++ b/tests/test_touch.py
@@ -2,9 +2,15 @@
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import io
+import json
+import os
import shutil
import subprocess
import sys
+import tempfile
+import time
import unittest
from pathlib import Path
@@ -51,12 +57,9 @@ class Mapping(unittest.TestCase):
self.assertEqual(t.locate(5, None)["display"], ":0")
def test_focus_display_is_decoded(self):
- t = self.t
- saved = t.xprop_root
- self.addCleanup(lambda: setattr(t, "xprop_root", saved))
for values, want in (([12602, 0, 58], ":1"), ([12346], ":0"), ([], None), ([0x41], None)):
- t.xprop_root = lambda d, n, v=values: v
- self.assertEqual(t.focus_display(), want)
+ self.assertEqual(self.t.focus_display({"GAMESCOPE_FOCUS_DISPLAY": values}), want)
+ self.assertIsNone(self.t.focus_display({}))
def test_ascii_table_covers_printable_characters(self):
for code in range(0x20, 0x7F):
@@ -259,6 +262,236 @@ class ServerChecks(unittest.TestCase):
self.s.remote_touch({"events": [{"dx": 1}] * (self.s.INPUT_BATCH_LIMIT + 1)})
+class FindingPanels(unittest.TestCase):
+ """panels() with faked headset probes: where gamescope's properties are, and why nothing was found (#73)."""
+
+ @classmethod
+ def setUpClass(cls):
+ import frame_touch
+ cls.t = frame_touch
+
+ def setUp(self):
+ t = self.t
+ names = ("read_props", "window_info", "window_pid", "displays", "desktop_running")
+ saved = {n: getattr(t, n) for n in names}
+ saved_which = t.shutil.which
+ self.real_displays = saved["displays"]
+ self.real_read_props = saved["read_props"]
+
+ def restore():
+ for n, v in saved.items():
+ setattr(t, n, v)
+ t.shutil.which = saved_which
+ t._ROOT.clear()
+ self.addCleanup(restore)
+ t._ROOT.clear()
+ self.props = {} # (display, property) -> values
+ self.windows = {} # (display, window) -> info
+ self.reads = [] # displays whose gamescope properties were read
+ t.read_props = lambda d, limit=5.0: self.reads.append(d) or {n: list(v) for (dd, n), v in self.props.items() if dd == d}
+ t.window_info = lambda d, w: self.windows.get((d, w))
+ t.window_pid = lambda d, w: None
+ t.displays = lambda: [":0", ":1", ":2"]
+ t.desktop_running = lambda: False
+ t.shutil.which = lambda tool: "/usr/bin/" + tool
+
+ def plasma_on(self, display):
+ # SteamOS 0.4.1: the nested Plasma desktop (kwin_wayland, app id 0) is a window on :0.
+ self.props[(display, "GAMESCOPE_FOCUSABLE_WINDOWS")] = [4194307, 0, 2222]
+ self.props[(display, "GAMESCOPE_FOCUSED_WINDOW")] = [4194307]
+ self.props[(display, "GAMESCOPE_FOCUS_DISPLAY")] = [0x303A] # ":0"
+ self.windows[(":0", 4194307)] = {"name": "KDE Wayland Compositor", "x": 0, "y": 0, "width": 1280, "height": 800}
+ self.windows[(":0", "root")] = {"width": 1280, "height": 720}
+
+ def test_desktop_panel_on_display_0(self):
+ self.plasma_on(":0")
+ out = self.t.panels()
+ self.assertEqual([(p["window"], p["display"], p["focused"]) for p in out["panels"]], [(4194307, ":0", True)])
+ self.assertNotIn("why", out)
+
+ def test_display_0_costs_one_read(self):
+ # The layout of every build seen so far: one read of :0's properties for the whole run.
+ self.plasma_on(":0")
+ self.t.panels()
+ self.assertEqual(self.reads, [":0"])
+
+ def test_gamescope_properties_on_another_display_are_found(self):
+ # A build that numbers its displays differently: :0 has no gamescope properties.
+ self.plasma_on(":1")
+ self.assertEqual(self.t.gamescope()[0], ":1")
+ self.reads.clear()
+ self.t._ROOT.clear()
+ self.assertEqual(len(self.t.panels()["panels"]), 1)
+ self.assertEqual(self.reads, [":0", ":1"]) # read once, then reused by focus() and focusable()
+
+ def test_other_displays_are_probed_briefly(self):
+ limits = []
+ self.t.read_props = lambda d, limit=5.0: limits.append((d, limit)) or {}
+ self.assertEqual(self.t.gamescope(), (":0", {}))
+ self.assertEqual(limits, [(":0", 5.0), (":1", self.t.ROOT_PROBE_TIMEOUT), (":2", self.t.ROOT_PROBE_TIMEOUT)])
+
+ def test_input_helper_remembers_the_display(self):
+ # The input helper lives for a session: a found display is kept, a failed search retried later.
+ t = self.t
+ self.plasma_on(":1")
+ t.gamescope(); t.gamescope()
+ self.assertEqual(self.reads, [":0", ":1", ":1"])
+ self.props.clear()
+ self.reads.clear()
+ t._ROOT.clear()
+ t.gamescope(); t.gamescope()
+ self.assertEqual(self.reads, [":0", ":1", ":2", ":0"]) # given up: :0 for ROOT_RETRY
+ t._ROOT[1] -= t.ROOT_RETRY
+ self.reads.clear()
+ t.gamescope()
+ self.assertEqual(self.reads, [":0", ":1", ":2"])
+
+ def test_read_props_parses_xprop(self):
+ t = self.t
+ saved = t.subprocess.run
+ self.addCleanup(setattr, t.subprocess, "run", saved)
+ out = ("GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 1, 0, 2, 3, 769, 4\n"
+ "GAMESCOPE_FOCUSED_WINDOW(CARDINAL) = 0\nGAMESCOPE_FOCUS_DISPLAY: not found.\n")
+ t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, out, "")
+ self.assertEqual(self.real_read_props(":0"), {"GAMESCOPE_FOCUSABLE_WINDOWS": [1, 0, 2, 3, 769, 4],
+ "GAMESCOPE_FOCUSED_WINDOW": [0]})
+ t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, "GAMESCOPE_FOCUSED_WINDOW: not found.\n", "")
+ self.assertEqual(self.real_read_props(":0"), {})
+
+ def hang(*a, **k):
+ raise subprocess.TimeoutExpired(a[0], k.get("timeout"))
+ t.subprocess.run = hang
+ self.assertEqual(self.real_read_props(":0"), {})
+
+ def test_deadline_returns_what_was_found(self):
+ t, clock = self.t, [0.0]
+ self.plasma_on(":0")
+ self.props[(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")] = [4194307, 0, 2222, 5, 0, 1, 6, 0, 1]
+ saved = t.time.monotonic
+ self.addCleanup(setattr, t.time, "monotonic", saved)
+ t.time.monotonic = lambda: clock[0]
+ info = self.windows.get
+
+ def slow(d, w): # every xwininfo takes 1 s: the run is out of time after a few
+ t.budget()
+ clock[0] += 1
+ return info((d, w))
+ t.window_info = slow
+ out = t.panels(limit=7) # focus: 2 reads; the first panel: 3 displays and its root
+ self.assertEqual([p["window"] for p in out["panels"]], [4194307])
+ self.assertIn("took too long", out["why"])
+ self.assertIn("Some may be missing", out["why"])
+ self.assertIsNone(t.DEADLINE[0]) # the next run (or the input helper) has no deadline left over
+
+ def test_listed_window_that_isnt_x11_explains_itself(self):
+ self.plasma_on(":0")
+ del self.windows[(":0", 4194307)] # say, a desktop drawn as a Wayland window
+ out = self.t.panels()
+ self.assertEqual(out["panels"], [])
+ self.assertIn("can't find its window", out["why"])
+ self.assertIn("Headset view", out["why"])
+
+ def test_desktop_running_but_not_listed(self):
+ self.t.desktop_running = lambda: True
+ out = self.t.panels()
+ self.assertEqual(out["panels"], [])
+ self.assertIn("desktop is running", out["why"])
+
+ def test_nothing_open(self):
+ out = self.t.panels()
+ self.assertEqual(out, {"focus": None, "focus_display": None, "panels": [],
+ "why": "No app panel is open in the headset. Open the desktop or an app in "
+ "the headset, then try again."})
+
+ def test_missing_x_tools_are_named(self):
+ self.plasma_on(":0")
+ self.t.shutil.which = lambda tool: None if tool == "xwininfo" else "/usr/bin/" + tool
+ self.windows.clear() # xwininfo can't run, so nothing resolves
+ self.assertIn("no xwininfo", self.t.panels()["why"])
+
+ def test_displays_include_abstract_sockets(self):
+ # /tmp/.X11-unix not visible to the SSH session: the abstract sockets still name the displays.
+ import builtins
+ t = self.t
+ real_open, real_listdir = builtins.open, t.os.listdir
+
+ def fake_open(path, *a, **k):
+ if path == "/proc/net/unix":
+ return io.StringIO("0000: 00000002 0 ... @/tmp/.X11-unix/X1\n0000: ... @/tmp/.X11-unix/X0\n"
+ "0000: ... /run/user/1000/wayland-0\n")
+ return real_open(path, *a, **k)
+
+ def fake_listdir(path):
+ if path == "/tmp/.X11-unix":
+ raise FileNotFoundError(path)
+ return real_listdir(path)
+ builtins.open, t.os.listdir = fake_open, fake_listdir
+ try:
+ self.assertEqual(self.real_displays(), [":0", ":1"])
+ finally:
+ builtins.open, t.os.listdir = real_open, real_listdir
+
+
+FAKE_XPROP = """#!/bin/sh
+sleep "${FAKE_X_DELAY:-0}"
+if [ "$1" = "-root" ] && [ "$DISPLAY" = ":0" ]; then
+ echo "GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 11, 0, 101, 12, 769, 102"
+ echo "GAMESCOPE_FOCUSED_WINDOW(CARDINAL) = 11"
+ echo "GAMESCOPE_FOCUS_DISPLAY(CARDINAL) = 12346"
+fi
+"""
+FAKE_XWININFO = """#!/bin/sh
+sleep "${FAKE_X_DELAY:-0}"
+[ "$DISPLAY" = ":0" ] || exit 1
+echo 'xwininfo: Window id: 0xb "Desktop"'
+echo ' Absolute upper-left X: 0'
+echo ' Absolute upper-left Y: 0'
+echo ' Width: 1280'
+echo ' Height: 800'
+echo ' Map State: IsViewable'
+"""
+
+
+@unittest.skipIf(sys.platform == "win32", "fake X tools are shell scripts; frame_touch.py runs on the Frame")
+class PanelsAsAFreshProcess(unittest.TestCase):
+ """`frame_touch.py panels` the way the server runs it: a new process each request, with
+ fake xprop and xwininfo first on PATH (two displays, :0 and :1)."""
+
+ def run_panels(self, delay):
+ with tempfile.TemporaryDirectory() as bin_dir:
+ for name, body in (("xprop", FAKE_XPROP), ("xwininfo", FAKE_XWININFO)):
+ path = Path(bin_dir) / name
+ path.write_text(body)
+ path.chmod(0o755)
+ env = dict(os.environ, PATH=bin_dir + os.pathsep + os.environ.get("PATH", ""), FAKE_X_DELAY=str(delay),
+ PYTHONDONTWRITEBYTECODE="1")
+ code = ("import sys; sys.path.insert(0, sys.argv[1]); import frame_touch as t; "
+ "t.displays = lambda: [':0', ':1']; t.desktop_running = lambda: False; "
+ "sys.argv = ['frame_touch.py', 'panels']; sys.exit(t.main())")
+ start = time.monotonic()
+ r = subprocess.run([sys.executable, "-c", code, str(ROOT / "ui")], env=env, capture_output=True,
+ text=True, timeout=60)
+ took = time.monotonic() - start
+ self.assertEqual(r.returncode, 0, r.stderr)
+ return json.loads(r.stdout), took
+
+ def test_lists_panels(self):
+ out, _ = self.run_panels(0)
+ self.assertEqual(out["focus"], 11)
+ self.assertEqual(out["focus_display"], ":0")
+ self.assertEqual([(p["window"], p["display"], p["focused"]) for p in out["panels"]],
+ [(11, ":0", True), (12, ":0", False)])
+ self.assertNotIn("why", out)
+
+ def test_slow_x_tools_stay_inside_the_servers_timeout(self):
+ # Review of #83: X tools answering in 4.6 s each would take well over the server's
+ # 20 s; one deadline for the run (PANELS_DEADLINE, 12 s) returns in time, saying why.
+ out, took = self.run_panels(4.6)
+ self.assertLess(took, 15, out)
+ self.assertEqual(out["panels"], [])
+ self.assertIn("took too long", out["why"])
+
+
@unittest.skipUnless(shutil.which("node"), "needs node")
class PageGestures(unittest.TestCase):
"""Control's gestures and queue (tests/page/ctrl_gestures.mjs runs the real functions from index.html)."""
@@ -268,6 +501,12 @@ class PageGestures(unittest.TestCase):
timeout=60)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+ def test_desktop_panel_list(self):
+ # #73: the headset's reason for no panels, and a failed listing, reach Capture and Control.
+ r = subprocess.run(["node", str(ROOT / "tests" / "page" / "desk_panels.mjs")], capture_output=True, text=True,
+ timeout=60)
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
class Script(unittest.TestCase):
def test_helper_compiles_on_the_frames_python(self):
diff --git a/tests/test_vr.py b/tests/test_vr.py
index e206173..13d25c0 100644
--- a/tests/test_vr.py
+++ b/tests/test_vr.py
@@ -1,4 +1,5 @@
"""Own VR telemetry, optional catalogue and entitlement guards; no headset needed."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import ctypes
import json
from pathlib import Path
diff --git a/tests/test_webinstall.py b/tests/test_webinstall.py
index e197b49..ccbb560 100644
--- a/tests/test_webinstall.py
+++ b/tests/test_webinstall.py
@@ -386,17 +386,28 @@ class ServerJobs(unittest.TestCase):
def test_dead_servers_leftovers_swept(self):
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
- # Downloads and title staging (unzipped titles) are both swept.
- for prefix in (self.server.WEB_TMP_PREFIX, self.server.frame_titles.TMP_PREFIX):
- gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-")
- live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-")
- try:
- self.server.sweep_tmp()
- self.assertFalse(os.path.exists(gone), prefix)
- self.assertTrue(os.path.exists(live), prefix)
- finally:
- shutil.rmtree(gone, ignore_errors=True)
- shutil.rmtree(live, ignore_errors=True)
+ s = self.server
+ with tempfile.TemporaryDirectory() as cache, \
+ mock.patch.object(s.frame_host, "cache_dir", lambda *parts: Path(cache).joinpath(*parts)):
+ # Downloads, title staging, patched VR APKs, files staged for the
+ # assistant, and app-index downloads (in the cache folder).
+ places = s._tmp_places()
+ self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX,
+ s.frame_android.TMP_PREFIX, s.frame_agent.TMP_PREFIX,
+ s.apk_fdroid.TMP_PREFIX})
+ for folder, prefix in places:
+ folder.mkdir(parents=True, exist_ok=True)
+ gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder)
+ live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-", dir=folder)
+ try:
+ s.sweep_tmp()
+ self.assertFalse(os.path.exists(gone), prefix)
+ self.assertTrue(os.path.exists(live), prefix)
+ s.sweep_tmp(own=True) # on the way out: this server's own go too
+ self.assertFalse(os.path.exists(live), prefix)
+ finally:
+ shutil.rmtree(gone, ignore_errors=True)
+ shutil.rmtree(live, ignore_errors=True)
def test_temp_dir_failure_ends_the_job(self):
job, dispatch = self.run_job(mkdtemp_error=OSError("disk full"))
diff --git a/tests/test_windows_fixes.py b/tests/test_windows_fixes.py
new file mode 100644
index 0000000..1903630
--- /dev/null
+++ b/tests/test_windows_fixes.py
@@ -0,0 +1,162 @@
+"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
+
+Run: python3 -m unittest discover -s tests
+"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+
+import frame_host # noqa: E402
+import frame_devices as fd # noqa: E402
+
+REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
+ "on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
+
+
+def ran(*results):
+ """subprocess.run stand-in answering whoami, then icacls."""
+ calls = []
+
+ def run(argv, **kw):
+ calls.append(argv)
+ return results[len(calls) - 1]
+ return run, calls
+
+
+class MakePrivate(unittest.TestCase):
+ def test_windows_sets_owner_only_acl_by_sid(self):
+ run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
+ subprocess.CompletedProcess([], 0))
+ with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
+ self.assertTrue(frame_host.make_private(Path("C:/x/config")))
+ self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
+ "*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
+
+ def test_windows_falls_back_to_username_and_reports_failure(self):
+ run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
+ with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
+ mock.patch.dict(os.environ, {"USERNAME": "bob"}):
+ self.assertFalse(frame_host.make_private(Path("config")))
+ self.assertIn("bob:F", calls[1])
+
+ @unittest.skipIf(os.name == "nt", "POSIX modes")
+ def test_posix_chmods_600(self):
+ with tempfile.NamedTemporaryFile() as f:
+ os.chmod(f.name, 0o644)
+ self.assertTrue(frame_host.make_private(f.name))
+ self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
+
+
+class ConfigWrites(unittest.TestCase):
+ def setUp(self):
+ self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
+ self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
+ self.config = self.ssh / "config"
+
+ def test_devices_and_connect_writes_make_the_file_private(self):
+ import frame_connect as fc
+ self.config.write_text("Host other\n User me\n", encoding="utf-8")
+ with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
+ mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
+ fc.write_config("10.0.0.5")
+ self.assertTrue(fd.repair_permissions(self.config))
+ fd.rewrite_block("frame", path=self.config, user="deck")
+ self.assertEqual(private.call_count, 3)
+ self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
+ self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
+ self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
+
+ def test_setup_runs_isolated_as_the_app_starts_it(self):
+ r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
+ capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
+ self.assertNotIn("ModuleNotFoundError", r.stderr)
+ self.assertIn("frame_connect.py", r.stdout + r.stderr)
+
+ def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
+ self.assertFalse(fd.repair_permissions(self.config))
+ data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
+ self.config.write_bytes(data)
+ with mock.patch.object(frame_host, "make_private", return_value=True):
+ self.assertTrue(fd.repair_permissions(self.config))
+ self.assertEqual(self.config.read_bytes(), data)
+
+ def test_repair_fails_without_the_acl_and_leaves_the_file(self):
+ self.config.write_bytes(b"Host a\n")
+ before = self.config.stat().st_ino
+ with mock.patch.object(frame_host, "make_private", return_value=False):
+ self.assertFalse(fd.repair_permissions(self.config))
+ self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
+ self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
+
+
+class ServerRepair(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ import server
+ cls.server = server
+
+ def setUp(self):
+ self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
+ self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
+ (self.ssh / "config").write_text("Host a\n", encoding="utf-8")
+ patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
+ mock.patch.object(frame_host, "WINDOWS", True),
+ mock.patch.object(self.server, "_config_repaired", False)]
+ for p in patches:
+ p.start()
+ self.addCleanup(p.stop)
+
+ def test_repairs_the_refused_config_once(self):
+ with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
+ self.assertTrue(self.server.repair_ssh_config(REFUSED))
+ self.assertFalse(self.server.repair_ssh_config(REFUSED))
+ repair.assert_called_once()
+
+ def test_leaves_other_files_and_errors_alone(self):
+ key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
+ with mock.patch.object(fd, "repair_permissions") as repair:
+ self.assertFalse(self.server.repair_ssh_config(key))
+ self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
+ with mock.patch.object(frame_host, "WINDOWS", False):
+ self.assertFalse(self.server.repair_ssh_config(REFUSED))
+ repair.assert_not_called()
+
+ def test_ssh_retries_after_repairing(self):
+ results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
+ with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
+ mock.patch.object(fd, "repair_permissions", return_value=True), \
+ mock.patch.object(self.server, "LINK", None):
+ self.assertEqual(self.server.ssh("true"), "ok")
+
+
+class LinkLocalZone(unittest.TestCase):
+ """A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
+
+ def probe(self, windows):
+ import frame_link as fl
+ info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
+ sock = mock.MagicMock()
+ with mock.patch.object(frame_host, "WINDOWS", windows), \
+ mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
+ mock.patch.object(fl.socket, "socket", return_value=sock), \
+ mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
+ return fl.probe("frame.local", 22)["ip"]
+
+ def test_windows_uses_the_numeric_zone(self):
+ self.assertEqual(self.probe(True), "fe80::1%12")
+
+ def test_elsewhere_uses_the_interface_name(self):
+ self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/ui/apk_sources/fdroid.py b/ui/apk_sources/fdroid.py
index 0006dba..ff02cb4 100644
--- a/ui/apk_sources/fdroid.py
+++ b/ui/apk_sources/fdroid.py
@@ -27,6 +27,7 @@ from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 2
+TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
_LOCK = threading.RLock() # settings only; never held while downloading
_load_locks = {}
_refreshing = {} # source id -> background refresh thread
@@ -530,7 +531,7 @@ def _load(source, force=False):
return found[:2]
cache.parent.mkdir(parents=True, exist_ok=True)
try:
- with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
+ with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-', dir=str(cache.parent)) as tmp:
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try:
diff --git a/ui/control-center.css b/ui/control-center.css
new file mode 100644
index 0000000..ca0d911
--- /dev/null
+++ b/ui/control-center.css
@@ -0,0 +1,49 @@
+/* Local to the sixth tab: the existing Home, Games, Android and Tools keep
+ their layouts. The same Steam palette gives the dashboard a quieter rhythm. */
+.cc { gap:24px; }
+.cc :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
+.cc-hero { display:flex; justify-content:space-between; align-items:center; gap:24px; padding:28px 30px;
+ border:1px solid #66c0f421; border-radius:12px; background:radial-gradient(ellipse at 90% 0%,#1a9fff19,transparent 65%),linear-gradient(125deg,#21364a,#152230); }
+.cc-eyebrow { font-size:11px; font-weight:700; letter-spacing:2px; text-transform:uppercase; color:var(--link); margin-bottom:7px; }
+.cc-hero h1 { font-size:clamp(25px,3vw,36px); font-weight:500; letter-spacing:-.7px; margin:0 0 8px; color:white; }
+.cc-hero p { color:#adb9c5; margin:0; max-width:580px; }
+.cc-hero > div { min-width:0; }
+.cc-hero-mark { flex:none; width:114px; height:82px; color:var(--link); opacity:.85; }
+.cc-topline { display:flex; gap:14px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
+.cc-topline h2 { font-size:17px; font-weight:500; margin:0; color:var(--bright); min-width:0; max-width:100%; overflow-wrap:anywhere; }
+.cc-topline button { margin-left:auto; border-radius:6px; }
+.cc-sample { font-size:12px; color:var(--muted); }.cc-offline { color:#cfb787; }
+.cc-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
+.cc-metric { border:1px solid #ffffff0c; border-radius:9px; padding:18px 20px; background:linear-gradient(145deg,#25374a80,#18232f); min-width:0; }
+.cc-metric > span { color:#a7b8c8; font-size:12px; }.cc-metric > strong { display:block; color:white; font-size:clamp(20px,2.2vw,30px); font-weight:500; margin:7px 0 3px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
+.cc-metric > small { color:var(--muted); display:block; font-size:11.5px; }
+.cc-device-facts { margin:14px 0 0; padding:16px 20px; border:1px solid #ffffff0a; border-radius:9px; background:#10192366; }
+.cc-device-facts summary { cursor:pointer; color:#b8cbd9; font-size:12px; }.cc-device-facts dl { margin:18px 0 0; }
+.cc-readings { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px 18px; padding:0; }
+.cc-readings div { min-width:0; }.cc-readings dt { color:var(--muted); font-size:11px; }.cc-readings dd { margin:3px 0 0; color:#d9e7f2; font-size:13px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
+.cc-device-facts .cc-readings { grid-template-columns:repeat(4,minmax(0,1fr)); }
+.cc-services { display:flex; flex-wrap:wrap; gap:12px 22px; border-top:1px solid #ffffff0b; margin-top:18px; padding-top:14px; }
+.cc-services > span { display:flex; gap:7px; align-items:center; font-size:11px; color:#b3c5d4; }.cc-services b { color:var(--muted); font-weight:400; }
+.cc-workspace { display:grid; grid-template-columns:220px minmax(0,1fr); gap:26px; align-items:start; }
+.cc-workspace > * { min-width:0; width:100%; max-width:100%; }
+.cc-sidebar { position:sticky; top:90px; }.cc-sidebar > p { margin:0 0 12px 12px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:1.5px; }
+.cc-categories { display:flex; flex-direction:column; gap:5px; min-width:0; max-width:100%; }.cc-categories button { display:flex; align-items:center; text-align:left; gap:10px; border-radius:7px; background:transparent; min-height:43px; padding:10px 12px; color:#a8b8c8; font-size:12px; }
+.cc-categories svg { width:17px; height:17px; flex:none; }.cc-category-count { margin-left:auto; font-size:11px; opacity:.65; }
+.cc-categories button:hover { background:#66c0f40a; color:white; }.cc-categories button[aria-pressed=true] { background:#1a9fff1a; color:var(--link); box-shadow:inset 2px 0 var(--blue); }
+.cc-status { margin-top:24px; padding:18px 14px; border-top:1px solid #ffffff10; }.cc-status > div { display:flex; align-items:center; gap:8px; font-size:12px; color:#c4d2dc; }.cc-status p { margin:10px 0 0; color:var(--muted); font-size:11.5px; line-height:1.6; }
+.cc-search-row { display:flex; align-items:center; gap:14px; margin-bottom:18px; }.cc-search { display:flex; align-items:center; gap:10px; border:1px solid #66c0f426; border-radius:8px; padding:0 12px; background:#0e1824; flex:1; min-width:0; }
+.cc-search:focus-within { border-color:var(--blue); }.cc-search svg { width:18px; color:var(--muted); flex:none; }.cc-search input { width:100%; min-width:0; padding:13px 0; border:0; background:transparent; font-size:13px; }.cc-search input:focus { outline:none; }.cc-search button { padding:3px 7px; background:transparent; color:var(--muted); font-size:18px; }.cc-results { color:var(--muted); font-size:11px; white-space:nowrap; }
+.cc-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }.cc-card { display:flex; flex-direction:column; min-width:0; padding:22px; background:linear-gradient(150deg,#202e3d,#17212c); border:1px solid #ffffff0e; border-radius:10px; }
+.cc-card-head { display:flex; gap:10px; align-items:center; justify-content:space-between; margin-bottom:16px; }.cc-icon { width:34px; height:34px; display:grid; place-items:center; background:#66c0f40c; border-radius:8px; color:#85bfe2; }.cc-icon svg { width:20px; height:20px; }
+.cc-kind { font-size:10px; padding:3px 7px; border-radius:4px; color:#a2b3c2; background:#ffffff06; letter-spacing:.2px; }.cc-kind-control { color:#92cdeb; background:#1a9fff12; }.cc-kind-headset { color:#d3c095; background:#d9a23a0b; }.cc-kind-read { color:#9fceb9; background:#59bf4009; }
+.cc-card h2 { font-size:17px; font-weight:500; color:white; margin:0 0 8px; }.cc-card > p { font-size:12.5px; color:#a4b4c3; line-height:1.65; margin:0 0 14px; }
+.cc-guidance { padding:11px 12px; border-left:2px solid #9e876544; border-radius:0 4px 4px 0; background:#ffffff03; color:#bcb7aa; font-size:11.5px; line-height:1.6; margin-bottom:14px; }
+.cc-links { display:flex; flex-wrap:wrap; gap:10px 18px; margin-top:auto; padding-top:8px; }.cc-links a { display:inline-flex; gap:8px; align-items:center; color:var(--link); font-size:12px; text-decoration:none; }.cc-links a:hover { color:white; text-decoration:underline; }.cc-links a span { font-size:14px; opacity:.65; }
+.cc-note { font-size:11px; line-height:1.6; color:var(--muted); }.cc-card p.cc-note { margin:10px 0 0; }.cc-audio label { display:flex; justify-content:space-between; font-size:12px; color:#b7c8d6; }.cc-audio output { color:var(--link); font-variant-numeric:tabular-nums; }.cc-audio input { width:100%; margin:15px 0; }.cc-audio-actions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }.cc-audio-actions button { border-radius:5px; }.cc-audio-actions span { flex-basis:100%; margin-top:4px; }.cc-audio input:disabled { opacity:.35; }
+.cc-empty { padding:40px 24px; border:1px dashed #66c0f433; border-radius:10px; text-align:center; color:var(--muted); }.cc-empty h2 { color:white; font-size:18px; font-weight:500; }.cc-empty button { margin-top:12px; border-radius:5px; }
+@media (min-width:641px) and (max-width:1250px) { .brand b { display:none; } header { gap:14px; } nav a { padding:0 9px; font-size:13px; } .pill { min-width:120px; } }
+@media (min-width:641px) and (max-width:900px) { html { scroll-padding-top:116px; } header { flex-wrap:wrap; height:auto; min-height:64px; padding-top:8px; padding-bottom:8px; gap:6px 12px; } nav { order:3; width:100%; height:36px; } nav a { flex:1; justify-content:center; padding:0 6px; } .hdr-end { flex:1; justify-content:flex-end; } .cc-sidebar { top:115px; } }
+@media (max-width:1100px) { .cc-workspace { grid-template-columns:185px minmax(0,1fr); gap:18px; }.cc-card { padding:18px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(3,minmax(0,1fr)); } }
+@media (max-width:900px) { .cc-workspace { grid-template-columns:minmax(0,1fr); }.cc-sidebar { position:static; }.cc-sidebar > p,.cc-status { display:none; }.cc-categories { flex-direction:row; overflow-x:auto; padding-bottom:6px; }.cc-categories button { flex:none; white-space:nowrap; }.cc-category-count { margin-left:2px; }.cc-categories button[aria-pressed=true] { box-shadow:inset 0 -2px var(--blue); }.cc-summary { gap:10px; }.cc-metric { padding:15px; } }
+@media (max-width:640px) { .cc { gap:20px; }.cc-hero { padding:22px 20px; }.cc-hero p { font-size:12px; }.cc-hero-mark { width:72px; height:55px; }.cc-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-metric > strong { font-size:24px; }.cc-topline { gap:8px; }.cc-sample { order:3; width:100%; }.cc-device-facts { padding:14px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-cards { grid-template-columns:minmax(0,1fr); gap:12px; }.cc-search-row { gap:8px; }.cc-results { font-size:10px; }.cc-card { padding:20px; }.cc-card h2 { font-size:18px; } }
+@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-links a { min-height:44px; }.cc-audio-actions button,.cc-topline button { min-height:44px; }.cc-search button { min-width:36px; min-height:44px; } }
diff --git a/ui/control-center.js b/ui/control-center.js
new file mode 100644
index 0000000..4a835d1
--- /dev/null
+++ b/ui/control-center.js
@@ -0,0 +1,318 @@
+/* The Control Center uses the shared page's guarded api() and status poll.
+ * Browsing never starts a stream, installs a helper or changes the headset. */
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory();
+ else root.FrameControlCenter = factory();
+})(typeof window === 'object' ? window : globalThis, function () {
+ 'use strict';
+ const categories = [
+ ['all', 'Everything', 'grid'], ['device', 'Device & audio', 'device'],
+ ['vr', 'VR & comfort', 'eye'], ['apps', 'Apps & files', 'apps'],
+ ['network', 'Connection & remote', 'network'], ['companion', 'Frame Control', 'settings'],
+ ];
+ // Each destination is an existing workflow, with its own guards and confirmations.
+ const catalog = [
+ {id:'audio', category:'device', icon:'audio', title:'Audio', kind:'control', label:'Available here',
+ description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.',
+ keywords:'sound speakers headphones loud quiet', special:'audio'},
+ {id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics',
+ description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.',
+ keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
+ {id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
+ description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
+ keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#apps','Linux apps'],['#power','SFTP & remote tools']]},
+ {id:'microphone', category:'device', icon:'audio', title:'Microphone & audio routing', kind:'headset', label:'In the headset',
+ description:'Frame Control controls the default output volume only. Microphone mute, input gain, output selection, balance and per-app mixers have no verified API here.',
+ keywords:'mic input microphone audio sink output device gain mute eq equalizer spatial audio balance headphones',
+ guidance:'Check the headset’s audio controls or the app’s own audio settings for these adjustments.'},
+ {id:'power', category:'device', icon:'power', title:'Power & sleep', kind:'flow', label:'Existing controls',
+ description:'Sleep, restart or shut down with confirmation and your Developer Mode password. A sleeping Frame must be woken on the device.',
+ keywords:'battery suspend reboot off awake idle timeout keep awake inhibitor', links:[['#power','Power controls']],
+ guidance:'Set automatic sleep in the headset: Steam Settings → Power → Sleep after inactivity. Keep-awake is available through the existing script/MCP tools; there is no switch here.'},
+ {id:'system', category:'device', icon:'device', title:'SteamOS & hardware', kind:'headset', label:'In the headset',
+ description:'Check your current OS build above. Manage system updates, Developer Mode and hardware settings in Steam Settings on the Frame.',
+ keywords:'firmware update beta stable channel developer password bluetooth controller battery pairing calibration brightness refresh rate display IPD lenses passthrough camera eye hand tracking foveation recovery factory reset',
+ guidance:'Brightness, refresh rate, optics, controllers, Bluetooth and passthrough have no verified remote controls here. Check the headset’s own controls. Recovery and factory reset are manual procedures.'},
+ {id:'performance', category:'vr', icon:'pulse', title:'Performance monitor', kind:'read', label:'Read-only metrics',
+ description:'CPU, GPU clock, app and compositor timings come from the Frame. Unavailable timings stay empty, including when SteamVR is in standby.',
+ keywords:'fps frame rate gpu cpu heat temperature thermal sensor render memory', special:'performance', links:[['#vrTools','HUD & performance tools']]},
+ {id:'view', category:'vr', icon:'eye', title:'Headset view & casting', kind:'flow', label:'Existing controls',
+ description:'Capture the lenses or desktop, stream live video, and control a focused panel. Casting can reveal private content.',
+ keywords:'screenshot camera video capture live stream desktop mirror control eye', links:[['#view','Open headset view'],['#shots','Screenshots']]},
+ {id:'comfort', category:'vr', icon:'clock', title:'Family & comfort', kind:'flow', label:'Existing controls',
+ description:'Set a session limit, breaks and check-ins, with battery and heat alerts. Headset reminders continue if your companion disconnects.',
+ keywords:'parent child timer minutes notifications health break low battery', links:[['#comfort','Configure a session']],
+ guidance:'A reminder, not a parental lock. Save and pause before the session returns to Steam Home.'},
+ {id:'playspace', category:'vr', icon:'space', title:'Playspace & movement', kind:'headset', label:'In the headset',
+ description:'Use SteamVR’s recenter and room setup. Choose seated play, snap-turn, teleport and vignettes in each game’s own settings.',
+ keywords:'boundary guardian tracking origin floor standing seated motion sickness locomotion turn recenter room',
+ guidance:'No verified universal locomotion switch or remote tracking-origin control is available.'},
+ {id:'panels', category:'vr', icon:'grid', title:'Panels & workspace', kind:'flow', label:'Existing controls',
+ description:'Find an open panel and bring it forward, or open the panel switcher in the headset. Move, size and dock panels with SteamVR’s dashboard.',
+ keywords:'overlay window layout float world curvature docking theatre multitasking', links:[['#panels','Open panel switcher']],
+ guidance:'Saved spatial layouts are not available. Panel placement stays in SteamVR.'},
+ {id:'steam', category:'apps', icon:'game', title:'Steam library', kind:'flow', label:'Existing controls',
+ description:'Browse owned games and Frame ratings, install with progress, or launch an installed game. Purchases and license prompts use Steam.',
+ keywords:'games download install store owned compatibility rating verified proton', links:[['#library','Installed games'],['#getgames','Library & store']]},
+ {id:'android', category:'apps', icon:'apps', title:'Android apps', kind:'flow', label:'Existing controls',
+ description:'Discover apps, manage Lepton instances, and launch, stop, test or remove installed apps. View compatibility reports before installing.',
+ keywords:'apk fdroid lepton application sideload sources repository reports', links:[['#appStore','Discover apps'],['#androidLibrary','Manage installed apps']]},
+ {id:'sources', category:'apps', icon:'shield', title:'App sources & compatibility', kind:'flow', label:'Existing controls',
+ description:'Choose catalogue sources or add a signed F-Droid repository from Sources. Review install availability and compatibility reports for the exact app.',
+ keywords:'fdroid f-droid repository source fingerprint trust signature compatibility report apk sidequest data backup restore', links:[['#appStore','Catalogue & sources'],['#androidLibrary','Apps & reports']],
+ guidance:'Android data backup and restore are script-only. This interface does not provide an app-data recovery workflow.'},
+ {id:'display', category:'apps', icon:'display', title:'Android display & text', kind:'flow', label:'Existing controls',
+ description:'Choose a running Android instance, then set resolution, UI density and text size, or reset to its defaults.',
+ keywords:'dpi scaling font scale sharp native 1920 1080 2560 1440 resolution screen text accessibility', links:[['#display','Configure app display']],
+ guidance:'Applies to that Lepton instance, not the headset’s lenses. Persistence after relaunch is unverified.'},
+ {id:'sideload', category:'apps', icon:'game', title:'Sideloaded games', kind:'flow', label:'Existing controls',
+ description:'Send a game folder, ZIP or executable. Frame Control selects Proton or the Linux runtime and adds a Steam library entry.',
+ keywords:'windows exe zip linux devkit runtime shortcuts launch remove', links:[['#sideloaded','Manage titles'],['#transfer','Send a game']]},
+ {id:'files', category:'apps', icon:'files', title:'Files & clipboard', kind:'flow', label:'Existing controls',
+ description:'Send files to ~/Downloads, or send text to the Frame’s clipboard. Clipboard transfer needs the desktop panel open.',
+ keywords:'upload transfer downloads paste copy text sftp storage', links:[['#transfer','Send files & text']]},
+ {id:'media', category:'apps', icon:'play', title:'Video & stereo photos', kind:'flow', label:'Preview feature',
+ description:'Send and play H.264/H.265 video, SBS/OU photos or small splats with the built-in player. Choose a layout and theatre surround.',
+ keywords:'movie photo sbs ou 3d splat gaussian theatre media player', links:[['#media','Open media player']],
+ guidance:'Seeking, subtitles, native HEIC/MPO and large splat scenes are not supported yet.'},
+ {id:'flatpak', category:'apps', icon:'apps', title:'Linux desktop apps', kind:'flow', label:'Existing controls',
+ description:'Install user Flatpaks by Flathub ID, choose quick picks like VLC and Moonlight, and manage installed apps.',
+ keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#apps','Manage Linux apps']]},
+ {id:'connection', category:'network', icon:'network', title:'Headsets & addresses', kind:'flow', label:'Existing controls',
+ description:'Manage headsets, LAN, USB-C, mDNS and Tailscale addresses. Inspect connection attempts and choose the device you want to control.',
+ keywords:'wifi wi-fi network ip ssh pairing keys discovery alias tailscale usb offline reconnect', links:[['#devices','Manage headsets','desktop'],['#connection','Connection details']],
+ guidance:'Join Wi-Fi networks on the headset. Frame Control manages connection addresses; it does not change Wi-Fi credentials.'},
+ {id:'remote', category:'network', icon:'display', title:'Remote access', kind:'flow', label:'Existing controls',
+ description:'Open SSH, SFTP, Steam Link or remote desktop using the host’s existing tools. Developer Mode enables headset SSH and xrdp.',
+ keywords:'terminal shell remote desktop rdp xrdp streaming steam link sftp', links:[['#power','Open remote tools']]},
+ {id:'input', category:'network', icon:'keyboard', title:'Keyboard & trackpad', kind:'flow', label:'Existing controls',
+ description:'Type and point in the Frame’s apps with KDE Connect, or use Control on the live panel view. First use sets up the input helper.',
+ keywords:'mouse pointer keyboard trackpad touch click kde input', links:[['#pad','Open keyboard & trackpad'],['#view','Panel control']]},
+ {id:'mac', category:'network', icon:'display', title:'Mac in the headset', kind:'flow', label:'macOS only', platform:'mac',
+ description:'Stream Mac screens or individual windows into headset panels. Choose quality and give each window its own display.',
+ keywords:'macos mac window screen sharing stream quality sharp balanced light permissions', links:[['#macview','Configure Mac streaming']],
+ guidance:'Requires Frame Control running on a Mac. It is unavailable when the server runs on Windows, Linux or the headset.'},
+ {id:'privacy', category:'companion', icon:'shield', title:'Privacy & sharing', kind:'flow', label:'Companion settings',
+ description:'Choose anonymous usage statistics, compatibility sharing and error details separately. Inspect what was sent and manage your optional contact email.',
+ keywords:'telemetry analytics diagnostics consent data tracking email contact opt out', links:[['#privacy','Privacy settings']]},
+ {id:'artwork', category:'companion', icon:'image', title:'Library artwork', kind:'flow', label:'Companion settings',
+ description:'Configure an optional SteamGridDB key or refresh artwork. Without a key, source images and generated art remain available.',
+ keywords:'cover image steamgriddb api key library art refresh', links:[['#artworkSettings','Artwork settings']]},
+ {id:'assistant', category:'companion', icon:'spark', title:'Assistant & agents', kind:'flow', label:'Opt-in tools',
+ description:'Use your own model endpoint and review proposed actions. Nothing goes to a model until you opt in; protected actions need approval.',
+ keywords:'ai mcp model endpoint agent approvals chat assistant', links:[['/assistant','Open assistant']]},
+ {id:'support', category:'companion', icon:'help', title:'Updates & diagnostics', kind:'flow', label:'Companion settings',
+ description:'Check Frame Control updates in the desktop app or prepare a private problem report. Review the diagnostic preview before sending.',
+ keywords:'app version bug feedback support update log error report licenses', links:[['#privacy','Updates & problem reports']]},
+ ];
+ const paths = {
+ grid:'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
+ device:'M3 7h18v10H3z M8 11v2 M16 11v2', eye:'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0',
+ apps:'M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z M17 14v6 M14 17h6',
+ network:'M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01',
+ settings:'M4 6h16 M4 12h16 M4 18h16 M8 3v6 M16 9v6 M10 15v6',
+ audio:'M3 9h4l5-4v14l-5-4H3z M16 8a6 6 0 0 1 0 8 M19 5a10 10 0 0 1 0 14',
+ power:'M12 2v10 M6 5a9 9 0 1 0 12 0', pulse:'M2 12h5l3-8 4 16 3-8h5',
+ clock:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2',
+ space:'M3 8l9-5 9 5v8l-9 5-9-5z M3 8l9 5 9-5 M12 13v8',
+ game:'M6 8h12l3 9-3 2-4-4h-4l-4 4-3-2z M7 10v4 M5 12h4 M16 11h.01 M18 13h.01',
+ display:'M3 4h18v13H3z M8 21h8 M12 17v4', files:'M3 5h7l2 3h9v12H3z',
+ play:'M8 4l12 8-12 8z', keyboard:'M2 6h20v12H2z M6 10h.01 M10 10h.01 M14 10h.01 M18 10h.01 M6 14h12',
+ shield:'M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6z M8 12l3 3 5-6',
+ image:'M3 3h18v18H3z M3 17l6-6 5 5 3-3 4 4 M16 7h.01',
+ spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3z',
+ help:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M9 8a3 3 0 0 1 6 1c0 2-3 2-3 5 M12 17h.01',
+ };
+ const icon = name => ``;
+ function matches(item, query, category) {
+ const text = [item.title, item.description, item.keywords, item.guidance, item.label,
+ categories.find(([id])=>id === item.category)?.[1]].join(' ').toLowerCase();
+ return (category === 'all' || item.category === category) && query.toLowerCase().trim().split(/\s+/).every(word => text.includes(word));
+ }
+ const number = (value, suffix = '', digits = 0) => Number.isFinite(value) ? value.toLocaleString(undefined, {maximumFractionDigits:digits}) + suffix : 'Unavailable';
+ const gib = value => Number.isFinite(value) ? number(value / 1073741824, ' GiB', 1) : 'Unavailable';
+ const duration = seconds => Number.isFinite(seconds) && seconds >= 0 ? seconds < 3600 ? `${Math.floor(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.floor(seconds % 3600 / 60)} min` : 'Unavailable';
+ function metrics(sample) {
+ const s = sample || {}, b = s.battery || {}, p = s.performance || {}, h = s.disk?.home, m = s.memory;
+ const capacity = b.percent;
+ const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5;
+ const charging = b.status === 'Charging';
+ const full = b.status === 'Full' || (!!s.power && capacity >= 100);
+ const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' :
+ full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery';
+ return {
+ battery:number(capacity, '%'), batteryDetail:batteryState,
+ storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable',
+ memory:m && Number.isFinite(m.total) && Number.isFinite(m.available) ? gib(m.total - m.available) : 'Unavailable',
+ memoryDetail:m ? `${gib(m.total)} total · system memory used` : 'Memory sensor unavailable',
+ rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available),
+ fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS',
+ power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable',
+ batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable',
+ estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
+ wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable',
+ uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable',
+ temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1),
+ appFps:number(p.appFps, '', 1), frame:number(p.frameMs, ' ms', 2), render:number(p.gpuMs, ' ms', 2), compositorCpu:number(p.compositorCpuMs, ' ms', 2),
+ thermal:!Array.isArray(s.thermalAlerts) ? 'Trip data unavailable' : s.thermalAlerts.length ? s.thermalAlerts.map(a => `${a.zone || 'Sensor'} ${number(a.tempC, ' °C')} ≥ ${number(a.limitC, ' °C')}`).join('; ') : 'No hot / critical trip reported',
+ };
+ }
+ // Strict validation and captured device identity apply even if a stale enabled
+ // button is clicked during a disconnect, headset switch, or pending request.
+ function createController({api, getContext}) {
+ let busy = false;
+ return {
+ get busy() { return busy; },
+ async audio(change) {
+ if (busy) throw new Error('An audio change is still in progress.');
+ const context = getContext();
+ if (context.online !== true || !context.fresh || !context.sample?.volume) throw new Error('Connect and refresh the Frame before changing audio.');
+ const keys = Object.keys(change);
+ if (!keys.length || keys.some(k => !['level','muted'].includes(k)) ||
+ ('level' in change && (typeof change.level !== 'number' || !Number.isFinite(change.level) || change.level < 0 || change.level > 1)) ||
+ ('muted' in change && typeof change.muted !== 'boolean')) throw new Error('Use a volume from 0 to 100% and a valid mute state.');
+ busy = true;
+ try {
+ const result = await api('/api/volume', change, context.device);
+ if (getContext().generation !== context.generation) return null;
+ return result;
+ } finally { busy = false; }
+ },
+ };
+ }
+ function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform}) {
+ const get = id => document.getElementById(id), page = get('controlCenter');
+ let category = 'all', sample = null, receivedAt = null, available = false, error = '', dirty = false, sampleGeneration = null, audioPending = false;
+ const metric = (id, label) => `
` : ''}`).join('');
+ // Host-only features stay searchable with a reason; only their unsupported
+ // action disappears. The host API, rather than viewport/UA, decides availability.
+ let macSupported = platform === 'darwin';
+ const visibleCatalog = catalog;
+ function filter() {
+ const query = get('ccSearch').value;
+ let count = 0;
+ catalog.forEach(item => {
+ const visible = visibleCatalog.includes(item) && matches(item, query, category);
+ page.querySelector(`[data-cc-card="${item.id}"]`).hidden = !visible;
+ if (visible) count++;
+ });
+ get('ccResultCount').textContent = `${count} ${count === 1 ? 'section' : 'sections'}`;
+ get('ccEmpty').hidden = count > 0;
+ get('ccClear').hidden = !query;
+ page.querySelectorAll('[data-cc-category]').forEach(button => {
+ button.setAttribute('aria-pressed', String(button.dataset.ccCategory === category));
+ button.querySelector('.cc-category-count').textContent = visibleCatalog.filter(item=>matches(item, query, button.dataset.ccCategory)).length;
+ });
+ page.querySelectorAll('[data-cc-mac-link]').forEach(link=>{link.hidden = !macSupported;});
+ }
+ get('ccSearch').addEventListener('input', filter);
+ function resetFilter() { get('ccSearch').value = ''; category = 'all'; filter(); get('ccSearch').focus(); }
+ get('ccClear').onclick = () => { get('ccSearch').value = ''; filter(); get('ccSearch').focus(); };
+ get('ccResetFilter').onclick = resetFilter;
+ get('ccCategories').onclick = event => {
+ const button = event.target.closest('[data-cc-category]');
+ if (!button) return;
+ category = button.dataset.ccCategory; filter();
+ };
+ page.querySelector('[href="#connection"]').onclick = event => { event.preventDefault(); openConnection(); };
+ // The headset clock can differ from this client's. Age uses the client
+ // receipt time, retained by the shared poll when it buffers an early sample.
+ const isFresh = () => available && Number.isFinite(receivedAt) && Date.now() >= receivedAt && Date.now() - receivedAt <= 90000;
+ const context = () => ({...getContext(), sample, fresh:isFresh() && sampleGeneration === getContext().generation});
+ const controller = createController({api, getContext:context});
+ function audioAvailability() {
+ const enabled = context().online === true && context().fresh && !!sample?.volume && !controller.busy && !audioPending;
+ ['ccVolume','ccApplyVolume','ccMute'].forEach(id=>{get(id).disabled = !enabled;});
+ }
+ function renderSample() {
+ const values = metrics(isFresh() ? sample : null);
+ page.querySelectorAll('[data-cc-metric]').forEach(el=>{el.textContent = values[el.dataset.ccMetric];});
+ get('ccDeviceName').textContent = isFresh() ? sample.hostname || 'Steam Frame' : 'Steam Frame';
+ const sampledAt = Number.isFinite(sample?.time) && sample.time > 0 && sample.time * 1000 <= 8640000000000000 ? new Date(sample.time * 1000) : null;
+ get('ccSampleState').textContent = isFresh() ? `${sampledAt ? 'Sampled ' + sampledAt.toLocaleTimeString() : 'Received ' + new Date(receivedAt).toLocaleTimeString()} · updates every 30 seconds` :
+ error || (receivedAt !== null ? 'Sample expired · refresh to read current metrics' : 'Connect your Frame to read live metrics');
+ get('ccSampleState').classList.toggle('cc-offline', !isFresh());
+ get('ccStatusLabel').textContent = isFresh() ? 'Connected' : 'No live sample';
+ get('ccStatusDot').classList.toggle('on', isFresh());
+ get('ccStatusNote').textContent = isFresh() ? 'Read-only status over SSH. Browse freely; controls run only when you use them.' :
+ 'Settings guides and companion tools remain available. Wake the Frame on the device, then reconnect.';
+ get('ccBuild').textContent = isFresh() ? `SteamOS ${values.os} · build ${values.build}` : 'System information unavailable';
+ const services = isFresh() ? sample.services || {} : {};
+ get('ccServices').innerHTML = [['steamvr','SteamVR'],['desktop','Desktop'],['lepton','Lepton · port 5555'],['rdp','RDP process']].map(([key,label])=>`${label}${typeof services[key] === 'boolean' ? services[key] ? 'Running' : 'Not detected' : 'Unavailable'}`).join('');
+ if (sample?.volume && isFresh()) {
+ const pct = Math.round(sample.volume.level * 100);
+ if (!dirty && !controller.busy && !audioPending) get('ccVolume').value = Math.min(100,Math.max(0,pct));
+ get('ccVolumeValue').textContent = `${get('ccVolume').value}%`;
+ get('ccAudioReadback').textContent = `Reported ${pct}% · ${sample.volume.muted ? 'muted' : 'unmuted'}`;
+ get('ccMute').textContent = sample.volume.muted ? 'Unmute' : 'Mute';
+ } else {
+ get('ccAudioReadback').textContent = isFresh() ? 'Audio reading unavailable' : 'Waiting for an audio reading';
+ get('ccVolumeValue').textContent = '—';
+ }
+ audioAvailability();
+ }
+ async function changeAudio(change) {
+ if (audioPending) return;
+ const generation = getContext().generation;
+ audioPending = true;
+ get('ccAudioResult').textContent = 'Applying…';
+ try {
+ const pending = controller.audio(change);
+ audioAvailability();
+ const result = await pending;
+ if (!result || generation !== getContext().generation) return;
+ if ('level' in change) dirty = false;
+ get('ccAudioResult').textContent = `${result.message || 'Audio updated'}. Refreshing the reading…`;
+ await refreshAudio();
+ if (generation !== getContext().generation) return;
+ if (!isFresh() || !sample?.volume) {
+ get('ccAudioResult').textContent = 'Audio command sent; current output could not be confirmed. Reconnect and refresh.';
+ } else {
+ const reported = sample.volume;
+ const matched = (!('level' in change) || Math.round(reported.level * 100) === Math.round(change.level * 100)) &&
+ (!('muted' in change) || reported.muted === change.muted);
+ get('ccAudioResult').textContent = `${matched ? 'Confirmed' : 'Command sent · reported output differs'}: ${Math.round(reported.level * 100)}%, ${reported.muted ? 'muted' : 'unmuted'}.${matched ? '' : ' Refresh to check again.'}`;
+ }
+ } catch (e) {
+ if (generation !== getContext().generation) return;
+ get('ccAudioResult').textContent = `Couldn’t apply audio: ${e.message}`;
+ } finally { audioPending = false; renderSample(); }
+ }
+ get('ccVolume').oninput = () => { dirty = true; get('ccVolumeValue').textContent = `${get('ccVolume').value}%`; };
+ get('ccApplyVolume').onclick = () => changeAudio({level:Number(get('ccVolume').value) / 100});
+ get('ccMute').onclick = () => changeAudio({muted:!sample?.volume?.muted});
+ get('ccRefresh').onclick = async () => {
+ get('ccRefresh').disabled = true;
+ try { await refresh(); } finally { get('ccRefresh').disabled = false; }
+ };
+ filter(); renderSample();
+ // Expire UI readings even when the shared poll is paused in a hidden tab.
+ const timer = setInterval(()=>{if (receivedAt !== null && available && !isFresh()) { available = false; renderSample(); }}, 5000);
+ return {
+ host(value) { macSupported = !value.mobile && value.os === 'macOS'; filter(); },
+ status(value, receipt = Date.now(), read = {valid: true, error: null}) {
+ // A redraw is not a successful read: preserve receipt age and any local
+ // invalidation. Shared validity also carries failures across late mount.
+ const replay = sample === value;
+ receivedAt = replay ? (Number.isFinite(receivedAt) && Number.isFinite(receipt) ? Math.min(receivedAt, receipt) : receivedAt) : receipt;
+ sample = value;
+ if (!read.valid) {
+ available = false; error = `No live sample${read.error?.message ? ' · ' + read.error.message : ''}`;
+ } else if (!replay) { available = true; error = ''; }
+ sampleGeneration = getContext().generation; renderSample();
+ },
+ unavailable(reason) { if (reason?.stale) return; available = false; error = `No live sample${reason?.message ? ' · ' + reason.message : ''}`; renderSample(); },
+ connection(ok) { if (!ok) { available = false; renderSample(); } else audioAvailability(); },
+ reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; renderSample(); },
+ destroy() { clearInterval(timer); },
+ };
+ }
+ return {catalog, categories, matches, metrics, createController, mount};
+});
diff --git a/ui/frame_agent.py b/ui/frame_agent.py
index b8a3dbe..40b003d 100644
--- a/ui/frame_agent.py
+++ b/ui/frame_agent.py
@@ -9,6 +9,8 @@ import subprocess
import threading
import time
+TMP_PREFIX = 'frame-agent-' # then the server's PID, so server.sweep_tmp can clear a stopped run's
+
class Approvals:
def __init__(self):
@@ -109,7 +111,7 @@ def call(server, body):
if name == 'send_file':
# Stage the reviewed bytes before the existing transfer helper reads them.
import tempfile
- with tempfile.TemporaryDirectory(prefix='frame-agent-') as tmp:
+ with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
source = Path(action['arguments']['path'])
with source.open('rb') as stream:
data = stream.read(16 * 1024**2 + 1)
diff --git a/ui/frame_android.py b/ui/frame_android.py
index 4345971..e0613f6 100644
--- a/ui/frame_android.py
+++ b/ui/frame_android.py
@@ -7,22 +7,23 @@ in the Steam library and gets its own SteamVR panel. Nothing goes through
Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py
- install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
+ install APK [SPLIT.apk ...] [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
"""
-import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
+import base64, gzip, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import frame_apk
import frame_artwork
import frame_host
import tempfile
import zipfile
-from frame_apk_vr import add_launcher_category
+from frame_apk_vr import add_launcher_category, drop_split_required
from frame_apk_sign import repack
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
+TMP_PREFIX = 'frame-vr-' # then the server's PID, so server.sweep_tmp can clear a stopped run's patched APK
APPS_DIR = 'Applications/Android' # relative to the Frame's $HOME
COMPAT = '.local/share/Steam/steamapps/compatdata'
SHADERS = '.local/share/Steam/steamapps/shadercache'
@@ -33,7 +34,8 @@ SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
XR_COMPAT = os.path.join(ROOT, 'frame', 'openxr-compat')
XR_COMPAT_FILES = {
'assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json': 'XrApiLayer_FRAME_compat.json',
- 'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so',
+ # Gzipped in the repo and the app; see frame/openxr-compat/build.sh for why.
+ 'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz',
}
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
@@ -43,16 +45,37 @@ class FrameError(RuntimeError):
pass
+class LayerMissing(FrameError):
+ """The OpenXR compatibility layer's files aren't in this copy of Frame Control."""
+
+
+LAYER_MISSING = ("Frame Control's OpenXR compatibility layer is missing from this copy "
+ "(frame/openxr-compat/prebuilt); reinstall Frame Control, or in a source "
+ "checkout run frame/openxr-compat/build.sh")
+LAYER_MISSING_NOTE = ("Installed without the OpenXR compatibility layer, which is missing from this "
+ "copy of Frame Control; apps that need OpenXR 1.1 may not start. Reinstalling "
+ "Frame Control restores it.")
+
+
def ssh(cmd, input=None, timeout=120):
try:
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
- p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
- timeout=timeout, text=isinstance(input, str) or input is None)
+ # The Frame writes UTF-8, whatever this computer's code page (Windows would decode it as
+ # cp1252). errors='replace': stray bytes (a file name) mustn't turn a failed command, or
+ # one that worked, into a UnicodeDecodeError.
+ text = ({'text': True, 'encoding': 'utf-8', 'errors': 'replace'}
+ if isinstance(input, str) or input is None else {})
+ p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
+ timeout=timeout, **text)
except subprocess.TimeoutExpired:
- raise FrameError(f'timed out talking to {FRAME}')
+ raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
if p.returncode != 0:
- raise FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
+ # Bytes input gives bytes output: decode it, so the message isn't "b'...'".
+ stderr, stdout = (v if isinstance(v, str) else (v or b'').decode(errors='replace')
+ for v in (p.stderr, p.stdout))
+ error = FrameError((stderr or stdout or f'ssh exited {p.returncode}').strip()[-600:])
+ raise frame_host.link_failure(error) if frame_host.ssh_link_failed(p.returncode, stderr) else error
return p.stdout
@@ -94,25 +117,47 @@ def xr_compat_files(apk_path):
for entry, rel in XR_COMPAT_FILES.items():
try:
with open(os.path.join(XR_COMPAT, rel), 'rb') as f:
- add[entry] = f.read()
- except OSError:
- raise FrameError("the OpenXR compatibility layer isn't built; run frame/openxr-compat/build.sh")
+ data = f.read()
+ add[entry] = gzip.decompress(data) if rel.endswith('.gz') else data
+ except (OSError, EOFError, zlib.error): # gzip.BadGzipFile is an OSError
+ add[entry] = b''
+ if not add[entry]: # missing, unreadable (antivirus, permissions) or truncated
+ raise LayerMissing(LAYER_MISSING)
return add
+def layer_note(meta):
+ """The missing-layer warning for an install's completion message, or ''."""
+ return LAYER_MISSING_NOTE if LAYER_MISSING_NOTE in ((meta or {}).get('vr_issues') or []) else ''
+
+
def check_installable(info):
if info['min_sdk'] and info['min_sdk'] > 30:
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
if info['abis'] and 'arm64-v8a' not in info['abis']:
- raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only")
+ # Sites that offer one APK per ABI (Grayjay: arm64-v8a, armeabi-v7a, x86, x86_64,
+ # universal) leave the choice to the user; say which file to fetch instead.
+ raise FrameError(f"{info['label']} has no arm64-v8a build ({', '.join(info['abis'])}); Lepton is 64-bit ARM only. "
+ "This file is for other devices: download the APK marked arm64-v8a "
+ "(or arm64, or universal) and install that instead")
-_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
+# Installs are rare; one at a time avoids every race. Reentrant so a caller (a Quest import's
+# final checks and data copy) can hold it around install_obb and install, which take it too.
+_install_lock = threading.RLock()
+# The slowest transfer a copy is allowed before it times out, in bytes a second (about 4 Mbit/s,
+# a slow or busy Wi-Fi link). A fixed 10 minutes failed any APK over about 300 MB at that rate.
+COPY_MIN_RATE = 512 * 1024
def _copy(src, dest, executable=False, timeout=600):
- """Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
+ """Copy a local file to the Frame: rsync where installed (not on Windows), else scp.
+ timeout is the least time allowed; a large file gets longer (COPY_MIN_RATE)."""
name = os.path.basename(src)
+ try:
+ timeout = max(timeout, os.path.getsize(src) / COPY_MIN_RATE)
+ except OSError:
+ pass # rsync or scp says what's wrong with the file
rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
if rsync:
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
@@ -120,7 +165,8 @@ def _copy(src, dest, executable=False, timeout=600):
else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
- subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
+ frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True,
+ errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
@@ -146,7 +192,303 @@ def _write_meta(d, meta):
install_hooks = []
-def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
+# Split APK members that belong to the split itself, not the app: never merged.
+SPLIT_OWN = re.compile(r'^(AndroidManifest\.xml|stamp-cert-sha256|META-INF/.*)$')
+# What a mergeable split may add: native libraries and assets, nothing else.
+SPLIT_ADDS = re.compile(r'^(lib/[^/]+/[^/]+\.so|assets/.*[^/])$')
+# Manifest elements a config or install-time asset split carries. Anything else (an
+# activity, service, uses-library...) belongs to a feature split, which needs its manifest.
+SPLIT_TAGS = {'manifest', 'application', 'uses-sdk', 'module', 'delivery', 'install-time', 'fusing'}
+# Every merged byte is held in memory while the APK is repacked and signed (as for every
+# patched APK), so splits are bounded by what's actually inflated, not only the headers.
+MAX_SPLIT_MEMBER = 512 * 1024**2
+MAX_SPLIT_TOTAL = 1024**3
+
+
+def _split_manifest(path, name):
+ """(package, split name) from a split APK's manifest, refusing splits that declare
+ anything the merged app would lose with that manifest."""
+ with zipfile.ZipFile(path) as z:
+ elements = frame_apk.manifest_elements(frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
+ attrs = next((a for tag, a in elements if tag == 'manifest'), {})
+ split = (attrs.get('split') or (None, None, None))[2]
+ feature = attrs.get('isFeatureSplit')
+ if feature and feature[1]:
+ raise FrameError(f'{name} ({split or "split"}) is a feature split with its own code and manifest; '
+ 'Frame Control can only merge splits that add native libraries or assets')
+ extra = sorted({tag for tag, _ in elements if tag not in SPLIT_TAGS})
+ if extra:
+ raise FrameError(f'{name} ({split or "split"}) declares <{">, <".join(extra)}> in its manifest; '
+ 'Frame Control can only merge splits that add native libraries or assets')
+ return (attrs.get('package') or (None, None, None))[2], split
+
+
+def split_entries(base, splits):
+ """What merging splits into base adds: {entry name: (split path, entry name, size)}.
+
+ Lepton runs one APK per instance, so a split app is merged into one APK. Only splits
+ that add native libraries (the arm64 ABI split) or install-time assets can be: a split
+ with code, components or resources (screen density, language) needs its own manifest
+ or resource table, which Frame Control doesn't merge, so those are refused."""
+ try:
+ with zipfile.ZipFile(base) as z:
+ taken = set(z.namelist())
+ package = apk_info(base)['package']
+ plan, total = {}, 0
+ for path in splits:
+ name = os.path.basename(path)
+ split_pkg, split_name = _split_manifest(path, name)
+ label = f'{name} ({split_name or "split"})'
+ if split_pkg != package:
+ raise FrameError(f'{name} is not part of {package} (it says {split_pkg or "nothing"})')
+ with zipfile.ZipFile(path) as z:
+ for member in z.infolist():
+ entry = member.filename
+ if entry.endswith('/') or SPLIT_OWN.match(entry):
+ continue
+ if entry == 'resources.arsc':
+ # A config split for native code may carry an empty table; any resource needs merging.
+ if not resources_empty(frame_apk._read(z, entry, frame_apk.MAX_ARSC)):
+ raise FrameError(f'{label} holds screen-density or language resources; Frame Control '
+ 'can only merge splits that add native libraries or assets')
+ continue
+ if entry.startswith('res/'):
+ raise FrameError(f'{label} holds screen-density or language resources ({entry}); Frame '
+ 'Control can only merge splits that add native libraries or assets')
+ if re.match(r'^classes\d*\.dex$', entry):
+ raise FrameError(f'{label} contains code ({entry}); Frame Control can only merge splits '
+ 'that add native libraries or assets')
+ if not SPLIT_ADDS.match(entry):
+ raise FrameError(f'{label} contains {entry}; Frame Control can only merge splits that '
+ 'add native libraries or assets')
+ if entry in taken:
+ raise FrameError(f'{name} and another part of the app both contain {entry}')
+ if member.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
+ raise FrameError(f'{entry} in {name} uses a compression Android does not')
+ if member.file_size > MAX_SPLIT_MEMBER:
+ raise FrameError(f'{entry} in {name} is {member.file_size / 1024**2:.0f} MB, more than '
+ f'Frame Control merges ({MAX_SPLIT_MEMBER // 1024**2} MB)')
+ total += member.file_size
+ if total > MAX_SPLIT_TOTAL:
+ raise FrameError(f'the split APKs add more than {MAX_SPLIT_TOTAL // 1024**3} GB, more than '
+ 'Frame Control merges')
+ taken.add(entry)
+ plan[entry] = (path, entry, member.file_size)
+ return plan
+ except (OSError, zipfile.BadZipFile, struct.error, IndexError, frame_apk.ApkError) as e:
+ raise FrameError(f'could not read the split APKs: {e}') from e
+
+
+def resources_empty(data):
+ """True only for a resource table that provably defines nothing: every type chunk's
+ entries absent. Bags (styles, plurals) count, and anything not understood counts too."""
+ def covered(start, end):
+ chunks, at = [], start
+ for chunk in frame_apk._chunks(data, start, end):
+ if chunk[2] != at:
+ return None
+ chunks.append(chunk)
+ at = chunk[2] + chunk[3]
+ return chunks if at == end else None
+
+ try:
+ if len(data) < 12 or struct.unpack_from(' len(body):
+ raise FrameError('truncated ZIP64 extra field')
+ values.append(struct.unpack_from(' MAX_SPLIT_MEMBER or info.compress_size > MAX_SPLIT_MEMBER:
+ raise FrameError(f'{name} is larger than Frame Control merges ({MAX_SPLIT_MEMBER // 1024**2} MB)')
+ following = next((o for o in starts if o > info.header_offset), None)
+ with open(path, 'rb') as raw:
+ raw.seek(info.header_offset)
+ header = raw.read(30)
+ if len(header) != 30 or header[:4] != b'PK\3\4':
+ raise FrameError(f'{name}: invalid local ZIP header')
+ flags, method, crc, csize, usize, nl, el = struct.unpack_from('<2xHH4xIIIHH', header, 4)
+ local_name, extra = raw.read(nl), raw.read(el)
+ if local_name != info.orig_filename.encode('utf-8' if flags & 0x800 else 'cp437', 'surrogateescape'):
+ raise FrameError(f'{name}: the local header names {local_name.decode("utf-8", "replace")!r}')
+ if method != info.compress_type or (flags & 0x08) != (info.flag_bits & 0x08):
+ raise FrameError(f'{name} does not match its header (local and central entries differ)')
+ start = info.header_offset + 30 + nl + el
+ compressed = raw.read(info.compress_size)
+ if len(compressed) != info.compress_size:
+ raise FrameError(f'{name} is truncated')
+ if flags & 0x08:
+ # Sizes and checksum follow the data: (signature,) crc, then sizes of 4 bytes each,
+ # or 8 for ZIP64. Streaming writers use the ZIP64 form even for small files when
+ # sizes weren't known up front, so the width isn't implied by the final sizes: take
+ # the layout that matches the central directory and fits before the next entry,
+ # preferring one that ends exactly there, then the width the local header implies.
+ zip64 = 0xffffffff in (csize, usize) or _has_zip64_extra(extra)
+ tail = raw.read(24)
+ if len(tail) < 12:
+ raise FrameError(f'{name}: truncated data descriptor')
+ end = start + info.compress_size
+ fits = []
+ for signed in (True, False):
+ if signed and tail[:4] != b'PK\7\x08':
+ continue
+ for width in (20, 12):
+ body = tail[4:4 + width] if signed else tail[:width]
+ if len(body) < width:
+ continue
+ values = struct.unpack_from(' following:
+ raise FrameError(f'{name} overlaps the next ZIP entry')
+ if info.compress_type == zipfile.ZIP_STORED:
+ if info.compress_size != size:
+ raise FrameError(f'{name} does not match its header (stored {info.compress_size} bytes, says {size})')
+ data = compressed
+ else:
+ inflate = zlib.decompressobj(-15)
+ try:
+ data = inflate.decompress(compressed, size + 1)
+ except zlib.error as e:
+ raise FrameError(f'{name} is corrupt: {e}') from e
+ if len(data) != size or not inflate.eof or inflate.unconsumed_tail or inflate.unused_data:
+ raise FrameError(f'{name} does not match its header (its compressed data is not exactly '
+ f'{size} bytes with nothing after it)')
+ if zlib.crc32(data) != info.CRC:
+ raise FrameError(f'{name} does not match its checksum')
+ return data
+
+
+def merge_splits(base, splits, dst):
+ """One installable APK from a base APK and its splits (see split_entries), re-signed
+ with Frame Control's key, without the base's isSplitRequired flag."""
+ plan = split_entries(base, splits)
+ add, total = {}, 0
+ try:
+ for entry, (path, member, size) in plan.items():
+ data = read_member(path, member)
+ total += len(data)
+ if total > MAX_SPLIT_TOTAL:
+ raise FrameError(f'the split APKs add more than {MAX_SPLIT_TOTAL // 1024**3} GB')
+ add[entry] = data
+ with zipfile.ZipFile(base) as z:
+ original = frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST)
+ manifest = drop_split_required(original)
+ repack(base, dst, replace={'AndroidManifest.xml': manifest} if manifest != original else None, add=add)
+ except (OSError, ValueError, IndexError, struct.error, zipfile.BadZipFile, frame_apk.ApkError) as e:
+ raise FrameError(f'could not merge the split APKs: {e}') from e
+ return {'merged': sorted(os.path.basename(p) for p in splits), 'entries': len(plan)}
+
+
+def split_size(splits):
+ """Bytes a merge adds, from the split headers (merge_splits enforces the real sizes)."""
+ total = 0
+ for path in splits:
+ with zipfile.ZipFile(path) as z:
+ total += sum(m.file_size for m in z.infolist() if SPLIT_ADDS.match(m.filename))
+ return total
+
+
+def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None,
+ splits=None):
+ """splits: the rest of a split APK (base first, as apk_path); merged into one APK first."""
+ if splits:
+ start = time.time()
+ with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
+ merged = os.path.join(tmp, 'merged.apk')
+ try:
+ merge_splits(apk_path, list(splits), merged)
+ except Exception as e: # reported like any other failed install
+ _after_install(None, None, e, start)
+ raise
+ meta = install(merged, flatscreen, name, source or os.path.basename(apk_path), icon_png, xr_compat, artwork)
+ return meta
start, info = time.time(), None
try:
info = apk_info(apk_path)
@@ -159,18 +501,31 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
if flatscreen is None:
flatscreen = not info['vr']
# VR apps get the OpenXR compatibility layer unless told otherwise; it only
- # changes calls SteamVR would otherwise reject.
- add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
+ # changes calls SteamVR would otherwise reject. Without it OpenXR 1.0 apps
+ # still run, so a copy of Frame Control that lacks it installs anyway and
+ # says so, unless the layer was asked for explicitly.
+ add = {}
+ if info['vr'] if xr_compat is None else xr_compat:
+ try:
+ add = xr_compat_files(apk_path)
+ except LayerMissing:
+ if xr_compat:
+ raise
+ info['vr_issues'] = list(info.get('vr_issues') or []) + [LAYER_MISSING_NOTE]
+ info['xr_layer_missing'] = True
with _install_lock:
if add or info['repairable']:
- with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
+ with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
patched = os.path.join(tmp, 'app.apk')
- info['patched'] = patch(apk_path, patched, add)['patched']
+ try:
+ info['patched'] = patch(apk_path, patched, add)['patched']
+ except FrameError as e:
+ raise FrameError(f'could not prepare the APK for the Frame: {e}') from e
info['launchable'] = True
meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
else:
meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
- except FrameError as e:
+ except Exception as e: # not only FrameError: every failed install is reported
_after_install(info, None, e, start)
raise
_after_install(info, meta, None, start)
@@ -216,6 +571,7 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
'source': source or os.path.basename(apk_path)})
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
+ 'version_code': info.get('version_code'),
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
@@ -238,6 +594,13 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
ssh(f'rm -rf {d}', timeout=30)
except FrameError:
pass
+ elif not ok:
+ # An update that failed: the app still works, but a partial copy of the new APK
+ # (scp leaves one; it can be gigabytes) mustn't stay beside it.
+ try:
+ ssh(f'rm -f {d}/app.apk.part', timeout=30)
+ except FrameError:
+ pass
@@ -295,6 +658,34 @@ except (OSError, ValueError, TypeError, AttributeError):
"""
+def _apk_parser_script():
+ """Frame-side Python preamble that loads this copy's stdlib APK parser (frame_apk)."""
+ script = 'import sys, types, json, base64\n'
+ for module in ('frame_apk', 'frame_apk_vr'):
+ with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
+ source = f.read()
+ script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
+ return script
+
+
+def installed_version_code(pkg):
+ """The version code of pkg's installed app.apk, read on the Frame (only its manifest
+ crosses the link), or None if there is no readable APK. Connection failures raise."""
+ if not PKG_RE.match(pkg or ''):
+ raise FrameError(f'bad package name {pkg!r}')
+ script = _apk_parser_script() + f"""try:
+ code = sys.modules['frame_apk'].apk_info({(APPS_DIR + '/' + pkg + '/app.apk')!r}).get('version_code')
+except Exception:
+ code = None
+print(json.dumps(code if isinstance(code, int) else None))
+"""
+ try:
+ code = json.loads(ssh('python3 -', input=script, timeout=120).strip().splitlines()[-1])
+ except (ValueError, IndexError):
+ return None
+ return code if isinstance(code, int) and not isinstance(code, bool) else None
+
+
def art_missing(m):
"""True when a Frame Control install has no complete Steam artwork on record."""
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
@@ -316,13 +707,7 @@ def refresh_art(pkg=None, artwork=None, fill_only=False):
m = _meta_or_fail(pkg)
d = f'{APPS_DIR}/{pkg}'
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
- modules = {}
- for module in ('frame_apk', 'frame_apk_vr'):
- with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
- modules[module] = f.read()
- script = 'import sys, types, json, base64\n'
- for module, source in modules.items():
- script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
+ script = _apk_parser_script()
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n"
@@ -383,10 +768,22 @@ def _clean_meta(m):
return m
-def running_instances():
- """Lepton container name -> adb port, for the instances that are running now."""
- out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true')
- return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2)
+def running_instances(strict=False):
+ """Lepton container name -> adb port, for the instances that are running now.
+
+ strict: a failed `podman ps` raises FrameError instead of reading as "nothing running",
+ and containers without an adb port label count too (port None); for callers that must
+ not mistake an error for a stopped instance."""
+ if not strict:
+ out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true')
+ return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2)
+ out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}"')
+ found = {}
+ for line in out.splitlines():
+ cols = line.split()
+ if cols:
+ found[cols[0]] = cols[1] if len(cols) > 1 and cols[1] != ', for a base APK whose
+ splits were merged into it: Android refuses a lone base that still says it needs them."""
+ chunks = list(frame_apk._chunks(axml_bytes, 8, len(axml_bytes)))
+ pool = next((c for c in chunks if c[0] == 1), None)
+ if pool is None:
+ raise frame_apk.ApkError('AndroidManifest.xml has no string pool')
+ strings = frame_apk._string_pool(axml_bytes, pool[2])
+ resmap = ()
+ for kind, header_size, offset, chunk_size in chunks:
+ if kind == 0x180:
+ resmap = struct.unpack_from('<%dI' % ((chunk_size - header_size) // 4), axml_bytes, offset + header_size)
+ result, changed = bytearray(axml_bytes[:8]), False
+ for kind, hs, off, size in chunks:
+ chunk = bytearray(axml_bytes[off:off + size])
+ if kind == 0x0102 and not changed:
+ name, astart, asize, count = struct.unpack_from('<4xIHHH', chunk, hs)
+ if name < len(strings) and strings[name] == 'application':
+ for i in range(count):
+ a = hs + astart + i * asize
+ attr = struct.unpack_from(' i + 1 else index)
+ changed = True
+ break
+ result += chunk
+ if not changed:
+ return axml_bytes
+ struct.pack_into(' when it was removed, for reports still being sent then
+_wake = threading.Event()
+_retrier = None
+
+
+def _defaults():
+ return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False,
+ 'prompt': 'new', 'pending': None, 'rev': 0}
+
+
+def load():
+ with _lock:
+ s = _defaults()
+ try:
+ with open(FILE) 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
+ return s
+
+
+def _save(s):
+ STATE.mkdir(parents=True, exist_ok=True)
+ tmp = FILE.with_suffix('.tmp')
+ tmp.write_text(json.dumps(s, indent=1))
+ os.replace(tmp, FILE)
+
+
+def valid_email(email):
+ return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email))
+
+
+def flag(body, key):
+ """A consent choice: true only when it really is true (not "false" or 1), left out is no."""
+ v = body.get(key)
+ if v is not None and not isinstance(v, bool):
+ raise ValueError(f'{key} must be true or false')
+ return v is True
+
+
+def from_report(email):
+ """Follow-up questions agreed to with a problem report: the address becomes the contact
+ email with that choice ticked, so it shows in Settings and is removed the same way. Update
+ notices stay on only for the same address: a different one replaces the old address with
+ follow-up questions only (the report form says so before sending). Returns (contact id,
+ rev) for the report to carry, read together with the change itself: a later change from
+ this copy has a higher rev, and the newest such change decides whether the report's
+ follow-up permission still stands, whatever the clocks say."""
+ with _lock:
+ s = load()
+ same = s['email'].lower() == email.lower()
+ changed, cid, rev = _apply({'email': s['email'] if same else email,
+ 'updates': s['updates'] and same, 'followup': True})
+ _deliver(changed)
+ return cid, rev
+
+
+def state():
+ """What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
+ and the Frame has connected at least once (setup worked), so it never greets a new install."""
+ s = load()
+ set_up = bool(frame_telemetry.settings().get('frames_seen'))
+ return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'],
+ 'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
+
+
+def _event(s):
+ email = s['email'] if s['updates'] or s['followup'] else ''
+ return {'event': 'contact_consent', 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()),
+ 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
+ 'properties': {**frame_telemetry.common(), 'email': email, 'updates': bool(email and s['updates']),
+ 'followup': bool(email and s['followup']),
+ 'action': 'set' if email else 'withdraw', 'rev': s['rev'], 'level': 'contact'}}
+
+
+def _send_pending(block=True):
+ """Send what's waiting, including changes made while sending. True if nothing is left
+ waiting. Without block, a send already under way is left to pick up the newest change."""
+ if not _send_lock.acquire(blocking=block):
+ return False
+ try:
+ while True:
+ with _lock:
+ event = load()['pending']
+ if event is None:
+ break
+ try:
+ frame_telemetry.post([event], timeout=30)
+ except frame_telemetry.SendError:
+ return False
+ _sent(event)
+ finally:
+ _send_lock.release()
+ # A change saved just as this finished found the lock still held and left it to us.
+ with _lock:
+ left = load()['pending'] is not None
+ return _send_pending(block=False) if left else True
+
+
+def _sent(event):
+ with _lock:
+ s = load()
+ if s['pending'] and s['pending'].get('uuid') == event['uuid']: # not replaced meanwhile
+ s['pending'] = None
+ _save(s)
+ # A withdrawal, or the address still in use: not an old one removed while this was on its way.
+ if event['properties']['email'] in ('', s['email']):
+ try:
+ frame_telemetry.record_sent([event])
+ except OSError:
+ pass
+
+
+def _forget_locally(email):
+ """Take a removed address out of the log of what was sent (contact events and reports)."""
+ with frame_telemetry._lock:
+ _removed[email.lower()] = time.time()
+ rows = frame_telemetry._read_lines(frame_telemetry.SENT)
+ hit = False
+ for e in rows:
+ p = e.get('properties') or {}
+ for k in ('email', 'contact'):
+ if p.get(k) and str(p[k]).strip().lower() == email.lower():
+ p[k], hit = '', True
+ if hit:
+ frame_telemetry._write_lines(frame_telemetry.SENT, rows)
+
+
+def redact_removed(event, started):
+ """Before logging a report (started at time.time() `started`) whose address was removed
+ while it was being sent: take the address out. Call with frame_telemetry._lock held, so a
+ removal can't slip between this and the log."""
+ p = event.get('properties') or {}
+ removed_at = _removed.get(str(p.get('contact') or '').strip().lower())
+ if removed_at is not None and started <= removed_at:
+ p['contact'] = ''
+
+
+def save(body):
+ """Set, change or remove the address and the two choices. An address needs at least one
+ choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
+ _deliver(_apply(body)[0])
+ return state()
+
+
+def _apply(body):
+ """save()'s change, kept here and waiting to send. Returns (changed, contact id, rev)."""
+ email = str(body.get('email') or '').strip()
+ updates, followup = flag(body, 'updates'), flag(body, 'followup')
+ if email and not valid_email(email):
+ raise ValueError("that doesn't look like an email address")
+ if email and not (updates or followup):
+ raise ValueError('tick what the address may be used for, or remove it')
+ if not email:
+ updates = followup = False
+ with _lock:
+ s = load()
+ old = s['email']
+ changed = (email, updates, followup) != (s['email'], s['updates'], s['followup'])
+ s.update(email=email, updates=updates, followup=followup)
+ if body.get('fromPrompt') or email:
+ s['prompt'] = 'answered'
+ if changed:
+ # Only the newest choice matters, so it replaces anything still waiting. A withdrawal
+ # is sent even for an address still waiting here: its send may already be under way.
+ s['rev'] += 1
+ s['pending'] = _event(s)
+ _save(s)
+ if old and old.lower() != email.lower():
+ try:
+ _forget_locally(old)
+ except OSError:
+ pass
+ return changed, s['id'], s['rev']
+
+
+def _deliver(changed):
+ if changed and not _send_pending(block=False):
+ _wake.set() # offline, or a send under way that will take this change with it
+
+
+def prompt(body):
+ """The one-time prompt was shown, or dismissed with No thanks. Either way it stays gone."""
+ action = body.get('prompt')
+ if action not in ('shown', 'dismissed'):
+ raise ValueError('unknown prompt action')
+ with _lock:
+ s = load()
+ if s['prompt'] in ('new', 'shown'):
+ s['prompt'] = action
+ _save(s)
+ return state()
+
+
+def start():
+ """Retry a change that couldn't be sent, from now on in the background."""
+ global _retrier
+ if _retrier:
+ return
+
+ def loop():
+ while True:
+ try:
+ _send_pending()
+ except Exception:
+ pass
+ _wake.wait(RETRY_EVERY)
+ _wake.clear()
+
+ _retrier = threading.Thread(target=loop, name='contact', daemon=True)
+ _retrier.start()
diff --git a/ui/frame_devices.py b/ui/frame_devices.py
index ba6f5ac..6c38e6e 100644
--- a/ui/frame_devices.py
+++ b/ui/frame_devices.py
@@ -28,8 +28,10 @@ remembered IP is caught.
Python stdlib only.
"""
+import base64
import contextlib
import copy
+import hashlib
import json
import os
import re
@@ -241,8 +243,7 @@ def _write_config(path, text, expected):
try:
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write(text)
- if not frame_host.WINDOWS:
- tmp.chmod(0o600)
+ frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
if read_config(path) != expected:
return False
@@ -271,6 +272,37 @@ def _edit_config(path, change):
raise OSError(f"{path} kept changing while Frame Control tried to update it")
+def repair_permissions(path=None):
+ """Give ~/.ssh/config make_private's ACL by swapping in a byte-for-byte copy: for a
+ file Windows' OpenSSH refuses ("Bad owner or permissions"). -> True only if the copy
+ got that ACL and replaced the file."""
+ path = Path(path or ssh_config())
+ with _config_lock, file_lock(path.with_name(LOCK_NAME)):
+ try:
+ data = path.read_bytes()
+ except OSError:
+ return False
+ fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
+ tmp = Path(tmp)
+ try:
+ with os.fdopen(fd_, "wb") as fh:
+ fh.write(data)
+ if not frame_host.make_private(tmp):
+ return False
+ for attempt in range(20): # a running ssh.exe can hold the file for a moment
+ if path.read_bytes() != data:
+ return False
+ try:
+ os.replace(tmp, path)
+ return True
+ except PermissionError:
+ time.sleep(0.25)
+ return False
+ finally:
+ if tmp.exists():
+ tmp.unlink()
+
+
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
file alone. -> True if the file changed. Does nothing if there's no such block, or
@@ -333,8 +365,8 @@ def remove_block(alias, path=None):
def effective_port(alias, config):
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
try:
- out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
- stdin=subprocess.DEVNULL, timeout=10).stdout
+ out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
+ stdin=subprocess.DEVNULL, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return 22
m = re.search(r"^port (\d+)$", out, re.M)
@@ -346,8 +378,8 @@ def effective_port(alias, config):
def _keygen(*args):
try:
- return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
- timeout=10)
+ return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
+ timeout=10)
except (OSError, subprocess.TimeoutExpired):
return None
@@ -388,19 +420,100 @@ def seed_pin(device_id, hosts, port=22, sources=None):
if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
keys.append(f"{name} {f[1]} {f[2]}")
if keys:
- target = known_hosts(device_id)
- target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
- fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
- with os.fdopen(fd_, "w", encoding="utf-8") as fh:
- fh.write("\n".join(dict.fromkeys(keys)) + "\n")
- os.replace(tmp, target) # whole file at once: ssh never sees half of it
+ _write_pin(device_id, keys)
return True
return False
+def pin_dir():
+ """Where the per-headset files go, created private: ssh won't create it itself."""
+ d = ssh_dir() / PIN_DIR
+ d.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
+ return d
+
+
+def _write_pin(device_id, lines):
+ target = known_hosts(device_id)
+ fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(pin_dir()))
+ with os.fdopen(fd_, "w", encoding="utf-8") as fh:
+ fh.write("\n".join(dict.fromkeys(lines)) + "\n")
+ frame_host.make_private(tmp) # Windows: this user only, not the folder's inherited ACL
+ os.replace(tmp, target) # whole file at once: ssh never sees half of it
+
+
+# The host key types OpenSSH writes to known_hosts.
+KEY_TYPE_RE = re.compile(r"ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-nistp(?:256|384|521)|"
+ r"sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com")
+
+
+def fingerprint(blob):
+ """OpenSSH's SHA256 fingerprint of a base64 key blob (what `ssh-keygen -l` and ssh -v print),
+ or None if it isn't base64."""
+ try:
+ raw = base64.b64decode(blob, validate=True)
+ except (ValueError, TypeError):
+ return None
+ return "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=")
+
+
+def key_entry(line, name):
+ """(type, blob, fingerprint) from a known_hosts line for `name`, or None."""
+ f = (line or "").split()
+ if len(f) < 3 or line.startswith("#") or name not in f[0].split(",") or not KEY_TYPE_RE.fullmatch(f[1]):
+ return None
+ fp = fingerprint(f[2])
+ return (f[1], f[2], fp) if fp else None
+
+
+def pinned_keys(device_id):
+ """[(type, fingerprint)] of the keys saved for a headset (plain entries only)."""
+ try:
+ lines = known_hosts(device_id).read_text(encoding="utf-8").splitlines()
+ except (OSError, UnicodeDecodeError):
+ return []
+ name = host_key_alias(device_id)
+ return [(e[0], e[2]) for e in (key_entry(line, name) for line in lines) if e]
+
+
+def pin_key(device_id, key_type, blob):
+ """Save one key as the headset's whole identity, replacing whatever was saved: what the
+ person confirmed after seeing its fingerprint (see frame_link.Link.trust_identity)."""
+ if not KEY_TYPE_RE.fullmatch(key_type or "") or not fingerprint(blob):
+ raise DeviceError("That isn't an SSH host key")
+ _write_pin(device_id, [f"{host_key_alias(device_id)} {key_type} {blob}"])
+
+
+def scan_file(device_id):
+ """(path, how ssh is told) for a throwaway known_hosts file that a key check writes the
+ headset's key to, without touching the saved one. `~` as in known_hosts_opt."""
+ name = f".scan-{device_id}-{secrets.token_hex(4)}"
+ path = pin_dir() / name
+ path.touch(**({} if frame_host.WINDOWS else {"mode": 0o600}))
+ frame_host.make_private(path)
+ return path, (str(path) if os.environ.get("FRAME_CONTROL_SSH_DIR") else f"~/.ssh/{PIN_DIR}/{name}")
+
+
+def identity_established(device):
+ """Whether this headset ever had an SSH identity here: the device's own flag (set once it
+ had a key or a connection that worked, and never cleared, whatever happens to its
+ addresses), a fingerprint recorded in devices.json, a pin file at all (even empty,
+ unreadable or not parseable), or an address that worked. Then a missing or unusable pin
+ fails closed, needing Check its key; only a headset with none of these may save the
+ first key it meets (accept-new)."""
+ if device.get("identity_established") or device.get("identity"):
+ return True
+ try:
+ if os.path.lexists(known_hosts(device["id"])):
+ return True
+ except OSError:
+ return True
+ return any(a.get("last_ok") for a in device.get("addresses") or ())
+
+
def forget_pin(device_id):
- """Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection
- trusts whatever key the headset shows, as a first connection does."""
+ """Drop a headset's saved key (the headset was removed, or the API's forget-identity).
+ The next connection trusts whatever key the headset shows, as a first connection does;
+ the page's Trust a new key flow (frame_link) shows the key first instead."""
try:
known_hosts(device_id).unlink()
return True
@@ -480,6 +593,10 @@ class Registry:
for a in d["addresses"]:
a.setdefault("networks", [])
a.setdefault("label", "")
+ if d.get("identity") or any(a.get("last_ok") for a in d["addresses"]):
+ # Files from before the flag existed: it had an identity (or a connection that
+ # worked, which saved one). Kept from now on, whatever happens to its addresses.
+ d["identity_established"] = True
return NAME_RE.fullmatch(d.get("id", "")) is not None
except (KeyError, TypeError, DeviceError):
return False
@@ -684,8 +801,25 @@ class Registry:
a["networks"] = (a["networks"] + [network_id])[-16:]
a["last_ok"] = time.time()
a["last_rtt_ms"] = rtt_ms
+ self._find(device_id)["identity_established"] = True # survives editing the address away
self.save()
+ def record_identity(self, device_id, fingerprints):
+ """Remember the fingerprints of the key saved for a headset, so that losing the pin
+ file later can't turn into trusting whatever answers (identity_established)."""
+ fingerprints = [f for f in fingerprints if isinstance(f, str) and f.startswith("SHA256:")][:8]
+ if not fingerprints:
+ return
+ with self._changing():
+ try:
+ d = self._find(device_id)
+ except DeviceError:
+ return
+ if d.get("identity") != fingerprints or not d.get("identity_established"):
+ d["identity"] = fingerprints
+ d["identity_established"] = True
+ self.save()
+
def undismiss(self, alias):
"""Set Up Connection is about to run for this alias: import its block again."""
with self._changing():
@@ -769,15 +903,17 @@ class Registry:
d["addresses"].append(dict(new_address(host), label="From Set Up Connection"))
d["config_host"] = host
changed = True
- if seed and host:
- seed_pin(d["id"], [host], b["port"])
+ if seed and host and seed_pin(d["id"], [host], b["port"]):
+ d["identity"] = [f for _, f in pinned_keys(d["id"])][:8] or d.get("identity")
+ d["identity_established"] = True
elif host and host != d.get("config_host"):
# Set Up Connection ran again and found the headset somewhere new.
d["config_host"] = host
if not any(a["host"] == host for a in d["addresses"]):
d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection"))
- if seed:
- seed_pin(d["id"], [host], b["port"])
+ if seed and seed_pin(d["id"], [host], b["port"]) and not d.get("identity"):
+ d["identity"] = [f for _, f in pinned_keys(d["id"])][:8] or None
+ d["identity_established"] = True
changed = True
if d.get("config_login") != [user, b["port"]]:
# Set Up Connection (or an edit) changed who to log in as, or the port.
diff --git a/ui/frame_host.py b/ui/frame_host.py
index e5c1914..aa31998 100644
--- a/ui/frame_host.py
+++ b/ui/frame_host.py
@@ -5,12 +5,17 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
CLI (used by the Electron app, so terminal handling lives in one place):
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
"""
+import hashlib
+import io
import os
+import re
import shlex
import shutil
+import socket
import ssl
import subprocess
import sys
+import tempfile
from pathlib import Path
MAC = sys.platform == "darwin"
@@ -32,6 +37,68 @@ class HostError(RuntimeError):
pass
+# The start of a line in which ssh itself says the link to the headset failed (not the command it ran).
+# That includes ssh refusing the headset's host key, and the headset refusing this computer's
+# key ("user@host: Permission denied (publickey,...)."): either way no command ran.
+SSH_LINK_FAILED = re.compile(r"^(?:ssh: connect to host |ssh: Could not resolve hostname |banner exchange: |"
+ r"kex_exchange_identification: |mux_client_\w+: |client_loop: |"
+ r"Connection (?:closed|reset) by \S+ port \d+|Connection timed out during banner exchange|"
+ r"Host key verification failed\.|No \S+ host key is known for |"
+ r"[^\n@:]{1,64}@\S+: Permission denied \()", # a Windows user name may have spaces
+ re.M)
+
+
+def ssh_link_failed(returncode, stderr):
+ """Whether an ssh run failed to reach the headset: ssh's own exit code (255) and its own words."""
+ return returncode == 255 and bool(SSH_LINK_FAILED.search(stderr or ""))
+
+
+def link_failure(error):
+ """Mark an exception as ssh failing to reach the headset, judged where ssh ran (so error
+ reports can tell it from a message that only looks like one: a file name, say)."""
+ error.frame_link_failed = True
+ return error
+
+
+class Unreachable(HostError):
+ """The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
+
+
+def run_ssh(argv, **kwargs):
+ """Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
+
+ A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
+ while keeping subprocess.run's captured output, text, check and timeout API.
+ """
+ if not WINDOWS:
+ return subprocess.run(argv, **kwargs)
+ if kwargs.pop("capture_output", False):
+ if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
+ raise ValueError("stdout and stderr arguments may not be used with capture_output")
+ kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ if kwargs.get("stderr") != subprocess.PIPE:
+ return subprocess.run(argv, **kwargs)
+ check = kwargs.pop("check", False)
+ text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
+ with tempfile.TemporaryFile() as stderr:
+ kwargs["stderr"] = stderr
+ try:
+ result = subprocess.run(argv, **kwargs)
+ except subprocess.TimeoutExpired as error:
+ stderr.seek(0)
+ error.stderr = stderr.read()
+ raise
+ stderr.seek(0)
+ if text:
+ with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
+ result.stderr = reader.read()
+ else:
+ result.stderr = stderr.read()
+ if check:
+ result.check_returncode()
+ return result
+
+
def data_dir(*parts):
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
@@ -153,6 +220,19 @@ def open_path(path):
stderr=subprocess.DEVNULL, **DETACHED)
+def reveal_path(path):
+ """Show a file selected in its folder (Linux file managers vary, so there the folder opens)."""
+ path = Path(path)
+ if MAC:
+ cmd = ["open", "-R", str(path)]
+ elif WINDOWS:
+ cmd = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma
+ else:
+ return open_path(path.parent)
+ subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, **DETACHED)
+
+
open_url = open_path # the same openers hand URLs to the default browser
@@ -228,10 +308,46 @@ def clipboard_text():
raise HostError("Can't read the clipboard")
+# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
+BAD_PERMISSIONS = "Bad owner or permissions on "
+
+
+def make_private(path):
+ """Leave only this user able to open PATH, as ssh insists for ~/.ssh/config.
+ Windows: an ACL of just this user, SYSTEM and Administrators, inherited nothing.
+ A file written into ~/.ssh otherwise takes the folder's ACL, and Windows' OpenSSH
+ refuses it if that grants anyone else, even an account deleted long ago
+ ("Bad owner or permissions"). Best effort: -> False if it couldn't."""
+ if not WINDOWS:
+ try:
+ os.chmod(path, 0o600)
+ return True
+ except OSError:
+ return False
+ me = os.environ.get("USERNAME", "")
+ try: # "desktop\me","S-1-5-21-..."
+ # Bytes: the account name is in the console's code page, the SID is ASCII.
+ out = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True,
+ stdin=subprocess.DEVNULL, timeout=10).stdout
+ sid = out.decode("ascii", "replace").strip().rsplit(",", 1)[-1].strip('"')
+ if sid.startswith("S-1-"):
+ me = "*" + sid
+ except (OSError, subprocess.TimeoutExpired):
+ pass
+ if not me:
+ return False
+ try:
+ return subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"{me}:F",
+ "*S-1-5-18:F", "*S-1-5-32-544:F"], capture_output=True,
+ stdin=subprocess.DEVNULL, timeout=10).returncode == 0
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+
+
def ssh_hostname(alias):
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
try:
- out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
+ out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return alias
for line in out.splitlines():
@@ -264,24 +380,65 @@ def open_steam_link():
return "Steam Link isn't installed; opened its download page"
+RDP_PORT = 3389
+RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's
+# xrdp's certificate is its own, so every client warns about it first.
+RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
+ "with your Developer Mode password")
+
+
+def check_rdp(host, timeout=3):
+ """Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
+ try:
+ with socket.create_connection((host, RDP_PORT), timeout=timeout):
+ return
+ except ConnectionRefusedError:
+ raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
+ "refused). Turn on Developer Mode in Steam Settings > System on the headset, "
+ "then restart it and try again.") from None
+ except socket.gaierror:
+ raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
+ "address on the Devices tab.") from None
+ except OSError as e:
+ raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
+ "switched off or on another network; if it's on, check Developer Mode is on "
+ "in Steam Settings > System.") from None
+
+
+def rdp_file(host):
+ """A Remote Desktop connection file for the Frame. mstsc /v: alone offers this
+ computer's Windows account, which xrdp turns away; the file names steamos instead."""
+ if any(c in host for c in "\r\n"):
+ raise HostError("That headset address can't be used for remote desktop")
+ # One file per address, so two launches close together can't swap headsets.
+ path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
+ f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
+ return path
+
+
def open_rdp(alias, host=None):
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
host = host or ssh_hostname(alias)
+ # The client would open either way and then fail on its own, with nothing said here.
+ check_rdp(host)
if MAC:
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
- return "Opened Windows App"
+ return f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
open_url("https://apps.apple.com/app/windows-app/id1295203466")
return "Windows App isn't installed; opened its App Store page"
if WINDOWS:
- _spawn(["mstsc.exe", f"/v:{host}"])
- return f"Opened Remote Desktop to {host}"
+ _spawn(["mstsc.exe", str(rdp_file(host))])
+ # Windows asks about the unsigned connection file first.
+ return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
if which("remmina"):
- _spawn(["remmina", "-c", f"rdp://steamos@{host}"])
- return f"Opened Remmina to {host}"
+ _spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
+ return f"Opened Remmina to {host}: {RDP_LOGIN}"
for name in ("xfreerdp3", "xfreerdp"):
if which(name):
- _spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
- return f"Opened FreeRDP to {host}"
+ _spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
+ return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
raise HostError("No RDP client found: install Remmina or FreeRDP")
diff --git a/ui/frame_link.py b/ui/frame_link.py
index aeb991b1..d38d9f2 100644
--- a/ui/frame_link.py
+++ b/ui/frame_link.py
@@ -28,16 +28,21 @@ Python stdlib only. Runs on this computer, never on the Frame.
import copy
import hashlib
import ipaddress
+import os
import queue
import re
+import shlex
import socket
import subprocess
+import sys
+import tempfile
import threading
import time
import frame_devices
import frame_host
import frame_network
+import frame_telemetry
PROBE_TIMEOUT = 4 # seconds for a TCP answer on port 22
RESOLVE_GRACE = 6 # ...after however long the name lookup took, up to this much
@@ -48,6 +53,7 @@ NETWORK_EVERY = 5 # how often the network fingerprint is read
TAILSCALE_EVERY = 30
RETRY = (5, 10, 20, 30) # seconds before automatic retries after a failure
REQUEST_GAP = 5 # a request may start a new attempt this long after the last one
+SCAN_TTL = 600 # a key shown for "Trust a new key" may be trusted this long after it was read
STAGES = [("network", "Checking this computer's network"), ("find", "Finding the headset"),
("ssh", "Opening SSH"), ("identity", "Checking the headset's identity"),
@@ -74,8 +80,8 @@ def ssh_g(alias):
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
try:
- out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
- timeout=10).stdout
+ out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
+ timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
out = ""
got = {}
@@ -88,6 +94,32 @@ def ssh_g(alias):
return got.get("hostname") or alias, port, got.get("user"), proxied
+def proxy_route(alias):
+ """How ~/.ssh/config sends ALIAS: (None, None) directly, ("jump", spec) through
+ ProxyJump, or ("command", text) through a ProxyCommand."""
+ try:
+ out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
+ timeout=10).stdout
+ except (OSError, subprocess.TimeoutExpired):
+ out = ""
+ got = {}
+ for line in out.splitlines():
+ k, _, v = line.partition(" ")
+ if k in ("proxyjump", "proxycommand") and k not in got:
+ got[k] = v.strip()
+ if got.get("proxyjump") not in (None, "", "none"):
+ return "jump", got["proxyjump"]
+ if got.get("proxycommand") not in (None, "", "none"):
+ return "command", got["proxycommand"]
+ return None, None
+
+
+# One jump host, as ProxyJump names it: [user@]host[:port], nothing that could start an
+# option, split a shell word or carry an ssh % token.
+JUMP_RE = re.compile(r"(?:([A-Za-z0-9][A-Za-z0-9._-]{0,63})@)?"
+ r"(\[[0-9A-Fa-f:.]{2,45}\]|[A-Za-z0-9][A-Za-z0-9.-]{0,252})(?::(\d{1,5}))?")
+
+
def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
"""Try a TCP connection to host:port. -> {"state", "detail", "ip", "rtt_ms"}.
@@ -108,7 +140,8 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
ip = addr[0]
if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip:
try: # a link-local IPv6 address only works with its interface
- ip = f"{ip}%{socket.if_indextoname(addr[3])}"
+ # Windows' ssh takes only the number: its names ("wireless_32768") don't resolve.
+ ip = f"{ip}%{addr[3] if frame_host.WINDOWS else socket.if_indextoname(addr[3])}"
except (OSError, AttributeError):
pass
left = deadline - now()
@@ -135,6 +168,122 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
return last or {"state": "timeout", "detail": "No answer"}
+def _ip_kind(text):
+ """ipv4, ipv6, ipv6 link-local or tailscale for an IP address (zone allowed), else None."""
+ try:
+ ip = ipaddress.ip_address((text or "").strip("[]").split("%")[0])
+ except ValueError:
+ return None
+ if frame_network.is_tailscale(str(ip)):
+ return "tailscale"
+ return "ipv4" if ip.version == 4 else "ipv6 link-local" if ip.is_link_local else "ipv6"
+
+
+def address_kind(host, ip=None):
+ """What sort of address a probe row is, for diagnostics, never the address itself:
+ ".local", "ipv4", "ipv6", "ipv6 link-local", "tailscale" or "hostname", plus what a
+ name resolved to (".local->ipv6 link-local") when the probe got that far."""
+ h = (host or "").lower().rstrip(".")
+ kind = _ip_kind(h) or (".local" if h.endswith(".local") else
+ "tailscale" if frame_network.is_tailscale(h) else "hostname")
+ got = _ip_kind(ip) if ip and not _ip_kind(h) else None
+ return f"{kind}->{got}" if got and got != kind else kind
+
+
+def probes_summary(probes):
+ """Each address tried, as its kind and how the probe went: "alias .local->ipv4 timeout"."""
+ out = []
+ for row in probes or ():
+ lead = "alias " if row.get("label") == "from ~/.ssh/config" else ""
+ out.append(f"{lead}{address_kind(row.get('host'), row.get('ip'))} {row.get('state') or '?'}")
+ return "; ".join(out)
+
+
+# Exactly the tokens the scrubbers write (here and frame_telemetry.scrub), nothing else in <...>.
+PLACEHOLDER = re.compile(r"(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)")
+
+
+def hide_hosts(text, hosts, keep=()):
+ """text with each of `hosts` (the headset's own addresses and names) replaced by ,
+ longest first and whatever the case, so a bare name like "steamdeck" that the scrubber
+ can't recognise goes too."""
+ keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording
+ names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
+ parts = PLACEHOLDER.split(text) # never inside a scrubber's own already there
+ for i in range(0, len(parts), 2):
+ for h in names:
+ parts[i] = re.sub(r"(?", parts[i], flags=re.I)
+ return "".join(parts)
+
+
+# ssh names the host it was going to after these words ("Could not resolve hostname X",
+# "connect to host X port 22", "Timed out talking to X"): whatever follows goes, known or not.
+OPERAND = re.compile(r"(?i)\b(hostname|host|to(?:\s+host)?)\s+(?!<)([^\s:,;'\"()|]+)")
+PLAIN_WORDS = {"answer", "the", "a", "an", "this", "it", "its", "be", "connect", "find", "work", "try"}
+KEYWORDS = {"host", "hostname", "to", "port"} | PLAIN_WORDS
+
+
+def hide_operands(text):
+ def one(m):
+ word = m.group(2).rstrip(".")
+ dots = m.group(2)[len(word):]
+ return m.group(0) if word.lower() in PLAIN_WORDS else f"{m.group(1)} {dots}"
+ return OPERAND.sub(one, text)
+
+
+def scrub_failure(text, hosts=()):
+ """Free text about a failed attempt, for the log: the attempt's own names, whole and
+ longest first (case-insensitively, never ssh's own words), then anything ssh names as a
+ host, then the shared scrubber (addresses, paths, user names)."""
+ return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
+
+
+def failure_detail(error):
+ """What an address that answered but failed SSH shows: the short reason when there is one
+ (the whole message is the banner's headline already), else the message."""
+ error = error or {}
+ return error.get("short") or error.get("message") or "SSH failed"
+
+
+def failure_category(message, raw=""):
+ """The telemetry error category (frame_unreachable, frame_auth, ...) for a failure: a fixed
+ word, never its text."""
+ return frame_telemetry.categorize(f"{message or ''}\n{raw or ''}")[0]
+
+
+_logged = {} # failure line -> {"at": when last written, "repeats": since then}
+LOG_REPEAT_EVERY = 300 # the same failure, retried every 30 s, goes in the log at most this often
+LOG_REMEMBER = 32 # different failures remembered for that
+
+
+def log_failure(stage, message, raw="", probes=(), hosts=()):
+ """One failed connection attempt to stderr (the app's server.log), scrubbed when written,
+ with the hosts of that attempt (so a later switch of headset can't let them through)."""
+ hosts = list(hosts) + [r.get(k) for r in probes or () for k in ("host", "ip")]
+ bits = [f"frame_link: {stage} failed: {scrub_failure(message, hosts)}"]
+ last = [ln.strip() for ln in (raw or "").splitlines() if ln.strip()][-1:]
+ if last and last[0] != message:
+ bits.append(f"ssh said: {scrub_failure(last[0][:300], hosts)}")
+ tried = probes_summary(probes)
+ if tried:
+ bits.append(f"addresses: {tried}")
+ line = " | ".join(bits)[:900]
+ t = time.monotonic()
+ seen = _logged.get(line)
+ if seen and t - seen["at"] < LOG_REPEAT_EVERY:
+ seen["repeats"] += 1
+ return
+ more = f" (and {seen['repeats']} more times)" if seen and seen["repeats"] else ""
+ _logged.pop(line, None)
+ _logged[line] = {"at": t, "repeats": 0}
+ while len(_logged) > LOG_REMEMBER:
+ _logged.pop(next(iter(_logged))) # the least recently written
+ try:
+ print(line + more, file=sys.stderr, flush=True)
+ except (OSError, ValueError, AttributeError):
+ pass # no stderr (a closed pipe): the page still shows the failure
+
+
def ssh_target(host, ip):
"""Where ssh should go for an address whose probe answered from `ip`: that IP, so ssh
doesn't look the name up again and try an address that didn't answer."""
@@ -145,6 +294,43 @@ def ssh_target(host, ip):
return host
+def _unlink(path):
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+
+def _scanned_key(path, name):
+ """The first whole, valid known_hosts line for `name` that a key check wrote, or None.
+ Only lines ssh has finished (ending in a newline): a half-written key mustn't count."""
+ try:
+ with open(path, encoding="utf-8") as fh:
+ text = fh.read()
+ except (OSError, UnicodeDecodeError):
+ return None
+ for line in text.splitlines(True):
+ if line.endswith("\n"):
+ entry = frame_devices.key_entry(line.strip(), name)
+ if entry:
+ return entry
+ return None
+
+
+def proxy_command(literal):
+ """A ProxyCommand that runs `literal` (argv, ending with the jump host) with `-W` to the
+ headset, each word exactly as given. ssh first expands its %-tokens in the line, so every
+ % of ours is doubled; then it hands the line to a shell (`$SHELL -c` on macOS and Linux)
+ or, on Windows, starts it as a command line, so every word is quoted for that: a path
+ with spaces, $(...), backticks or quotes stays one plain word. The forward is
+ [%h]:%p, so an IPv6 address (with a zone) reads as one host."""
+ words = [w.replace("%", "%%") for w in literal]
+ target = "[%h]:%p"
+ if frame_host.WINDOWS:
+ return subprocess.list2cmdline([*words[:-1], "-W", target, words[-1]])
+ return " ".join(shlex.quote(w) for w in [*words[:-1], "-W", target, words[-1]])
+
+
def probe_raw(host, port, result):
"""ssh's own wording for a failed probe, so the server's UNREACHABLE table explains it."""
return {"unresolved": f"ssh: Could not resolve hostname {host}: not found",
@@ -163,7 +349,8 @@ class Link:
self.session_alias = env_alias # ...and stays selectable after switching away
self.mux_base = list(mux_base) # ["ssh", "-o", "BatchMode=yes", ControlPath...]
self.control = control # ControlPath, or None where ssh can't share connections
- self.apply = apply # apply(alias, host_opts): point every ssh command at the headset
+ self._apply = apply # apply(alias, host_opts): point every ssh command at the headset
+ self.applied = (None, []) # what it was last given
self.explain = explain # ssh error text -> plain reason, or None
self.cond = threading.Condition()
self.version = 0
@@ -188,6 +375,9 @@ class Link:
self.route_lock = threading.Lock()
self.routed = None # the device id every ssh command points at
self.routed_device = None
+ self.last_failure = None # {"stage", "category", "at"}: for report diagnostics, no free text
+ self.attempt_device = None # the headset the current attempt is for
+ self.scans = {} # device id -> the host key it showed for "Trust a new key"
# ---- publishing ----
def publish(self, **fields):
@@ -392,13 +582,76 @@ class Link:
return [*mux, *(device.get("frozen") or [])] # what ~/.ssh/config said when it was routed
if not host: # a headset with no addresses: reach nothing, not whatever ~/.ssh/config says
return ["-o", "HostName=no-address.invalid"]
- # StrictHostKeyChecking=yes: whatever ~/.ssh/config says for Host *, every command
- # checks the headset's pinned key (only the connector's first handshake may save one).
- return [*mux, "-o", "StrictHostKeyChecking=yes", "-o", f"HostName={frame_devices.ssh_host(host)}",
+ # Whatever ~/.ssh/config says for Host *, every command checks the headset's pinned key:
+ # StrictHostKeyChecking=yes. Only a headset that never had an identity here (just
+ # added, never connected) gets accept-new: the first contact saves its key, as Set Up
+ # Connection and the connector's first handshake do, rather than every command failing
+ # with "No host key is known" until the connector gets there. Once it had one, a pin
+ # file that's gone, empty or unreadable fails closed (identity_established): accept-new
+ # can't tell that from a new headset and would trust whatever answers.
+ strict = self.strictness(device)
+ if strict == "accept-new":
+ try:
+ frame_devices.pin_dir() # ssh won't create the folder its known_hosts file goes in
+ except OSError:
+ pass # ssh then says it couldn't save the key, and goes on
+ return [*mux, "-o", f"StrictHostKeyChecking={strict}", "-o", f"HostName={frame_devices.ssh_host(host)}",
"-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}",
"-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt(device['id'])}", "-o", "HashKnownHosts=no",
"-o", f"User={device['user']}", "-o", f"Port={device['port']}"]
+ def apply(self, alias, opts):
+ self.applied = (alias, list(opts))
+ self._apply(alias, opts)
+
+ def strictness(self, device):
+ """yes, or accept-new for a headset that never had an identity. Judged on what the
+ registry says now, not only the copy handed in (a route frozen at connect, say, from
+ before its first key was recorded). A key saved since is recorded on the way."""
+ try:
+ current = self.reg.get(device["id"])
+ except frame_devices.DeviceError:
+ current = {}
+ if not (frame_devices.identity_established(device) or
+ (current and frame_devices.identity_established(current))):
+ return "accept-new"
+ self.settle_pin(dict(device, **current) if current else device, tighten=False)
+ return "yes"
+
+ def settle_pin(self, device=None, tighten=True):
+ """A key ssh saved on a first contact, whatever happened next (logged in or refused,
+ by the handshake or any command): record it, make the file private, and from then on
+ route every command strictly. Idempotent and cheap when there's nothing to do."""
+ device = device or self.routed_device
+ if not device or device.get("transient") or device.get("none") or not device.get("id"):
+ return
+ did = device["id"]
+ if frame_devices.pinned(did):
+ fps = [f for _, f in frame_devices.pinned_keys(did)]
+ if fps and (device.get("identity") != fps or not device.get("identity_established")):
+ try:
+ current = self.reg.get(did)
+ except frame_devices.DeviceError:
+ return
+ if current.get("identity") != fps or not current.get("identity_established"):
+ frame_host.make_private(frame_devices.known_hosts(did)) # ssh wrote it
+ self.reg.record_identity(did, fps)
+ if not tighten:
+ return
+ with self.route_lock:
+ alias, opts = self.applied
+ if alias == device.get("alias") and "StrictHostKeyChecking=accept-new" in opts and \
+ frame_devices.identity_established(dict(device, **self._current(did))):
+ self.opts = [("StrictHostKeyChecking=yes" if o == "StrictHostKeyChecking=accept-new" else o)
+ for o in opts]
+ self.apply(alias, self.opts)
+
+ def _current(self, device_id):
+ try:
+ return self.reg.get(device_id)
+ except frame_devices.DeviceError:
+ return {}
+
def public_device(self, d):
return {k: d.get(k) for k in ("id", "name", "alias", "user", "port", "transient")}
@@ -428,6 +681,8 @@ class Link:
if t - last_ts >= TAILSCALE_EVERY and self.state["network"] and not reasons:
last_ts = t
self.refresh_network()
+ if "StrictHostKeyChecking=accept-new" in self.applied[1]:
+ self.settle_pin() # a command's first contact may have saved the key
phase = self.state["phase"]
if phase == "connected" and not self.alive():
reasons.append("dropped")
@@ -436,7 +691,9 @@ class Link:
if reasons:
self.connect(reasons)
except Exception as e: # keep the loop alive whatever happens; say what went wrong
- self.publish(phase="failed", error={"stage": "network", "message": f"{type(e).__name__}: {e}",
+ message = f"{type(e).__name__}: {e}"
+ self.note_failure("network", message, str(e))
+ self.publish(phase="failed", error={"stage": "network", "message": message,
"raw": str(e)}, retry_at=now() + RETRY[-1])
finally:
with self.cond:
@@ -512,6 +769,7 @@ class Link:
self.cond.notify_all()
ok = False
try:
+ self.attempt_device = device # its names, for scrubbing this attempt's log lines
ok = self.attempt(device)
finally:
self.finish(gen, ok, device)
@@ -540,6 +798,7 @@ class Link:
now() + RETRY[min(self.fails, len(RETRY)) - 1])
if not self.state["error"]:
self.state["error"] = {"stage": "find", "message": "Couldn't connect", "raw": ""}
+ self.note_failure("find", "Couldn't connect", "", self.state["probes"])
self.version += 1
self.cond.notify_all()
@@ -558,17 +817,33 @@ class Link:
return "Starting up"
return "Connecting"
- def fail(self, sid, message, raw=""):
+ def fail(self, sid, message, raw="", **more):
+ """A stage failed. `more`: what the page can offer to fix it (hostkey, auth), and `short`:
+ the reason in a few words, for the places a whole sentence doesn't fit (the header pill,
+ the address that failed)."""
self.stage(sid, "failed", message)
with self.cond:
- self.state["error"] = {"stage": sid, "message": message, "raw": raw}
+ self.state["error"] = dict({"stage": sid, "message": message, "raw": raw}, **more)
+ probes = copy.deepcopy(self.state["probes"])
+ self.note_failure(sid, message, raw, probes)
+
+ def note_failure(self, stage, message, raw="", probes=()):
+ """Keep a failure for report diagnostics as fixed values only (its stage and error
+ category, decided now), and write it to the log scrubbed with this attempt's hosts."""
+ self.last_failure = {"stage": stage, "category": failure_category(message, raw), "at": now()}
+ hosts = []
+ for d in (self.attempt_device, self.routed_device): # the headset tried, and where commands go
+ d = d or {}
+ hosts += [a.get("host") for a in d.get("addresses") or ()]
+ hosts += [d.get("frozen_host"), d.get("alias"), d.get("name")]
+ log_failure(stage, message, raw, probes, hosts)
def attempt(self, device):
if device.get("none"):
self.fail("find", "No headset is set up. Add one on the Devices tab.")
return False
if not device.get("transient") and not device["addresses"]:
- self.fail("find", f"{device['name']} has no addresses. Add one on the Devices tab.")
+ self.fail("find", "The active headset has no addresses. Add one on the Devices tab.")
return False
# 1. this computer's network
self.stage("network", "active")
@@ -631,7 +906,7 @@ class Link:
self.learn(device, a["host"], net, None)
return True
with self.cond:
- why_not = (self.state["error"] or {}).get("message") or "SSH failed"
+ why_not = failure_detail(self.state["error"])
self.probe_update(i, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -681,7 +956,7 @@ class Link:
self.learn(device, a["host"], net, results[pick]["rtt_ms"])
return True
with self.cond:
- why_not = (self.state["error"] or {}).get("message") or "SSH failed"
+ why_not = failure_detail(self.state["error"])
self.probe_update(pick, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -737,6 +1012,7 @@ class Link:
if device.get("transient"):
return
self.reg.record_success(device["id"], host, net.get("id"), rtt)
+ self.reg.record_identity(device["id"], [f for _, f in frame_devices.pinned_keys(device["id"])])
# Set Up Connection may have changed the block while this attempt ran: take that
# in (and reconnect) rather than writing this attempt's older settings over it.
self.watch_config()
@@ -766,8 +1042,8 @@ class Link:
if not self.control:
return False
try:
- return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
- stdin=subprocess.DEVNULL, timeout=5).returncode == 0
+ return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
+ stdin=subprocess.DEVNULL, timeout=5).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
@@ -778,8 +1054,8 @@ class Link:
pending.kill()
if self.control and self.alias:
try:
- subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
- stdin=subprocess.DEVNULL, timeout=5)
+ frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
+ stdin=subprocess.DEVNULL, timeout=5)
except (OSError, subprocess.TimeoutExpired):
pass
if proc and proc.poll() is None:
@@ -791,7 +1067,17 @@ class Link:
def handshake(self, device, a, found, user):
"""SSH to one address, following ssh -v through stages 3-5.
- -> "ok", "next" (try another address) or "stop"."""
+ -> "ok", "next" (try another address) or "stop". A key ssh saved is recorded and
+ protected however it ends: a login refused after the key was saved, a timeout, a stop."""
+ try:
+ return self._handshake(device, a, found, user)
+ finally:
+ if not device.get("transient"):
+ with self.route_lock:
+ current = self.attempt_gen == self.gen
+ self.settle_pin(device, tighten=current)
+
+ def _handshake(self, device, a, found, user):
# The IP that answered (with a link-local IPv6 address's zone), so ssh doesn't
# look the name up again and stall on an address that didn't answer.
opts = self.host_opts(device, ssh_target(a["host"], found.get("ip")))
@@ -809,15 +1095,9 @@ class Link:
return "ok"
extra = []
if not device.get("transient"):
- if frame_devices.pinned(device["id"]):
- extra = ["-o", "StrictHostKeyChecking=yes"]
- else:
- # First connection since this headset was added: trust what it shows
- # (as Set Up Connection does), and pin it from now on. ssh won't create
- # the folder its known_hosts file goes in.
- extra = ["-o", "StrictHostKeyChecking=accept-new"]
- pins = frame_devices.known_hosts(device["id"]).parent
- pins.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
+ # A headset's first connection trusts what it shows (as Set Up Connection does) and
+ # pins it; any later one checks the pin strictly (see host_opts).
+ extra = ["-o", f"StrictHostKeyChecking={self.strictness(device)}"]
if self.control:
# No ConnectTimeout: with it, OpenSSH's master takes ~5s to open its socket.
# `extra` first: ssh takes the first value of an option, and it may say accept-new.
@@ -825,27 +1105,70 @@ class Link:
"-o", "ServerAliveCountMax=2", "-N", alias]
else:
argv = [*self.mux_base, *extra, *opts, "-v", "-o", "ConnectTimeout=10", alias, "true"]
+ lines = queue.Queue()
+ collecting = [True]
+ reader_done = threading.Event()
+ errfile = None
try:
- proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
- stderr=subprocess.PIPE, **frame_host.DETACHED)
+ if self.control:
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE, **frame_host.DETACHED)
+ else:
+ # No master (Windows): ssh runs `true` and exits. Its stderr goes to a file read
+ # as it grows, not a pipe: Windows' OpenSSH can block writing to a redirected
+ # pipe (see frame_host.run_ssh), and the handshake would never finish.
+ fd_, errfile = tempfile.mkstemp(prefix="frame-ssh-")
+ try:
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=fd_, **frame_host.DETACHED)
+ finally:
+ os.close(fd_)
except OSError as e:
+ if errfile:
+ _unlink(errfile)
self.fail("ssh", f"Couldn't run ssh: {e}", str(e))
return "stop"
self.pending = proc # so stop() can end it mid-handshake
- lines = queue.Queue()
- collecting = [True]
def read():
for raw in iter(proc.stderr.readline, b""):
if collecting[0]:
lines.put(raw.decode("utf-8", "replace").rstrip())
+ reader_done.set()
lines.put(None)
proc.stderr.close()
- threading.Thread(target=read, daemon=True).start()
+
+ def tail():
+ buf = b""
+ try:
+ with open(errfile, "rb") as fh:
+ while True:
+ chunk = fh.read()
+ if not chunk:
+ if proc.poll() is None:
+ time.sleep(0.05)
+ continue
+ chunk = fh.read() # whatever it wrote just before exiting
+ if not chunk:
+ break
+ buf += chunk
+ *done, buf = buf.split(b"\n")
+ for raw in done:
+ lines.put(raw.decode("utf-8", "replace").rstrip())
+ except OSError:
+ pass
+ if buf:
+ lines.put(buf.decode("utf-8", "replace").rstrip())
+ reader_done.set()
+ lines.put(None)
+ _unlink(errfile)
+ threading.Thread(target=read if errfile is None else tail, daemon=True).start()
step, said, authed = "ssh", [], False
deadline = time.monotonic() + HANDSHAKE_TIMEOUT
mismatch = False
+ shown = None # (type, fingerprint) of the key the headset showed
+ exited = None # when ssh was first seen to have exited
while True:
left = deadline - time.monotonic()
if self.stopped:
@@ -863,7 +1186,14 @@ class Link:
if authed and self.control and self.check(opts, alias):
break
if proc.poll() is not None and lines.empty():
- line = None
+ # ssh exited: its last words (Permission denied…) may still be on their way.
+ # The reader's end marker says when they're all in; a pipe that something
+ # else holds open (a ProxyCommand's child) gets a short grace instead.
+ exited = exited or time.monotonic()
+ if reader_done.is_set() and lines.empty() or time.monotonic() - exited > 2:
+ line = None
+ else:
+ continue
else:
continue
if line is None: # ssh exited
@@ -872,7 +1202,7 @@ class Link:
break
if authed and self.control and self.check(opts, alias):
break
- return self.failed(step, said, mismatch, alias)
+ return self.failed(step, said, mismatch, alias, shown, a["host"], device)
if not line.startswith("debug"):
said.append(line)
m = CONNECTING.search(line)
@@ -885,12 +1215,15 @@ class Link:
self.stage("identity", "active", "Waiting for the headset's host key")
elif HOSTKEY.search(line):
m = HOSTKEY.search(line)
+ shown = (m.group(1), m.group(2))
self.stage("identity", "active", f"It shows {m.group(1)} key {m.group(2)[:20]}…")
elif KNOWN.search(line):
self.stage("identity", "done", "Matches the identity saved for this headset")
step = "login"
self.stage("login", "active", f"Logging in as {user}")
elif ADDED.search(line):
+ if not device.get("transient"):
+ self.settle_pin(device, tighten=False) # now, before the login can fail
self.stage("identity", "done", "First connection: saved this headset's identity")
step = "login"
self.stage("login", "active", f"Logging in as {user}")
@@ -929,18 +1262,36 @@ class Link:
# OpenSSH lower-cases host names (FRAME.LOCAL logs as frame.local).
return not m or m.group(1).lower() in (host.lower(), alias.lower()) or "Authentication succeeded" in line
- def failed(self, step, said, mismatch, alias):
+ def failed(self, step, said, mismatch, alias, shown=None, host=None, device=None):
text = "\n".join(said).strip()
if mismatch:
- self.fail("identity", "This address answered as a different headset (its SSH identity doesn't match). "
- "If SteamOS was reinstalled, use Forget Identity on the Devices tab.", text)
+ # A key that differs from the saved one, or (strict, pinned) none of the saved key's
+ # type any more: either way not the identity saved. Never accepted here: the page
+ # shows the new key's fingerprint and the person decides (Trust a new key).
+ name = (device or {}).get("name") or alias
+ lost = bool(device) and not frame_devices.pinned(device["id"]) and \
+ frame_devices.identity_established(device)
+ if lost:
+ message = (f"The SSH identity saved for {name} is missing or can't be read, so Frame Control won't "
+ "trust whatever answers. Check its key and trust it on the Devices tab.")
+ else:
+ message = (f"This address answered with a different SSH identity from the one saved for {name}. "
+ "If SteamOS was reinstalled or reset, or this is a new headset, check its new key and trust "
+ "it on the Devices tab. If not, a different device may be answering here.")
+ self.fail("identity", message, text,
+ short="Saved SSH identity missing" if lost else "SSH identity changed",
+ hostkey={"state": "missing" if lost else "changed", "type": shown[0] if shown else None,
+ "fingerprint": shown[1] if shown else None, "host": host})
return "next"
# A refused key is the same at every address: stop. (Judged by ssh's own words, not
# the step: a jump host's progress lines look like the headset's.) A forward that
# a jump host couldn't open is about this address only: try the next.
forward = re.search(r"open failed|forwarding failed|Connection refused|Connection closed|timed out", text)
if re.search(r"Permission denied", text) and not forward:
- self.fail("login", self.explain(text) or "The headset didn't accept this computer's key.", text)
+ # Not something a retry fixes: the headset needs this computer's key (Set Up Connection).
+ self.fail("login", self.explain(text) or "The headset didn't accept this computer's key. Run Set Up "
+ "Connection: it pairs this computer with the headset again.", text, auth="denied",
+ short="This computer's key refused")
return "stop"
if step == "connected":
self.fail("login", "Logged in, but the shared SSH connection didn't start.", text)
@@ -984,12 +1335,14 @@ class Link:
lead = f"Answered in {res['rtt_ms']:g} ms" if res.get("rtt_ms") is not None else "Through the jump host"
rows[i]["ssh"] = "checking"
put()
+ # "yes" before host_opts (ssh takes an option's first value): a test only looks, so
+ # it reports a key not saved yet rather than saving it as host_opts' accept-new would.
argv = [*self.mux_base[:3], "-o", "ControlPath=none", "-o", "ConnectTimeout=8",
- *self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
- "-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
+ "-o", "StrictHostKeyChecking=yes",
+ *self.host_opts(device, ssh_target(a["host"], res.get("ip"))), device["alias"], "true"]
try:
- r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
- errors="replace", timeout=20)
+ r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
+ errors="replace", timeout=20)
err = r.stderr.strip()
if r.returncode == 0:
rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
@@ -1014,6 +1367,156 @@ class Link:
put(done=True, finished=now())
self.devices_changed()
+ # ---- Trust a new key ----
+ # Every way a key check could do more than read the key: no login of any kind, no agent
+ # (nor forwarding it), no X11, port or tunnel forwarding, no local or remote command, no
+ # terminal, no shared connection, no learning keys. Given first, so ssh takes these over
+ # anything ~/.ssh/config (or host_opts) says. -N: no command, even if a device accepts "none".
+ SCAN_OPTS = ["BatchMode=yes", "ControlMaster=no", "ControlPath=none", "ConnectTimeout=8",
+ "StrictHostKeyChecking=accept-new", "UpdateHostKeys=no", "HashKnownHosts=no",
+ "PreferredAuthentications=none", "PubkeyAuthentication=no", "PasswordAuthentication=no",
+ "KbdInteractiveAuthentication=no", "HostbasedAuthentication=no", "IdentityAgent=none",
+ "ForwardAgent=no", "ForwardX11=no", "ForwardX11Trusted=no", "ClearAllForwardings=yes",
+ "Tunnel=no", "PermitLocalCommand=no", "RemoteCommand=none", "RequestTTY=no"]
+ SCAN_TIMEOUT = 20
+
+ def scan_identity(self, device_id):
+ """Read the headset's host key without trusting it or logging in. ssh writes it to a
+ throwaway known_hosts file during the key exchange, before any authentication, and is
+ stopped as soon as it has (or when it gives up); SCAN_OPTS and -N rule out anything else.
+ `-F none` too, unless ~/.ssh/config sends the alias through a jump host (then it's
+ needed for that, and SCAN_OPTS still win). The address that showed the changed key goes
+ first, then the rest in order. -> {"type", "fingerprint", "host", "saved", "recorded",
+ "matches", "recorded_match"}; the key is kept (SCAN_TTL) for trust_identity, which pins
+ exactly it."""
+ device = self.reg.get(device_id)
+ if not device["addresses"]:
+ raise frame_devices.DeviceError("This headset has no addresses to ask")
+ hosts = [a["host"] for a in device["addresses"]]
+ with self.cond:
+ err = self.state.get("error") or {}
+ mine = (self.state.get("device") or {}).get("id") == device_id
+ first = (err.get("hostkey") or {}).get("host") if mine else None
+ if first in hosts:
+ hosts.remove(first)
+ hosts.insert(0, first)
+ route = self.scan_route(device)
+ reasons = []
+ for host in hosts:
+ key, err_text = self._scan_one(device, host, route)
+ if key:
+ saved = frame_devices.pinned_keys(device_id)
+ recorded = [f for f in device.get("identity") or [] if isinstance(f, str)]
+ scan = {"type": key[0], "blob": key[1], "fingerprint": key[2], "host": host,
+ "port": device["port"], "user": device["user"], "at": now()}
+ with self.cond:
+ self.scans[device_id] = scan
+ return {"type": key[0], "fingerprint": key[2], "host": host,
+ "saved": [{"type": t, "fingerprint": f} for t, f in saved], "recorded": recorded,
+ "matches": any(f == key[2] for _, f in saved), "recorded_match": key[2] in recorded}
+ text = err_text.strip()
+ reasons.append(f"{host}: " + (self.explain(text) or (text.splitlines() or ["no answer"])[-1]))
+ raise frame_devices.DeviceError("Couldn't read the headset's key. " + "; ".join(reasons))
+
+ # The jump host's own ssh (a ProxyCommand the scan builds itself): it may log in to the jump
+ # host as configured (keys, the agent), but it never forwards the agent, X11 or ports, runs
+ # no local or remote command, has no terminal, shares no connection and goes no further.
+ HOP_OPTS = ["BatchMode=yes", "ForwardAgent=no", "ForwardX11=no", "ForwardX11Trusted=no",
+ "ClearAllForwardings=yes", "Tunnel=no", "PermitLocalCommand=no", "RemoteCommand=none",
+ "RequestTTY=no", "ControlMaster=no", "ControlPath=none", "ProxyCommand=none", "ProxyJump=none"]
+
+ def scan_route(self, device):
+ """The options that take a key check to the headset: `-F none` and nothing else when
+ it's reached directly. Through a jump host (ProxyJump), `-F none` and a ProxyCommand
+ built here, whose ssh gets HOP_OPTS ahead of ~/.ssh/config: config options can't be
+ overridden for ProxyJump's own ssh, which inherits ForwardAgent, LocalCommand and the
+ rest. Through a ProxyCommand from ~/.ssh/config, or several jump hosts, it refuses:
+ those can't be made safe from here."""
+ kind, spec = proxy_route(device["alias"])
+ if kind is None:
+ return ["-F", "none"]
+ m = JUMP_RE.fullmatch(spec or "") if kind == "jump" else None
+ if not m or (m.group(3) and not 1 <= int(m.group(3)) <= 65535):
+ how = "a ProxyCommand" if kind == "command" else "this jump host setting"
+ raise frame_devices.DeviceError(
+ f"{device['name']} is reached through {how} in ~/.ssh/config, which Frame Control can't make safe "
+ "for talking to a device it doesn't trust yet, so it won't read the key that way. Add an address "
+ "it can reach directly (or through a single ProxyJump host) and check its key again; or compare "
+ "the key by hand (on the headset, in Konsole: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub)")
+ user, host, port = m.groups()
+ # The ssh this server runs, by its full path: the shell that runs a ProxyCommand may set
+ # up another PATH.
+ ssh = frame_host.which(self.mux_base[0])
+ if not ssh:
+ raise frame_devices.DeviceError(f"Couldn't find {self.mux_base[0]} to reach the jump host with")
+ hop = [ssh, *(a for o in self.HOP_OPTS for a in ("-o", o)), *(["-l", user] if user else []),
+ *(["-p", port] if port else []), host.strip("[]")] # ssh takes a bare IPv6 address here
+ return ["-F", "none", "-o", "ProxyCommand=" + proxy_command(hop)]
+
+ def _scan_one(self, device, host, route):
+ """-> ((type, blob, fingerprint) or None, what ssh said)."""
+ name = frame_devices.host_key_alias(device["id"])
+ path, opt = frame_devices.scan_file(device["id"])
+ opts = [a for o in [*self.SCAN_OPTS, f"UserKnownHostsFile={opt}"] for a in ("-o", o)]
+ argv = [self.mux_base[0], *route, *opts, *self.host_opts(device, host), "-N", device["alias"]]
+ fd_, errfile = tempfile.mkstemp(prefix="frame-scan-") # a file, not a pipe (see frame_host.run_ssh)
+ key, proc = None, None
+ try:
+ try:
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=fd_,
+ **frame_host.DETACHED)
+ finally:
+ os.close(fd_)
+ deadline = time.monotonic() + self.SCAN_TIMEOUT
+ while key is None:
+ ended = proc.poll() is not None
+ key = _scanned_key(path, name)
+ if key or ended or self.stopped or time.monotonic() > deadline:
+ break
+ time.sleep(0.05)
+ except OSError as e:
+ return None, str(e)
+ finally:
+ if proc and proc.poll() is None:
+ proc.kill() # it has the key (or gave up): nothing more to do with that device
+ if proc:
+ try:
+ proc.wait(5)
+ except subprocess.TimeoutExpired:
+ pass
+ try:
+ with open(errfile, "rb") as fh:
+ said = fh.read().decode("utf-8", "replace")
+ except OSError:
+ said = ""
+ _unlink(errfile)
+ _unlink(path)
+ if key is None and not said.strip() and proc and proc.returncode is not None and proc.returncode < 0:
+ said = f"Timed out talking to {device['alias']}"
+ return key, said
+
+ def trust_identity(self, device_id, fingerprint):
+ """Pin the key scan_identity read, and only if `fingerprint` is the one it showed (what
+ the person saw and confirmed), replacing whatever was saved; and only while the headset
+ is still reached as it was then (same port and user, the address still one of its own).
+ -> the scan."""
+ with self.cond:
+ scan = self.scans.get(device_id)
+ if not scan or now() - scan["at"] > SCAN_TTL:
+ self.scans.pop(device_id, None)
+ raise frame_devices.DeviceError("Check the headset's key again first: the one shown has expired")
+ if not isinstance(fingerprint, str) or fingerprint != scan["fingerprint"]:
+ raise frame_devices.DeviceError("That isn't the key the headset showed. Check its key again")
+ self.scans.pop(device_id)
+ d = self.reg.get(device_id) # still a headset here
+ if (d["port"], d["user"]) != (scan["port"], scan["user"]) or \
+ scan["host"] not in [a["host"] for a in d["addresses"]]:
+ raise frame_devices.DeviceError("The headset's address, port or user changed since its key was read. "
+ "Check its key again")
+ frame_devices.pin_key(device_id, scan["type"], scan["blob"])
+ self.reg.record_identity(device_id, [scan["fingerprint"]])
+ return scan
+
# ---- the page's API: /api/devices -------------------------------------------------
@@ -1032,6 +1535,9 @@ def devices_view(link):
view = {k: v for k, v in d.items() if k not in ("config_host", "addresses")}
view["active"] = d["id"] == active["id"]
view["pinned"] = frame_devices.pinned(d["id"])
+ view["keys"] = [{"type": t, "fingerprint": f} for t, f in frame_devices.pinned_keys(d["id"])]
+ # It had an identity, but the saved key is gone or unusable: nothing is trusted until checked.
+ view["identity_lost"] = not view["pinned"] and frame_devices.identity_established(d)
view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]])
for a in d["addresses"]]
devices.append(view)
@@ -1064,7 +1570,7 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
is_active = did == active["id"]
moves = action == "use" or (action == "retry" and link.alive()) or (is_active and (
# (a retry while connected would cut the install's connection)
- action in ("remove", "address-remove", "forget-identity")
+ action in ("remove", "address-remove", "identity-trust")
or (action == "update" and login_change(reg, did, body))
or (action == "address-update" and body.get("newHost") not in (None, body.get("host")))))
if moves and busy():
@@ -1145,11 +1651,25 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
threading.Thread(target=link.test, args=(did,), daemon=True).start()
msg = f"Testing {len(d['addresses'])} address{'es' * (len(d['addresses']) != 1)}"
elif action == "forget-identity":
+ # Dropping the pin used to let the next contact save whatever key answered, unseen.
+ # A new key is now always shown first: identity-scan, then identity-trust.
+ reg.get(did)
+ raise frame_devices.DeviceError("Forgetting a headset's identity is no longer supported: use Check its key "
+ "on the Devices tab (identity-scan, then identity-trust), which shows the "
+ "new key before saving it")
+ elif action == "identity-scan":
d = reg.get(did)
- frame_devices.forget_pin(did)
+ scan = link.scan_identity(did)
+ msg = (f"{d['name']} shows the key already saved for it" if scan["matches"]
+ else f"{d['name']} shows a {scan['type']} key, {scan['fingerprint']}")
+ link.devices_changed()
+ return dict(devices_view(link), message=msg, scan=scan)
+ elif action == "identity-trust":
+ d = reg.get(did)
+ link.trust_identity(did, body.get("fingerprint"))
if is_active:
- link.kick("switch")
- msg = f"Forgot {d['name']}'s SSH identity; the next connection saves the one it shows"
+ link.kick("switch") # reconnect with the key now saved
+ msg = f"Saved {d['name']}'s new SSH identity; reconnecting"
elif action == "name-network":
reg.name_network(body.get("network"), body.get("name"))
if link.state["network"]:
diff --git a/ui/frame_meta.py b/ui/frame_meta.py
new file mode 100644
index 0000000..9739bfa
--- /dev/null
+++ b/ui/frame_meta.py
@@ -0,0 +1,1442 @@
+"""My Meta library (Experimental): install Quest games you own from Meta, with your own token.
+
+Opt-in and off until you connect. You paste your own Meta access token (docs/meta-library.md
+says how to get it); Frame Control checks it with a harmless "who am I" query, then keeps it
+in a private file (frame_host.data_dir('meta-account.json'), owner-only). It lists the Quest
+(Android) apps your account owns, downloads one you choose (its APK, plus its OBB game data
+when it has some) from Meta's download server into a per-user cache, and installs it on the
+Frame through the same checks and install as Import from my Quest (frame_quest.py). Install
+and test then runs the game and reads its log to say whether it runs, and records the
+result through the existing compatibility reports (frame_catalog.add_report), which share
+it only if you chose to share compatibility results.
+
+This uses Meta's undocumented internal API (graph.oculus.com, securecdn.oculus.com), as the
+open-source tools QAVS, DownQuest and BSManager do; it may break whenever Meta changes it and
+may be against Meta's terms. Frame Control's code is its own (MIT); only the protocol facts
+(endpoints, query ids, field names) come from those tools' public source (docs/meta-library.md).
+
+Rules this module keeps:
+- Only games the account owns: a download must be an app in the account's own library.
+- No licence-check bypass of any kind: games are installed as Meta ships them.
+- The token goes only to TOKEN_HOSTS over HTTPS, never into logs, errors, API answers,
+ the page or reports (redact()); a redirect elsewhere carrying it is refused.
+- Game files stay in the local install cache and are deleted after each install.
+
+Python stdlib only. CLI: python3 ui/frame_meta.py
+ connect --token-stdin | status | disconnect | list [--refresh] | download APP_ID
+ install APP_ID [--test] [--allow-downgrade] | test PACKAGE [--wait SECONDS] [--no-report]
+"""
+import concurrent.futures
+import hashlib
+import http.client
+import ipaddress
+import json
+import os
+from pathlib import Path
+import re
+import secrets
+import shutil
+import socket
+import ssl
+import sys
+import tempfile
+import threading
+import time
+import urllib.parse
+import zipfile
+
+import frame_android as android
+import frame_host
+import frame_quest as quest
+
+GRAPH_URL = 'https://graph.oculus.com/graphql'
+DOWNLOAD_URL = 'https://securecdn.oculus.com/binaries/download/'
+# The only hosts the token is ever sent to. Downloads may redirect elsewhere (a CDN): the
+# token never goes there: a redirect carrying it in any form is refused (next_hop).
+TOKEN_HOSTS = frozenset({'graph.oculus.com', 'securecdn.oculus.com'})
+# Stored queries ("doc_id") as the public tools use them (docs/meta-library.md).
+DOC_VIEWER = '4149322231793299' # data.viewer.user {id, alias}
+DOC_ENTITLEMENTS = '4850747515044496' # data.viewer.user.active_entitlements.nodes[].item
+DOC_BINARY = '4734929166632773' # one binary: package, version, size, OBB and asset files
+QUEST_PLATFORMS = frozenset({'ANDROID_6DOF'}) # Quest apps; Rift (PC) and Go (3DOF) are left out
+# Entitlement states that mean the account owns the app. A real account's purchases all
+# came back PERMANENT (2026-10-14); ACTIVE, which this was first written for, is kept too.
+OWNED_STATES = frozenset({'ACTIVE', 'PERMANENT'})
+UA = 'FrameControl/1 (My Meta library)'
+ACCOUNT_FILE = 'meta-account.json'
+
+MAX_RESPONSE = 32 * 1024**2 # one API answer
+MAX_APK = 2 * 1024**3 # as for every other APK download (apk_sources/_web.py)
+MAX_DATA_FILE = 8 * 1024**3 # one OBB
+MAX_TOTAL = 16 * 1024**3 # everything one install downloads
+MAX_APPS = 1000 # owned apps listed
+MAX_DETAILS = 200 # binary queries per library refresh (the rest when installed)
+DETAIL_WORKERS = 4
+MAX_REDIRECTS = 5
+LIBRARY_TTL = 600
+CACHE_TTL = 6 * 3600 # a download a crash left behind is swept after this
+TIMEOUT = 30
+TEST_WAIT = 30 # seconds a test watches the game after its instance is up
+TEST_STEP = 10 # ...looking at it this often
+START_TIMEOUT = 90
+
+WARNING = ("Experimental. My Meta library uses Meta's undocumented internal API with your own Meta "
+ "access token. It may stop working whenever Meta changes things, and using it may be against "
+ "Meta's terms; your account is your responsibility. It only lists and downloads games your "
+ "account owns, never changes them, and never removes a licence check.")
+NOT_CONNECTED = 'Connect your Meta account first (My Meta library, Experimental).'
+REJECTED = ("Meta didn't accept this token. It may have expired or been signed out: get a new one "
+ "(docs/meta-library.md) and connect again.")
+RAN_PLATFORM_SDK = ("It uses Meta's licence check; on the Frame that check can't reach Meta, so the "
+ "game may quit, hang, or run without online features. Frame Control won't remove the check.")
+
+
+class MetaError(android.FrameError):
+ """A readable failure. Its text never carries the token (built with redact())."""
+
+
+class TokenRejected(MetaError):
+ pass
+
+
+class SignInCancelled(MetaError):
+ """The desktop app's Sign in with Meta window gave up on this attempt (closed, timed
+ out, signed out), so a connect it had started is not saved."""
+
+
+# ---- Keeping the token out of everything ---------------------------------------
+
+REDACTED = ''
+# What a Meta user token may be made of, used both to accept one (check_token) and to find
+# token-shaped values to redact (_GENERIC), so anything accepted is always redacted. The
+# public tools only check the OC/FRL prefix and refuse % | : (docs/meta-library.md); the
+# tokens they describe are letters and digits, so this allows those plus the base64 and
+# URL-safe base64 characters (+ / = - _) and nothing else. Widen it here, in one place, if a
+# real token needs more.
+TOKEN_CHARS = r'A-Za-z0-9+/=_\-' # a regex character-class body
+TOKEN_MIN = 20 # characters, prefix included
+# Any token-shaped value, whoever's it is, in this order: bare OC.../FRL... tokens of the
+# shape check_token accepts first (so a token that happens to contain "access_token=" goes
+# whole, prefix and all), then access_token=... / "access_token": "..." parameters and
+# Authorization schemes.
+#
+# The token shape: the OC or FRL prefix check_token requires and its minimum length, over
+# the characters tokens are made of (TOKEN_CHARS, plus % | : of encoded and app tokens in
+# messages), digits or not. No word boundary or lookbehind: a token is found whatever comes
+# before it (_OC..., /OC..., libOC...); the run then takes every token character after the
+# prefix, so an accepted token is always inside one match. Upper-case prefix, so package
+# names (com.oculus...) are left alone. One run per match, no lookahead: linear. The stored
+# token itself is matched in any case by _clean.
+_TOKEN_SHAPE = re.compile(r'(?:OC[%s%%|:]{%d,}|FRL[%s%%|:]{%d,})'
+ % (TOKEN_CHARS, TOKEN_MIN - 2, TOKEN_CHARS, TOKEN_MIN - 3))
+_GENERIC = [
+ _TOKEN_SHAPE,
+ re.compile(r'(?i)(access[_%5f-]*token["\']?\s*(?:=|:|%3d)\s*["\']?)[^&\s"\'<>,;]+'),
+ re.compile(r'(?i)\b(bearer|oauth)\s+[^\s"\'<>,;]+'),
+]
+# Upper-case constants separated by underscores (OCULUS_PLATFORM_SDK_VERSION) are names in
+# logs and are kept. check_token refuses any value without a lower-case letter, so a run
+# holding an accepted token (which has one) can never look like this.
+_CONSTANT = re.compile(r'[A-Z0-9]+(?:_[A-Z0-9]+)+')
+
+
+def _clean_spans(message, token):
+ """Where in message the token is, or any start of it (8+ characters, as truncation leaves), in
+ any letter case, with any of its characters written literally, percent-encoded (%4B or
+ %4b) or percent-encoded twice (%254B, %254b). Mirrors _itch_account._clean on
+ feat/itch-purchases (plus the double encoding), so the two can be unified."""
+ if not token:
+ return []
+ # Each character: itself, or the (once or twice) percent-encoding of either of its letter
+ # cases; hex digits in any case (re.I).
+ tokens = ['(?:%s)' % '|'.join([re.escape(c)] + [f % ord(v) for v in sorted({c.lower(), c.upper()})
+ for f in ('%%%02x', '%%25%02x')])
+ for c in token]
+ start, rest = re.compile(''.join(tokens[:8]), re.I), [re.compile(t, re.I) for t in tokens[8:]]
+ spans, pos = [], 0
+ while True:
+ found = start.search(message, pos)
+ if not found:
+ break
+ end = found.end()
+ for t in rest: # as much more of the token as follows
+ more = t.match(message, end)
+ if not more:
+ break
+ end = more.end()
+ spans.append((found.start(), end))
+ # Look again from the next character, not the end: a partial match just before the
+ # real token can overlap its start. Overlapping spans merge in _replace.
+ pos = found.start() + 1
+ return spans
+
+
+def _clean(message, token):
+ """message without the token in any of the forms _clean_spans finds."""
+ return _replace(message, _clean_spans(message, token))
+
+
+def _replace(text, spans):
+ """text with every span replaced by REDACTED; overlapping or touching spans merge first,
+ so no part of a value found by one rule survives because another rule found the rest."""
+ out, pos = [], 0
+ for a, b in sorted(spans):
+ if out and a <= pos: # overlaps or touches the previous span
+ pos = max(pos, b)
+ continue
+ out += [text[pos:a], REDACTED]
+ pos = b
+ return ''.join(out) + text[pos:]
+
+
+def _generic_spans(text):
+ spans = []
+ for pattern in _GENERIC:
+ for m in pattern.finditer(text):
+ if pattern is _TOKEN_SHAPE:
+ if not _CONSTANT.fullmatch(m.group(0)):
+ spans.append(m.span())
+ else: # keep the parameter or scheme name, redact its value
+ spans.append((m.end(1), m.end()) if m.groups() else m.span())
+ return spans
+
+
+def carries_token(url, token):
+ """True if url holds the token (or 8+ characters of its start), however cased or encoded,
+ anywhere (host, path, query or fragment), or an access_token parameter of any value.
+ Mirrors _itch_account._carries_key on feat/itch-purchases."""
+ for form in (url, urllib.parse.unquote(url), urllib.parse.unquote_plus(url)):
+ if _clean(form, token) != form:
+ return True
+ names = {name.lower() for name, _ in urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query,
+ keep_blank_values=True)}
+ return 'access_token' in names
+
+
+def redact(text, limit=None, token=None):
+ """text without the stored token (and any other token-shaped value), then cut to limit
+ characters. Redacting before cutting means a cut can't leave part of a token behind."""
+ text = '' if text is None else str(text)
+ # Every rule finds its spans in the original text and overlapping spans merge, so one
+ # rule removing part of a value can never stop another from recognising the rest.
+ spans = _generic_spans(text)
+ for t in {t for t in (token, _stored_token_quiet()) if t}:
+ spans += _clean_spans(text, t)
+ text = _replace(text, spans)
+ if limit is not None and len(text) > limit:
+ text = text[:max(0, limit - 1)] + '…'
+ return text
+
+
+def check_token(token):
+ """The token, stripped, if it looks like a Meta user access token; else MetaError. The
+ rules match what the public tools check before sending one anywhere."""
+ token = (token or '').strip()
+ if token.lower().startswith('bearer '):
+ token = token[7:].strip()
+ if not token:
+ raise MetaError('Paste your Meta access token first.')
+ if len(token) > 4096 or not re.fullmatch(r'[\x21-\x7e]+', token):
+ raise MetaError("That doesn't look like a Meta access token (it has spaces or unusual characters).")
+ if not (token.startswith('OC') or token.startswith('FRL')):
+ raise MetaError("That doesn't look like a Meta access token: it should start with OC or FRL.")
+ if '%' in token:
+ raise MetaError('That token is URL-encoded (it has % in it); copy it from the cookie or request '
+ 'payload itself, not from an address.')
+ if '|' in token or ':' in token:
+ raise MetaError("That's an app token, not your account's token. See docs/meta-library.md for which one to copy.")
+ if re.fullmatch(r'OC\d{15}', token) or len(token) < TOKEN_MIN:
+ raise MetaError("That token is incomplete. Copy the whole value.")
+ if not re.fullmatch('[%s]+' % TOKEN_CHARS, token):
+ raise MetaError("That doesn't look like a Meta token: it has characters a Meta token doesn't (only "
+ 'letters, digits and + / = - _). Copy just the token value.')
+ if not re.search('[a-z]', token): # real tokens are mixed case; this is a constant or a name
+ raise MetaError("That doesn't look like a Meta token: it has no lower-case letters. Copy just the "
+ 'token value.')
+ return token
+
+
+# ---- The account file -------------------------------------------------------------
+
+_lock = threading.Lock() # the account file, the generation and the library cache
+_state = {'generation': 0}
+_library = {'generation': None, 'at': 0, 'apps': None}
+
+
+def account_path():
+ return Path(frame_host.data_dir(ACCOUNT_FILE))
+
+
+def _read_account():
+ try:
+ with open(account_path(), encoding='utf-8') as f:
+ data = json.load(f)
+ except (OSError, ValueError):
+ return None
+ return data if isinstance(data, dict) and isinstance(data.get('token'), str) else None
+
+
+def _stored_token_quiet():
+ account = _read_account()
+ return account['token'] if account else None
+
+
+def _write_account(data):
+ """Write the account file owner-only (0600), atomically: a temporary file made by mkstemp
+ (already 0600) in the same folder, then renamed over the old one."""
+ path = account_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.meta-account-', suffix='.tmp')
+ try:
+ try:
+ os.chmod(tmp, 0o600)
+ except OSError:
+ pass
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
+ json.dump(data, f)
+ os.replace(tmp, str(path))
+ try:
+ os.chmod(str(path), 0o600)
+ except OSError:
+ pass
+ finally:
+ if os.path.exists(tmp):
+ os.remove(tmp)
+
+
+def _session():
+ """(token, generation) for the connected account, or MetaError."""
+ with _lock:
+ account = _read_account()
+ if not account:
+ raise MetaError(NOT_CONNECTED)
+ return account['token'], _state['generation']
+
+
+def _current(generation):
+ with _lock:
+ return _state['generation'] == generation
+
+
+def status():
+ """For the page: whether an account is connected and its display name. Never the token."""
+ account = _read_account()
+ user = (account or {}).get('user') or {}
+ return {'connected': bool(account), 'experimental': True, 'warning': WARNING,
+ 'user': {'name': str(user.get('name') or '')[:80]} if account else None,
+ 'connectedAt': (account or {}).get('connected')}
+
+
+# Sign in with Meta (app/meta-signin.js) names each of its attempts. While a connect from an
+# attempt is being checked it is listed in _attempts (only then, so this never grows); a
+# cancel marks it, and it is then not saved. _saved_by is the attempt whose token is the
+# saved account (None for a pasted token), set together with the save, so a cancel that
+# arrives just after the save still removes that token, and never a later one.
+SIGNIN_ATTEMPT = re.compile(r'[A-Za-z0-9-]{8,64}')
+SIGNIN_CANCELLED = 'Sign in with Meta was cancelled, so that sign-in was not saved.'
+_attempts = {} # in-flight attempt id -> {'cancelled': bool, 'running': connects under way}
+_saved_by = [None]
+
+
+def _check_attempt(attempt):
+ if attempt is not None and not (isinstance(attempt, str) and SIGNIN_ATTEMPT.fullmatch(attempt)):
+ raise MetaError('Invalid sign-in attempt id.')
+ return attempt
+
+
+def cancel_signin(attempt):
+ """Never keep a token from this Sign in with Meta attempt: one still being checked isn't
+ saved, and one already saved is deleted. Unknown attempts change nothing."""
+ if attempt is None:
+ raise MetaError('Invalid sign-in attempt id.')
+ _check_attempt(attempt)
+ with _lock:
+ # Both, independently: a connect that has just saved may still be listed until
+ # its cleanup runs, and its token must go all the same.
+ if attempt in _attempts:
+ _attempts[attempt]['cancelled'] = True
+ if _saved_by[0] == attempt:
+ _forget_locked()
+ return status()
+
+
+def connect(token, attempt=None):
+ """Check the token with Meta ("who am I"), then keep it. A disconnect (or another
+ connect) that happens while it is being checked wins: this one is then not saved. So
+ does cancelling the Sign in with Meta attempt it came from (cancel_signin)."""
+ _check_attempt(attempt)
+ token = check_token(token)
+ with _lock:
+ generation = _state['generation']
+ if attempt is not None:
+ entry = _attempts.setdefault(attempt, {'cancelled': False, 'running': 0})
+ entry['running'] += 1
+ try:
+ user = whoami(token)
+ with _lock:
+ if attempt is not None and _attempts[attempt]['cancelled']:
+ raise SignInCancelled(SIGNIN_CANCELLED)
+ if _state['generation'] != generation:
+ raise MetaError('The Meta account was disconnected (or another token connected) while this one '
+ 'was being checked, so it was not saved.')
+ _write_account({'token': token, 'user': user, 'connected': time.strftime('%Y-%m-%dT%H:%M:%S')})
+ _saved_by[0] = attempt
+ _state['generation'] += 1
+ _library.update(generation=None, at=0, apps=None)
+ finally:
+ if attempt is not None:
+ with _lock:
+ entry = _attempts[attempt]
+ entry['running'] -= 1
+ if not entry['running']:
+ del _attempts[attempt]
+ return status()
+
+
+def _forget_locked():
+ _state['generation'] += 1
+ _saved_by[0] = None
+ _library.update(generation=None, at=0, apps=None)
+ try:
+ os.remove(str(account_path()))
+ except FileNotFoundError:
+ pass
+
+
+def disconnect():
+ """Forget the token: delete the file and the library in memory. Downloads under way stop."""
+ with _lock:
+ _forget_locked()
+ return status()
+
+
+# ---- HTTPS to Meta ----------------------------------------------------------------
+
+# Connections: every address a host resolves to must be public (public_address), and the
+# connection is made to exactly one of those checked addresses, with TLS verified for the
+# host name, so a second DNS answer can't swap in another (no urllib, so no environment
+# proxies either). This mirrors _web.public_address / resolve_public / _public_connection
+# on feat/itch-purchases, so the two can be unified when both merge.
+
+# IPv6 is an allow-list: only global unicast (2000::/3), which rules out everything else at
+# once (loopback, unspecified, mapped/compatible IPv4, NAT64 64:ff9b::, site-local fec0::,
+# unique-local fc00::, link-local, multicast, reserved). Inside it, these special blocks are
+# refused too: 6to4 and Teredo carry IPv4 that may be private; the rest aren't the internet.
+_GLOBAL6 = ipaddress.ip_network('2000::/3')
+_SPECIAL6 = tuple(ipaddress.ip_network(n) for n in (
+ '2001::/32', # Teredo
+ '2001:2::/48', # benchmarking
+ '2001:10::/28', # ORCHID
+ '2001:20::/28', # ORCHIDv2
+ '2001:db8::/32', # documentation
+ '2002::/16', # 6to4
+ '3fff::/20')) # documentation (2024)
+# IPv4 blocks that are never the public internet, listed in full rather than trusting
+# is_global (Python 3.9 calls 192.0.0.8, .11 and .255 global).
+_SPECIAL4 = tuple(ipaddress.ip_network(n) for n in (
+ '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
+ '192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15',
+ '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32'))
+
+
+def public_address(ip):
+ """True for an address on the public internet: never loopback, private, link-local,
+ site-local, shared (CGNAT), reserved, unspecified or multicast, nor an IPv6 wrapper
+ around IPv4. Python 3.9's is_global alone lets several of these through."""
+ try:
+ a = ipaddress.ip_address(ip.split('%', 1)[0])
+ except ValueError:
+ return False
+ if a.version == 6 and (a not in _GLOBAL6 or any(a in n for n in _SPECIAL6)):
+ return False
+ if a.version == 4 and any(a in n for n in _SPECIAL4):
+ return False
+ return a.is_global and not (a.is_reserved or a.is_private or a.is_loopback or a.is_link_local
+ or a.is_multicast or a.is_unspecified)
+
+
+def resolve_public(host):
+ """host's addresses for port 443, if every address it has is public; else MetaError."""
+ try:
+ addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
+ except (OSError, UnicodeError):
+ raise MetaError(f"Couldn't find {host}; check the internet connection") from None
+ if not addresses or not all(public_address(a[4][0]) for a in addresses):
+ raise MetaError(f'{host} is on a private or local network address; not connecting')
+ found = []
+ for a in addresses:
+ if a[4][:2] not in found:
+ found.append(a[4][:2])
+ return found
+
+
+CONNECT_BUDGET = 20 # seconds for connecting, all addresses and TLS handshakes together
+
+
+def _public_connection(host, timeout, budget=CONNECT_BUDGET):
+ """A verified TLS connection to host:443, made to an address checked to be public.
+
+ Each checked address is tried in turn, all within one deadline (budget, or timeout if
+ shorter) covering both connecting and the handshake. The one connected to is always one
+ that was checked, so a second lookup can't swap in another. timeout is for reads after."""
+ deadline = time.monotonic() + min(budget, timeout)
+ error = None
+ for address in resolve_public(host):
+ if time.monotonic() >= deadline:
+ break
+ try:
+ return _connect(host, address, timeout, deadline)
+ except OSError as e: # unreachable, refused, or its TLS failed: try the next one
+ error = e
+ raise error or socket.timeout('Timed out connecting to ' + host)
+
+
+def _connect(host, address, timeout, deadline):
+ """TLS to exactly address, verified as host, connected and handshaken before deadline
+ (a time.monotonic() value); reads then get timeout seconds each."""
+ def remaining():
+ left = deadline - time.monotonic()
+ if left <= 0:
+ raise socket.timeout('Timed out connecting to ' + host)
+ return left
+ sock = socket.create_connection(address, timeout=remaining())
+ try:
+ sock.settimeout(remaining())
+ sock = ssl.create_default_context().wrap_socket(sock, server_hostname=host,
+ do_handshake_on_connect=False)
+ sock.settimeout(remaining())
+ sock.do_handshake()
+ sock.settimeout(timeout)
+ except BaseException:
+ sock.close()
+ raise
+ conn = http.client.HTTPConnection(host, 443, timeout=timeout)
+ conn.sock = sock
+ return conn
+
+
+class _Answer:
+ """A response that closes its connection with it."""
+
+ def __init__(self, conn, response):
+ self.conn, self.response = conn, response
+
+ def read(self, n=-1):
+ return self.response.read(n)
+
+ def close(self):
+ try:
+ self.response.close()
+ finally:
+ self.conn.close()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ self.close()
+
+
+def _send(url, data=None, headers=None, timeout=TIMEOUT):
+ """One HTTPS request to a checked public address, redirects not followed: (status,
+ headers, readable body). The tests replace this with a fake Meta."""
+ p = urllib.parse.urlsplit(check_url(url))
+ conn = _public_connection(p.hostname, timeout)
+ try:
+ conn.request('POST' if data is not None else 'GET', (p.path or '/') + ('?' + p.query if p.query else ''),
+ body=data, headers=headers or {})
+ r = conn.getresponse()
+ except BaseException:
+ conn.close()
+ raise
+ return r.status, r.headers, _Answer(conn, r)
+
+
+def check_url(url):
+ """url if it is HTTPS on port 443 without credentials; else MetaError (never echoing the
+ URL, which can hold the token). No lookup: addresses are checked when connecting."""
+ try:
+ p = urllib.parse.urlsplit(url)
+ port = p.port
+ except (ValueError, TypeError):
+ raise MetaError("Meta's server gave an invalid address") from None
+ if p.scheme != 'https' or p.username or p.password or port not in (None, 443) or not p.hostname:
+ raise MetaError("Meta's server sent the download somewhere that isn't plain HTTPS; not following it")
+ return url
+
+
+def next_hop(current, location, token):
+ """Where a redirect from current to location goes, checked before any lookup or
+ connection: plain HTTPS, and off Meta's own hosts never carrying the token in any form,
+ anywhere in the address (host, path, query or fragment). Refused, not rewritten."""
+ url = check_url(urllib.parse.urljoin(current, location or ''))
+ if (urllib.parse.urlsplit(url).hostname or '').lower() not in TOKEN_HOSTS and carries_token(url, token):
+ raise MetaError("Meta's server pointed the download at an address carrying your token; refused, so "
+ 'the token is not sent anywhere but Meta')
+ return url
+
+
+def _follow(url, headers, token):
+ """GET url, following up to MAX_REDIRECTS redirects through next_hop. Returns
+ (status, headers, body) of the final answer."""
+ check_url(url)
+ for _ in range(MAX_REDIRECTS + 1):
+ status, rheaders, body = _send(url, headers=headers)
+ if status in (301, 302, 303, 307, 308):
+ location = rheaders.get('Location') if rheaders else None
+ body.close()
+ if not location:
+ raise MetaError("Meta's server redirected the download without saying where")
+ url = next_hop(url, location, token)
+ continue
+ return status, rheaders, body
+ raise MetaError("Meta's server redirected the download too many times")
+
+
+def graphql(token, doc_id, variables=None):
+ """One query to Meta's GraphQL API: the token travels in the POST body, never the URL.
+ Returns the answer's "data"; raises TokenRejected or MetaError with readable text."""
+ body = urllib.parse.urlencode({'access_token': token, 'doc_id': doc_id,
+ 'variables': json.dumps(variables or {})}).encode()
+ try:
+ status, _, r = _send(GRAPH_URL, data=body, headers={
+ 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'User-Agent': UA})
+ with r:
+ raw = r.read(MAX_RESPONSE + 1)
+ except MetaError:
+ raise
+ except (http.client.HTTPException, OSError, ValueError) as e:
+ raise MetaError("Couldn't reach Meta's API: " + redact(getattr(e, 'reason', e), 200, token)) from None
+ if len(raw) > MAX_RESPONSE:
+ raise MetaError("Meta's API answer was too large")
+ if 300 <= status < 400:
+ raise MetaError(f"Meta's API redirected the request (HTTP {status}); Frame Control doesn't follow that")
+ try:
+ answer = json.loads(raw.decode('utf-8'))
+ except (UnicodeDecodeError, ValueError):
+ answer = None
+ error = answer.get('error') if isinstance(answer, dict) else None
+ if status in (401, 403) or (isinstance(error, dict) and (error.get('type') == 'OAuthException'
+ or error.get('code') in (102, 190))):
+ raise TokenRejected(REJECTED)
+ if status != 200 or not isinstance(answer, dict):
+ said = error.get('message') if isinstance(error, dict) else ''
+ raise MetaError(f"Meta's API said HTTP {status}" + (f': {redact(said, 200, token)}' if said else ''))
+ if isinstance(error, dict):
+ raise MetaError("Meta's API refused the request: " + redact(error.get('message'), 200, token))
+ errors = answer.get('errors')
+ if errors and not answer.get('data'):
+ first = errors[0] if isinstance(errors, list) and errors else {}
+ raise MetaError("Meta's API refused the request: "
+ + redact(first.get('message') if isinstance(first, dict) else first, 200, token))
+ data = answer.get('data')
+ if not isinstance(data, dict):
+ raise MetaError("Meta's API answered without data; it may have changed")
+ return data
+
+
+def whoami(token):
+ """The account a token belongs to ({'id', 'name'}), from a harmless read-only query."""
+ data = graphql(token, DOC_VIEWER)
+ user = ((data.get('viewer') or {}).get('user') if isinstance(data.get('viewer'), dict) else None)
+ if not isinstance(user, dict) or not user.get('id'):
+ raise TokenRejected(REJECTED)
+ return {'id': str(user['id'])[:40], 'name': str(user.get('alias') or user.get('display_name') or '')[:80]}
+
+
+# ---- The library --------------------------------------------------------------------
+
+def _int(value):
+ try:
+ n = int(str(value).strip())
+ return n if n >= 0 else None
+ except (TypeError, ValueError):
+ return None
+
+
+def _nodes(connection):
+ """The items of a GraphQL list, whether it is {nodes: [...]}, {edges: [{node}]} or a list."""
+ if isinstance(connection, list):
+ items = connection
+ elif isinstance(connection, dict):
+ items = connection.get('nodes') or [e.get('node') for e in connection.get('edges') or [] if isinstance(e, dict)]
+ else:
+ items = []
+ return [i for i in items if isinstance(i, dict)]
+
+
+def _file(node, required=None):
+ """An OBB or asset file: {'id', 'name', 'size', 'required', 'sha256'} or None."""
+ if not isinstance(node, dict) or not str(node.get('id') or '').isdigit():
+ return None
+ digest = str(node.get('sha256') or node.get('sha256_hash') or '').lower()
+ return {'id': str(node['id']), 'name': str(node.get('file_name') or ''),
+ 'size': _int(node.get('size')), 'sha256': digest if re.fullmatch(r'[0-9a-f]{64}', digest) else None,
+ 'required': bool(node.get('is_required')) if required is None else required}
+
+
+def parse_binary(binary):
+ """The download facts of one Android binary: package, version, size, OBB and asset files."""
+ if not isinstance(binary, dict):
+ return {}
+ out = {}
+ if str(binary.get('id') or '').isdigit():
+ out['binary_id'] = str(binary['id'])
+ if isinstance(binary.get('package_name'), str) and android.PKG_RE.match(binary['package_name']):
+ out['package'] = binary['package_name']
+ if binary.get('version') not in (None, ''):
+ out['version'] = str(binary['version'])[:80]
+ if _int(binary.get('version_code')) is not None:
+ out['version_code'] = _int(binary['version_code'])
+ if _int(binary.get('size')) is not None:
+ out['size'] = _int(binary['size'])
+ digest = str(binary.get('sha256') or '').lower()
+ if re.fullmatch(r'[0-9a-f]{64}', digest):
+ out['sha256'] = digest
+ if 'obb_binary' in binary:
+ out['obb'] = _file(binary.get('obb_binary'), required=True)
+ if 'asset_files' in binary:
+ out['assets'] = [f for f in (_file(n) for n in _nodes(binary.get('asset_files'))) if f]
+ return out
+
+
+def parse_entitlements(data, now=None):
+ """The Quest apps an entitlements answer says the account owns, newest facts first.
+ Expired or inactive entitlements and other platforms (Rift, Go) are left out."""
+ now = now or time.time()
+ user = ((data.get('viewer') or {}).get('user') or {}) if isinstance(data, dict) else {}
+ apps, seen = [], set()
+ for node in _nodes(user.get('active_entitlements') if isinstance(user, dict) else None):
+ state = str(node.get('active_state') or 'ACTIVE').upper()
+ expires = _int(node.get('expiration_time'))
+ item = node.get('item')
+ if state not in OWNED_STATES or (expires and expires < now) or not isinstance(item, dict):
+ continue
+ app_id = str(item.get('id') or '')
+ platform = str(item.get('platform') or '').upper()
+ if not app_id.isdigit() or app_id in seen or (platform and platform not in QUEST_PLATFORMS):
+ continue
+ seen.add(app_id)
+ app = {'id': app_id, 'title': str(item.get('display_name') or item.get('name') or app_id)[:120],
+ 'package': None, 'version': None, 'version_code': None, 'size': None, 'binary_id': None}
+ app.update(parse_binary(item.get('latest_supported_binary')))
+ # Detailed: the answer said what game data the binary has (its OBB and asset files).
+ app['detailed'] = 'obb' in app and 'assets' in app
+ app.setdefault('obb', None)
+ app.setdefault('assets', [])
+ apps.append(app)
+ if len(apps) >= MAX_APPS:
+ break
+ return sorted(apps, key=lambda a: a['title'].lower())
+
+
+def binary_details(token, binary_id):
+ """The full facts of one binary (package, version code, size, OBB, asset files)."""
+ data = graphql(token, DOC_BINARY, {'binaryID': str(binary_id)})
+ node = data.get('node')
+ if not isinstance(node, dict):
+ node = next((v for v in data.values() if isinstance(v, dict) and str(v.get('id') or '') == str(binary_id)), {})
+ return parse_binary(node)
+
+
+def _complete(app):
+ return bool(app.get('package') and app.get('version_code') is not None and app.get('detailed'))
+
+
+def _fill(token, app):
+ """Add one binary query's facts to app (in place)."""
+ try:
+ facts = binary_details(token, app['binary_id'])
+ except TokenRejected:
+ raise
+ except MetaError as e:
+ app['problem'] = str(e)
+ return app
+ app.update({k: v for k, v in facts.items() if v is not None})
+ app['detailed'] = True
+ app.pop('problem', None)
+ return app
+
+
+def library(refresh=False):
+ """The Quest apps the connected account owns, each with its download facts. Kept in
+ memory only (LIBRARY_TTL), and dropped by a disconnect. Apps the list answer doesn't
+ fully describe get one binary query each, at most MAX_DETAILS per refresh; the rest
+ are filled in when installed."""
+ token, generation = _session()
+ with _lock:
+ if (not refresh and _library['generation'] == generation and _library['apps'] is not None
+ and time.time() - _library['at'] < LIBRARY_TTL):
+ return [dict(a) for a in _library['apps']]
+ apps = parse_entitlements(graphql(token, DOC_ENTITLEMENTS))
+ missing = [a for a in apps if a.get('binary_id') and not _complete(a)][:MAX_DETAILS]
+ if missing:
+ with concurrent.futures.ThreadPoolExecutor(DETAIL_WORKERS) as pool:
+ for future in [pool.submit(_fill, token, a) for a in missing]:
+ future.result()
+ with _lock:
+ if _state['generation'] == generation:
+ _library.update(generation=generation, at=time.time(), apps=[dict(a) for a in apps])
+ return apps
+
+
+def owned(app_id, refresh=False):
+ """An app the connected account owns, by Meta app id; MetaError if it isn't in the library."""
+ app_id = str(app_id or '')
+ if not re.fullmatch(r'[0-9]{1,25}', app_id):
+ raise MetaError('invalid Meta app id')
+ for attempt in (refresh, True):
+ app = next((a for a in library(refresh=attempt) if a['id'] == app_id), None)
+ if app or attempt:
+ break
+ if not app:
+ raise MetaError("That app isn't in your Meta library, so Frame Control won't download it.")
+ if app.get('binary_id') and not _complete(app):
+ token, _ = _session()
+ _fill(token, app)
+ if not app.get('binary_id') or not app.get('package'):
+ raise MetaError(f"Meta didn't say which file to download for {app['title']}" +
+ (f" ({app['problem']})" if app.get('problem') else '') + '.')
+ return app
+
+
+def with_verdicts(apps):
+ """apps with the community verdict for each package from the compatibility database,
+ when there is one (apk-catalog/reports.py)."""
+ try:
+ import frame_catalog
+ by = frame_catalog.reports.by_package(frame_catalog.compat_db.load())
+ except Exception: # the library still shows without verdicts
+ return apps
+ for app in apps:
+ found = frame_catalog.reports.verdict(by.get(app.get('package'))) if app.get('package') else None
+ app['verdict'] = {'verdict': found[0], 'lines': found[1]} if found else None
+ return apps
+
+
+# ---- Downloading -------------------------------------------------------------------
+
+_install_lock = threading.Lock()
+
+
+def cache_root():
+ return Path(frame_host.cache_dir('meta-library'))
+
+
+def sweep_cache(now=None):
+ """Remove downloads a crash left behind (older than CACHE_TTL)."""
+ root, now = cache_root(), now or time.time()
+ if root.is_dir():
+ for d in root.iterdir():
+ try:
+ if now - d.stat().st_mtime > CACHE_TTL:
+ shutil.rmtree(d, ignore_errors=True)
+ except OSError:
+ pass
+
+
+class Budget:
+ """The bytes one install (or CLI download) may still download, across all its files.
+ Counted from the bytes actually received, so files of unknown size count too."""
+
+ def __init__(self, total=None):
+ self.left = MAX_TOTAL if total is None else total
+
+ def take(self, n, name):
+ if n > self.left:
+ raise MetaError(f'{name} would take this install past the {MAX_TOTAL / 1024**3:.0f} GB Frame Control '
+ 'downloads for one game; download stopped and removed')
+ self.left -= n
+
+
+def fetch(file_id, dest, limit, token, generation, expected=None, sha256=None, progress=None, budget=None):
+ """Download one file by id from Meta's download server into dest. Bounded by limit, by
+ the size Meta declared and by budget (the whole install's bytes); checked against its
+ SHA-256 when Meta gives one. Stops if the account is disconnected meanwhile. Nothing is
+ left behind on failure."""
+ budget = budget if budget is not None else Budget()
+ if expected is not None and (expected > limit or expected > budget.left):
+ raise MetaError(f'{dest.name} is {expected / 1024**3:.1f} GB, more than Frame Control downloads '
+ f'({min(limit, budget.left) / 1024**3:.1f} GB left for this game)')
+ url = DOWNLOAD_URL + '?' + urllib.parse.urlencode({'id': file_id, 'access_token': token})
+ part = dest.with_name(dest.name + '.part')
+ try:
+ try:
+ status, headers, body = _follow(url, {'User-Agent': UA}, token)
+ except MetaError:
+ raise
+ except (http.client.HTTPException, OSError, ValueError) as e:
+ raise MetaError(f"Couldn't download {dest.name} from Meta: " + redact(getattr(e, 'reason', e), 200, token)) from None
+ with body:
+ if status in (401, 403):
+ raise TokenRejected(f"Meta refused to download {dest.name} (HTTP {status}). " + REJECTED)
+ if status != 200:
+ raise MetaError(f"Meta's download server said HTTP {status} for {dest.name}")
+ length = _int((headers or {}).get('Content-Length'))
+ if length is not None and (length > limit or (expected is not None and length != expected)):
+ raise MetaError(f'{dest.name} is not the size Meta said it is; download refused')
+ h, size = hashlib.sha256(), 0
+ with open(part, 'wb') as f:
+ while True:
+ try:
+ chunk = body.read(1 << 20)
+ except (OSError, ValueError) as e:
+ raise MetaError(f'Downloading {dest.name} failed: ' + redact(e, 200, token)) from None
+ if not chunk:
+ break
+ size += len(chunk)
+ if size > limit or (expected is not None and size > expected):
+ raise MetaError(f'{dest.name} is larger than Meta said; download stopped')
+ if not _current(generation):
+ raise MetaError('The Meta account was disconnected; the download was stopped and removed.')
+ budget.take(len(chunk), dest.name)
+ h.update(chunk)
+ f.write(chunk)
+ if progress:
+ progress(size)
+ if expected is not None and size != expected:
+ raise MetaError(f'{dest.name} is incomplete ({size} of {expected} bytes); download removed')
+ if sha256 and h.hexdigest() != sha256:
+ raise MetaError(f"{dest.name} doesn't match the checksum Meta gave; download removed")
+ os.replace(part, dest)
+ return {'path': str(dest), 'size': size, 'sha256': h.hexdigest(), 'verified': bool(sha256)}
+ finally:
+ if part.exists():
+ part.unlink()
+
+
+def obb_name(app, file):
+ """The name an OBB gets on the Frame: Meta's file name when it is a standard
+ main|patch...obb name, else main...obb for the
+ app's own OBB binary. None for anything else (not copied)."""
+ pattern = quest.OBB_NAME.format(re.escape(app['package']))
+ if re.fullmatch(pattern, file.get('name') or ''):
+ return file['name']
+ if file is app.get('obb') and app.get('version_code') is not None:
+ return f"main.{app['version_code']}.{app['package']}.obb"
+ return None
+
+
+def data_plan(app):
+ """(files to download as OBBs [(file, name)], names left out with why)."""
+ files = ([app['obb']] if app.get('obb') else []) + [f for f in app.get('assets') or [] if f.get('required')]
+ plan, skipped, names = [], [], set()
+ for f in files:
+ name = obb_name(app, f)
+ if not name or name in names:
+ skipped.append(f.get('name') or f['id'])
+ continue
+ names.add(name)
+ plan.append((f, name))
+ return plan, skipped
+
+
+def _check_space(work, need, title):
+ free = shutil.disk_usage(str(work)).free
+ if need > free:
+ raise MetaError(f'Installing {title} needs about {need / 1024**3:.1f} GB free on this computer '
+ f'({free / 1024**3:.1f} GB available)')
+
+
+def _bar(report, stage, start, span, total):
+ def progress(done):
+ if total:
+ report(stage, start + int(span * min(done, total) / total))
+ return progress
+
+
+def download_apk(app, work, token, generation, report, budget):
+ report(f"Downloading {app['title']} from Meta", 5)
+ got = fetch(app['binary_id'], work / 'base.apk', MAX_APK, token, generation, expected=app.get('size'),
+ sha256=app.get('sha256'), progress=_bar(report, f"Downloading {app['title']} from Meta", 5, 40,
+ app.get('size')), budget=budget)
+ try:
+ with zipfile.ZipFile(got['path']) as z:
+ if 'AndroidManifest.xml' not in z.namelist():
+ raise MetaError(f"What Meta sent for {app['title']} is not an APK")
+ except zipfile.BadZipFile:
+ raise MetaError(f"What Meta sent for {app['title']} is not an APK") from None
+ return Path(got['path'])
+
+
+def download_data(app, work, token, generation, report, budget):
+ """The app's OBB files, in work/obb: ([paths], [names not copied]). Every byte counts
+ against budget as it arrives; a file that overflows it is removed."""
+ plan, skipped = data_plan(app)
+ folder = work / 'obb'
+ folder.mkdir(exist_ok=True)
+ paths, total = [], sum(f.get('size') or 0 for f, _ in plan)
+ if total > budget.left:
+ raise MetaError(f"{app['title']}'s game data is {total / 1024**3:.1f} GB, more than Frame Control "
+ f"downloads ({MAX_TOTAL // 1024**3} GB)")
+ for i, (f, name) in enumerate(plan):
+ stage = f"Downloading {app['title']}'s game data ({i + 1} of {len(plan)})"
+ report(stage, 50)
+ got = fetch(f['id'], folder / name, MAX_DATA_FILE, token, generation, expected=f.get('size'),
+ sha256=f.get('sha256'), progress=_bar(report, stage, 50, 15, f.get('size')), budget=budget)
+ paths.append(got['path'])
+ return paths, skipped
+
+
+def download(app_id, progress=None):
+ """CLI: download an owned app's APK and OBB files into the local install cache (removed
+ after CACHE_TTL) and say where they are."""
+ report = progress or (lambda stage, percent=None: None)
+ token, generation = _session()
+ app = owned(app_id)
+ sweep_cache()
+ work = cache_root() / f"{app['package']}-{secrets.token_hex(4)}"
+ work.mkdir(parents=True)
+ try:
+ _check_space(work, (app.get('size') or 0) + sum(f.get('size') or 0 for f, _ in data_plan(app)[0]),
+ app['title'])
+ budget = Budget()
+ apk = download_apk(app, work, token, generation, report, budget)
+ obb, skipped = download_data(app, work, token, generation, report, budget)
+ except BaseException:
+ shutil.rmtree(work, ignore_errors=True)
+ raise
+ return {'app': _public(app), 'apk': str(apk), 'obb': obb, 'skipped': skipped, 'folder': str(work)}
+
+
+# ---- Installing -------------------------------------------------------------------
+
+def _public(app):
+ """An app as the page and the CLI see it (download ids are fine: they need the token)."""
+ keys = ('id', 'title', 'package', 'version', 'version_code', 'size', 'problem', 'verdict')
+ out = {k: app.get(k) for k in keys if k in app}
+ plan, skipped = data_plan(app) if app.get('package') else ([], [])
+ out['data_size'] = sum(f.get('size') or 0 for f, _ in plan) or None
+ out['data_files'] = len(plan)
+ out['skipped_files'] = skipped
+ return out
+
+
+def _wait_running(pkg, timeout=START_TIMEOUT):
+ for _ in range(timeout // 3):
+ if quest._running(pkg):
+ return True
+ quest._sleep(3)
+ return quest._running(pkg)
+
+
+def install(app_id, test=False, progress=None, allow_downgrade=False, wait=TEST_WAIT):
+ """Download an owned app from Meta and install it on the Frame (as Import from my Quest
+ does), then, with test, run it and record whether it runs.
+
+ Game data (OBB) can only go into a running Lepton instance. Without test that is a second
+ Install while the person has the game open (Frame Control then copies it in and closes the
+ game). With test, the person asked Frame Control to run the game, so it starts the game
+ itself to add the data, then starts it again for the test."""
+ report = progress or (lambda stage, percent=None: None)
+ if not _install_lock.acquire(blocking=False):
+ raise MetaError('Another My Meta library install is running; wait for it to finish')
+ try:
+ token, generation = _session()
+ report('Reading your Meta library', 1)
+ app = owned(app_id)
+ pkg = app['package']
+ sweep_cache()
+ work = cache_root() / f'{pkg}-{secrets.token_hex(4)}'
+ work.mkdir(parents=True)
+ try:
+ result = _install(app, work, token, generation, report, test, allow_downgrade)
+ finally:
+ shutil.rmtree(work, ignore_errors=True) # never kept: the files are on the Frame now
+ finally:
+ _install_lock.release()
+ if test:
+ report('Testing it on the Frame', 90)
+ outcome = run_test(pkg, wait=wait, app=app)
+ result['test'] = outcome
+ result['message'] += ' ' + outcome['message']
+ report('Done', 100)
+ return result
+
+
+def _install(app, work, token, generation, report, test, allow_downgrade):
+ pkg = app['package']
+ plan, skipped = data_plan(app)
+ data_bytes = sum(f.get('size') or 0 for f, _ in plan)
+ apk_bytes = app.get('size') or 0
+ # The APK, a merged or patched copy of it, and the OBB files.
+ _check_space(work, 2 * apk_bytes + data_bytes, app['title'])
+ budget = Budget()
+ apk = download_apk(app, work, token, generation, report, budget)
+ report('Checking it can run on the Frame', 46)
+ checked = quest.inspect(pkg, str(apk))
+ if app.get('version_code') is not None and checked['version_code'] not in (None, app['version_code']):
+ raise MetaError(f"Meta said {app['title']} is version code {app['version_code']}, but the APK it sent "
+ f"is {checked['version_code']}; not installing it")
+ warnings = list(checked['warnings'])
+ if skipped:
+ warnings.append('Not copied (Frame Control only copies standard OBB files; other files are fetched '
+ "through Meta's Platform SDK, which the Frame doesn't have): " + ', '.join(skipped))
+ label = checked['label']
+ if quest._running(pkg):
+ # The second Install: the game is open, so add its game data to exactly this install.
+ quest._check_target(pkg, android.read_meta(pkg), checked)
+ if not plan:
+ raise MetaError(f'{label} is already installed and open on the Frame, and has no game data to add. '
+ 'Close it first to install it again.')
+ obb, _ = download_data(app, work, token, generation, report, budget)
+ result = quest.add_game_data(pkg, checked, obb, None, report, warnings, source="Meta's download server")
+ result.update(package=pkg, source='meta-store', version_code=checked['version_code'])
+ return result
+ report('Installing on the Frame', 60)
+ with android._install_lock:
+ if quest._running(pkg):
+ raise MetaError(f'{label} was started on the Frame during the install. Close it, then install again.')
+ quest._check_downgrade(android.read_meta(pkg), checked, allow_downgrade)
+ meta = android.install(str(apk), source='Meta Horizon Store')
+ try:
+ apk.unlink() # the Frame has it now
+ except OSError:
+ pass
+ message = f"Installed {meta['label']} from your Meta library. It's in the Steam library."
+ needs_data = bool(plan)
+ added = None
+ if plan and test:
+ obb, _ = download_data(app, work, token, generation, report, budget)
+ report(f'Starting {label} to add its game data', 68)
+ android.launch(pkg)
+ if not _wait_running(pkg):
+ raise MetaError(f"{label} was installed, but its Lepton instance didn't start within "
+ f'{START_TIMEOUT} s, so its game data could not be added. Start it from the Steam '
+ 'library, then use Install again to add the data.')
+ added = quest.add_game_data(pkg, checked, obb, None, report, (), source="Meta's download server")
+ message += ' ' + added['message']
+ needs_data = False
+ elif plan:
+ message += (f" To add its OBB game data, start {meta['label']} from the Steam library, leave it on its "
+ 'first screen, then choose Install again here: Frame Control downloads the data, copies it '
+ 'in and closes the game.')
+ note = android.layer_note(meta)
+ return {'message': ' '.join([message, *warnings, *([note] if note else [])]), 'app': meta,
+ 'package': pkg, 'warnings': warnings, 'needs_game_data': needs_data,
+ 'obb': (added or {}).get('obb', []), 'source': 'meta-store', 'version_code': checked['version_code'],
+ 'platform_sdk': checked['platform_sdk']}
+
+
+# ---- Testing whether it runs ----------------------------------------------------------
+#
+# Signatures for common reasons a Quest game fails on the Frame (from the compatibility
+# research in docs/meta-library.md), written for Frame Control. Each is (key, pattern,
+# kind): 'blocker' explains a quit or a stall, 'issue' a game that runs with something
+# missing. Causes are fixed texts (CAUSES), never log text: reports carry only those. A
+# short excerpt (an extension, library, exception or signal name, matched by a strict
+# pattern) may be shown to the person, redacted before it is shortened, never reported.
+
+SIGNATURES = [
+ # Explicit failure evidence only: a game that merely calls the check (and passes) isn't one.
+ ('licence', re.compile(r'(?i)entitle\w{0,40}\s{0,8}(?:check\s{0,8})?(?:has\s{1,8})?'
+ r'(?:failed|failure|denied|rejected|error)|'
+ r'\bnot entitled\b|failed to (?:verify|check|get) (?:the )?entitlement|'
+ r'(?:ovr_Entitlement\w{0,60}|IsUserEntitledToApplication)(?:\(\))?\s{0,8}'
+ r'(?:failed|returned (?:an )?error|error|=\s{0,8}false|:\s{0,8}false)'), 'blocker'),
+ ('platform', re.compile(r'(?i)ovr_Platform(?:Initialize)?\w{0,60}\s{0,8}(?:failed|error)|'
+ r'libovrplatform\w{0,40}\.so.{0,200}?(?:not found|failed)|'
+ r'Failed to initialize (?:the )?(?:Oculus|Meta) Platform|'
+ r'Oculus Platform.{0,200}?(?:failed|not initialized)'),
+ 'blocker'),
+ ('device', re.compile(r'(?i)Failed to launch SystemActivities|IsRunningOnSantaCruz|'
+ r'(?:device|headset) (?:is )?not supported|unsupported (?:device|headset)|not an? (?:Oculus|Meta) (?:Quest|device)'),
+ 'blocker'),
+ ('vrapi', re.compile(r'(?i)vrapi_\w{1,60} was called before|libvrapi\.so.{0,200}?(?:not found|failed)|'
+ r'vrapi_Initialize\w{0,40}\s{0,8}(?:failed|error)'),
+ 'blocker'),
+ ('extension', re.compile(r'XR_ERROR_EXTENSION_NOT_PRESENT'), 'blocker'),
+ ('xr-version', re.compile(r'XR_ERROR_API_VERSION_UNSUPPORTED'), 'blocker'),
+ ('runtime', re.compile(r'XR_ERROR_RUNTIME_(?:UNAVAILABLE|FAILURE)|XR_ERROR_INITIALIZATION_FAILED|'
+ r'(?i:xrCreateInstance\w{0,40}\s{0,8}(?:failed|returned -))'), 'blocker'),
+ ('gpu', re.compile(r'(?i)VK_ERROR_DEVICE_LOST|DEVICE[ _]LOST|vkCreate\w{1,60} failed|VK_ERROR_\w+|'
+ r'libgallium_dri\.so|zink.{0,200}?(?:error|fail)|GL_INVALID_\w+|GL_OUT_OF_MEMORY|'
+ r'shader (?:compil|link)\w{0,20} (?:failed|error)|failed to (?:compile|link) shader'), 'issue'),
+ ('clipboard', re.compile(r'ClipboardManager'), 'blocker'),
+ ('android', re.compile(r'NoSuchMethodError|NoClassDefFoundError'), 'issue'),
+ ('library', re.compile(r'UnsatisfiedLinkError|dlopen failed'), 'blocker'),
+]
+CAUSES = {
+ 'licence': "Meta's licence check failed (it needs Meta's services, which the Frame doesn't have). "
+ "Frame Control won't remove the check.",
+ 'platform': "Meta's Platform SDK couldn't start (no Meta services on the Frame); online features, and the "
+ 'licence check if the game enforces it, won\'t work.',
+ 'device': 'The game checks that it is on a Meta Quest and stops on the Frame.',
+ 'vrapi': "The game uses Meta's legacy VrApi, which the Frame doesn't have.",
+ 'extension': 'The game asked for an OpenXR feature the Frame lacks.',
+ 'xr-version': "The game asked for an OpenXR version the Frame's runtime refuses.",
+ 'runtime': "The game couldn't start an OpenXR session with the Frame's runtime.",
+ 'gpu': "Graphics errors (the Frame uses the Mesa Vulkan and GLES drivers, which are stricter than the "
+ "Quest's); it may draw wrongly or crash.",
+ 'clipboard': "The game needs Android's clipboard service, which Lepton doesn't have.",
+ 'android': 'The game called an Android feature Lepton (Android 11) lacks.',
+ 'library': "The game needs a library the Frame doesn't have.",
+ 'not-started': f"Its Lepton instance didn't start within {START_TIMEOUT} s.",
+ 'crash-java': 'It crashed (a Java exception).',
+ 'crash-native': 'It crashed (a native crash signal).',
+ 'crash': 'It crashed.',
+ 'quit': 'It closed by itself without a recognisable error.',
+ 'quit-no-log': 'It closed by itself, and its instance stopped before its log could be read.',
+ 'not-responding': 'It stopped responding (Android reported it not responding).',
+ 'no-session': "It stayed open, but its log didn't show its VR scene starting, so Frame Control can't tell "
+ "whether it runs. This result isn't recorded.",
+}
+CRASH = re.compile(r'FATAL EXCEPTION|Fatal signal \d+|\bSIG(?:SEGV|ABRT|BUS|ILL)\b|>>> [\w.]{1,200} <<<|Abort message:')
+HANG = re.compile(r'ANR in [\w.]+|Application Not Responding|Input dispatching timed out')
+# Signs the game got far enough to show its VR scene.
+SESSION = re.compile(r'XR_SESSION_STATE_(?:SYNCHRONIZED|VISIBLE|FOCUSED)|xrEndFrame|xrBeginSession|'
+ r'session state (?:changed )?(?:to )?(?:SYNCHRONIZED|VISIBLE|FOCUSED)', re.I)
+XR_STARTED = re.compile(r'FrameXrCompat.{0,200}?created instance|xrCreateInstance', re.I)
+LINE_SCAN = 4096 # characters of each log line the signatures look at (Android's own line limit)
+LOG_UNREADABLE = ("Frame Control couldn't read the game's log from the Frame, so the test has no result. "
+ 'Check the Frame is connected, then test again.')
+# The compatibility database's result for each outcome; None: not recorded at all.
+RESULTS = {'runs': 'runs', 'runs-with-issues': 'runs', 'quits': 'crashes', 'crashes': 'crashes',
+ 'hangs': 'crashes', 'instance-failed': 'instance_failed', 'inconclusive': None}
+OUTCOME_WORDS = {'runs': 'runs', 'runs-with-issues': 'runs, with issues', 'quits': 'quit',
+ 'crashes': 'crashed', 'hangs': 'seems stuck', 'instance-failed': "didn't start",
+ 'inconclusive': 'gave no clear result'}
+
+
+def _excerpt(key, line):
+ """A short name from the log line behind a cause, for the person only: an OpenXR
+ extension, a library, an exception class or a signal. The whole line is redacted first,
+ then the name is taken whole (never a cut-off end of something longer), and dropped
+ unless it looks like a real identifier."""
+ if key not in _NAMES or not line:
+ return ''
+ # The whole line is redacted, then split once into words; only words short enough to
+ # be names are tested, each with one fullmatch: linear in the line, however long.
+ for word in re.split(r'[^\w.+$-]+', redact(line)):
+ word = word.strip('.')
+ if 0 < len(word) <= 64 and _NAMES[key].fullmatch(word) and _plausible(word):
+ return word
+ return ''
+
+
+_NAMES = {'extension': re.compile(r'XR_(?!ERROR_)[A-Z0-9]+_[A-Za-z0-9_]+'),
+ 'library': re.compile(r'[\w.+-]+\.so'),
+ 'crash-java': re.compile(r'[\w$]+(?:\.[\w$]+)*(?:Exception|Error)'),
+ 'crash-native': re.compile(r'SIG[A-Z]{2,6}')}
+
+
+def _plausible(name):
+ """A name short and plain enough to be a library, extension, class or signal name, not
+ a credential: at most 64 characters, no run of 32+ letters and digits, and no run of 16+
+ that mixes upper case, lower case and digits (as tokens and base64 do)."""
+ if not name or len(name) > 64 or REDACTED in name:
+ return False
+ for run in re.findall(r'[A-Za-z0-9]+', name):
+ if len(run) >= 32 or (len(run) >= 16 and re.search(r'[a-z]', run) and re.search(r'[A-Z]', run)
+ and re.search(r'[0-9]', run)):
+ return False
+ return True
+
+
+def classify(log, alive, instance_up=True, seconds=None, started=True):
+ """An outcome (runs, runs-with-issues, quits, crashes, hangs, instance-failed, or
+ inconclusive when the game stayed open without showing its VR scene), the key of a fixed
+ plain-English cause, and the signals behind it, from the game's Android log, whether its
+ Lepton instance started at all, and whether its process and instance were up at the end."""
+ # Signatures look at the start of each line only (LINE_SCAN), and every pattern is
+ # bounded, so a hostile log can't make this slow. The full line is kept for the excerpt,
+ # which redacts all of it before taking a name.
+ full = (log or '').splitlines()
+ lines = [l[:LINE_SCAN] for l in full]
+ log = '\n'.join(lines)
+ by_scan = dict(zip(lines, full))
+ found = [] # (key, kind, line)
+ for key, pattern, kind in SIGNATURES:
+ line = next((l for l in lines if pattern.search(l)), None)
+ if line is not None:
+ found.append((key, kind, line))
+ crash = next((l for l in lines if CRASH.search(l)), None)
+ hang = bool(HANG.search(log))
+ session = bool(SESSION.search(log))
+ signals = {'alive': bool(alive), 'instance_up': bool(instance_up), 'crash': bool(crash), 'not_responding': hang,
+ 'xr_session': session, 'xr_started': session or bool(XR_STARTED.search(log)),
+ 'matched': [key for key, _, _ in found]}
+ blockers = [(k, l) for k, kind, l in found if kind == 'blocker']
+ issues = [(k, l) for k, kind, l in found if kind == 'issue']
+ first = (blockers + issues or [(None, None)])[0]
+ if not started:
+ outcome, (key, line) = 'instance-failed', ('not-started', None)
+ elif not alive:
+ outcome = 'crashes' if crash else 'quits'
+ if first[0]:
+ key, line = first
+ elif crash:
+ java = next((l for l in lines if 'AndroidRuntime' in l and ('Exception' in l or 'Error' in l)), None)
+ native = next((l for l in lines if re.search(r'Fatal signal|\bSIG[A-Z]{2,6}\b', l)), None)
+ key, line = ('crash-java', java) if java else ('crash-native', native) if native else ('crash', None)
+ else:
+ key, line = ('quit' if log.strip() else 'quit-no-log'), None
+ elif hang:
+ outcome, (key, line) = 'hangs', (blockers[0] if blockers else ('not-responding', None))
+ elif not session:
+ # Open, but no sign of its VR scene: not enough to say it runs, nor that it hangs.
+ outcome, (key, line) = 'inconclusive', (blockers[0] if blockers else ('no-session', None))
+ elif first[0]:
+ outcome, (key, line) = 'runs-with-issues', first
+ else:
+ outcome, key, line = 'runs', None, None
+ cause = CAUSES[key] if key else ''
+ if outcome == 'inconclusive' and key != 'no-session':
+ cause += ' ' + CAUSES['no-session']
+ if seconds is not None:
+ signals['seconds'] = seconds
+ return {'outcome': outcome, 'result': RESULTS[outcome], 'cause_key': key, 'cause': cause,
+ 'detail': _excerpt(key, by_scan.get(line, line)), 'signals': signals}
+
+
+def collect_log(meta):
+ """The game's Lepton instance's Android log (main, system and crash buffers), bounded."""
+ ctr = f"lepton-steamlaunch-{int(meta['instance'])}"
+ return android.ssh(f"podman exec {ctr} /system/bin/sh -c 'logcat -d -b main,system,crash -t 4000' "
+ "2>/dev/null | tail -c 400000 || true", timeout=60)
+
+
+def run_test(pkg, wait=TEST_WAIT, app=None, report_result=True):
+ """Start the game, watch it for `wait` seconds after its Lepton instance is up, then
+ read its log and classify how it went. Records a clear result as a compatibility report
+ (source meta-store, with the version code), shared only if the person chose to share
+ compatibility results; an inconclusive one isn't recorded. The game is left running, as
+ Test does, unless it hangs."""
+ try:
+ return _run_test(pkg, wait, app, report_result)
+ except MetaError:
+ raise # fixed texts already
+ except android.FrameError:
+ # A failed ssh command's error is its output, which here can be the game's own log:
+ # never shown, not even redacted.
+ raise MetaError(LOG_UNREADABLE) from None
+
+
+def _run_test(pkg, wait, app, report_result):
+ meta = android._meta_or_fail(pkg)
+ ctr = f"lepton-steamlaunch-{int(meta['instance'])}"
+ label = meta.get('label') or (app or {}).get('title') or pkg
+ if quest._running(pkg):
+ closed, _ = quest._close(pkg) # a fresh start gives a clean log
+ if not closed:
+ raise MetaError(f"{label} is open on the Frame and Frame Control couldn't close it, so it wasn't "
+ 'tested. Close it from Steam, then test again.')
+ android.launch(pkg)
+ started = up = _wait_running(pkg)
+ alive, log, seconds = False, '', 0
+ if started:
+ # Look every TEST_STEP seconds, keeping the newest log: a game that quits can take
+ # its instance (and its log) with it.
+ steps = max(1, -(-wait // TEST_STEP))
+ sh = f'podman exec {ctr} /system/bin/sh -c'
+ for _ in range(steps):
+ quest._sleep(wait / steps)
+ seconds += wait / steps
+ up = ctr in android.running_instances()
+ alive = up and bool(android.ssh(f"{sh} 'pidof {pkg}' 2>/dev/null || true").strip())
+ if up:
+ log = collect_log(meta) or log
+ if not alive:
+ break
+ seconds = int(round(seconds))
+ verdict = classify(log, alive, up, seconds, started)
+ code = meta.get('version_code') if meta.get('version_code') is not None else (app or {}).get('version_code')
+ verdict.update(package=pkg, version=meta.get('version'), version_code=code, label=label, source='meta-store')
+ verdict['notes'] = report_notes(verdict)
+ word = OUTCOME_WORDS[verdict['outcome']]
+ message = f'Test: {label} {word}.' + (f" {verdict['cause']}" if verdict['cause'] else '')
+ if verdict['detail']:
+ message += f" ({verdict['detail']})"
+ if meta.get('vr_issues') and any('platform SDK' in i for i in meta['vr_issues']) and \
+ verdict['outcome'] not in ('runs', 'inconclusive'):
+ message += ' ' + RAN_PLATFORM_SDK
+ if verdict['outcome'] == 'hangs':
+ closed, _ = quest._close(pkg)
+ verdict['closed'] = closed
+ message += (' Frame Control closed it.' if closed else
+ " Frame Control couldn't close it: the game may still be running; close it from Steam.")
+ verdict['message'] = message
+ if report_result and verdict['result']:
+ verdict['report'] = submit(verdict)
+ elif verdict['result'] is None:
+ verdict['report'] = {'id': None, 'shared': False, 'skipped': 'inconclusive results are not recorded'}
+ return verdict
+
+
+def report_notes(verdict):
+ """The report's notes, from fixed texts only: the outcome, the cause, the version code and
+ the names of the signals matched. Never log text, nothing from the account, never a file."""
+ parts = [f"Meta library test: {verdict['outcome']}"]
+ if verdict.get('cause_key'):
+ parts.append(CAUSES[verdict['cause_key']])
+ if isinstance(verdict.get('version_code'), int):
+ parts.append(f"version code {verdict['version_code']}")
+ matched = [k for k in verdict.get('signals', {}).get('matched', []) if k in CAUSES]
+ if matched:
+ parts.append('signals: ' + ', '.join(matched))
+ return redact('. '.join(p.rstrip('.') for p in parts) + '.', 1000)
+
+
+def submit(verdict):
+ """Record a test through the existing compatibility report path (frame_catalog.add_report):
+ kept on this computer, and shared only as the Privacy settings say."""
+ import frame_catalog
+ import frame_telemetry
+ try:
+ r = frame_catalog.add_report(verdict['package'], verdict.get('version'), result=verdict['result'],
+ notes=verdict['notes'], via='probe', label=verdict.get('label'),
+ source='meta-store')
+ except Exception as e: # the test still happened: say why it wasn't recorded
+ return {'id': None, 'shared': False, 'error': redact(e, 300)}
+ shared = frame_catalog.compat_db.shared() or frame_telemetry.enabled('compat')
+ return {'id': r.get('id'), 'shared': bool(shared)}
+
+
+# ---- CLI ------------------------------------------------------------------------------
+
+def _arg(args, name, default=None):
+ return args[args.index(name) + 1] if name in args and args.index(name) + 1 < len(args) else default
+
+
+def main():
+ cmd, *args = sys.argv[1:] or ['help']
+ say = lambda stage, percent=None: print(f'{percent:>3}% {stage}' if percent is not None else stage, file=sys.stderr)
+ try:
+ if cmd == 'connect':
+ if '--token-stdin' not in args:
+ raise MetaError('usage: connect --token-stdin (then paste the token and press Enter, or pipe it in)')
+ print(WARNING, file=sys.stderr)
+ r = connect(sys.stdin.readline())
+ elif cmd == 'status':
+ r = status()
+ elif cmd == 'disconnect':
+ r = disconnect()
+ elif cmd == 'list':
+ r = [_public(a) for a in with_verdicts(library(refresh='--refresh' in args))]
+ elif cmd == 'download':
+ names = [a for a in args if not a.startswith('--')]
+ if len(names) != 1:
+ raise MetaError('usage: download APP_ID')
+ r = download(names[0], progress=say)
+ elif cmd == 'install':
+ names = [a for a in args if not a.startswith('--')]
+ if len(names) != 1:
+ raise MetaError('usage: install APP_ID [--test] [--allow-downgrade]')
+ r = install(names[0], test='--test' in args, allow_downgrade='--allow-downgrade' in args, progress=say)
+ elif cmd == 'test':
+ names = [a for a in args if not a.startswith('--') and a != _arg(args, '--wait')]
+ if len(names) != 1:
+ raise MetaError('usage: test PACKAGE [--wait SECONDS] [--no-report]')
+ wait = _int(_arg(args, '--wait', TEST_WAIT))
+ if not wait or wait > 600:
+ raise MetaError('--wait must be 1 to 600 seconds')
+ r = run_test(names[0], wait=wait, report_result='--no-report' not in args)
+ else:
+ sys.exit(__doc__)
+ except (android.FrameError, OSError) as e:
+ sys.exit('error: ' + redact(e, 2000))
+ print(json.dumps(r, indent=1))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ui/frame_quest.py b/ui/frame_quest.py
new file mode 100644
index 0000000..97555da
--- /dev/null
+++ b/ui/frame_quest.py
@@ -0,0 +1,644 @@
+"""Import from my Quest: copy games you own off your own Meta Quest onto the Frame.
+
+The Quest is plugged into this computer by USB with developer mode on; its own
+adb copies the APK (and split APKs) into a per-user cache. The APK is checked
+before anything goes to the Frame, then installed through frame_android.install
+like any other APK. Game data (the OBB folder and, if asked, the external
+app-data folder) is copied by a second import while the person has the game
+open, since only a running Lepton instance can take it; Frame Control never
+starts the game itself. The cache is always removed. Games that link Meta's Platform SDK
+(libovrplatformloader.so) install with a warning: linking it doesn't mean the game
+enforces a licence check, but if it does the game may quit or hang on the Frame, and
+Frame Control does not remove the check or work around it. See docs/quest-import.md.
+
+Python stdlib only. CLI: python3 ui/frame_quest.py
+ devices | list [--serial SERIAL] | import PACKAGE [--serial SERIAL] [--data] [--allow-downgrade]
+"""
+import json
+import os
+from pathlib import Path
+import re
+import secrets
+import shlex
+import shutil
+import subprocess
+import sys
+import tarfile
+import threading
+import time
+import zipfile
+
+import frame_android as android
+import frame_host
+
+OBB = '/sdcard/Android/obb'
+DATA = '/sdcard/Android/data'
+OBB_NAME = r'(main|patch)\.[0-9]+\.{}\.obb'
+# Serials of adb connections that aren't a USB headset: Frame Control's own SSH tunnels
+# to the Frame's Lepton instances (127.0.0.1:, server.AdbTunnel) and emulators.
+TUNNEL_RE = re.compile(r'^(127\.0\.0\.1|localhost|\[::1\]):\d+$')
+QUEST_MAKERS = {'oculus', 'meta'}
+META_APPS = ('com.oculus.', 'com.meta.', 'com.facebook.')
+CACHE_TTL = 6 * 3600 # an import left behind by a crash is swept after this
+ADB_TIMEOUT = 30
+PULL_TIMEOUT = 3 * 3600
+READY_TIMEOUT = 150
+BATCH = 15 # packages per adb shell command while listing
+
+# Linking the Platform SDK doesn't mean a game enforces the licence check (Meta's
+# VRC.Quest.Security.1 has only been Recommended since 2021), so it's a warning, not a refusal.
+ENTITLEMENT = ("Uses Meta's licence check (Platform SDK, libovrplatformloader.so); it may quit, hang, or run "
+ "without online features on the Frame. Frame Control won't remove the check.")
+VRAPI = ("Uses Meta's legacy VrApi and no OpenXR loader. The Frame doesn't have VrApi, so it may not run.")
+NO_DEVICE = ("No Quest found on this computer's USB. Connect the Quest with a cable that carries data, "
+ "put the headset on and allow USB debugging. Developer mode must be on first: it needs a Meta "
+ "developer organisation on a verified Meta account (developers.meta.com), then Developer Mode "
+ "switched on for the headset in the Meta Horizon phone app.")
+UNAUTHORISED = ("The Quest hasn't allowed this computer yet. Put the headset on and accept the \"Allow USB "
+ "debugging\" prompt (tick Always allow from this computer), then try again. No prompt? Unplug "
+ "and replug the cable, or switch developer mode off and on.")
+NO_PERMISSIONS = ("This computer sees the Quest but isn't allowed to open it over USB. On Linux, add a udev "
+ "rule for Meta's USB vendor id 2833 (see docs/quest-import.md), then replug it.")
+OFFLINE = "adb reports the Quest offline. Unplug and replug the cable, or restart the headset."
+GONE = "The Quest was disconnected. Plug it back in, put it on, and try again."
+
+
+class QuestError(android.FrameError):
+ pass
+
+
+def _adb_cmd():
+ try:
+ return [frame_host.adb()]
+ except frame_host.HostError as e:
+ raise QuestError(str(e)) from e
+
+
+def _friendly(out):
+ low = out.lower()
+ if 'unauthorized' in low:
+ return UNAUTHORISED
+ if 'no permissions' in low or 'insufficient permissions' in low:
+ return NO_PERMISSIONS
+ if 'device offline' in low:
+ return OFFLINE
+ if 'no devices/emulators found' in low or re.search(r"device '[^']*' not found", low):
+ return GONE
+ return None
+
+
+def adb(*args, serial=None, timeout=ADB_TIMEOUT):
+ """Run this computer's adb (the one Frame Control already finds) and return stdout."""
+ cmd = _adb_cmd() + (['-s', serial] if serial else []) + list(args)
+ try:
+ r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors='replace',
+ timeout=timeout)
+ except subprocess.TimeoutExpired:
+ raise QuestError(f'adb {args[0]} took more than {timeout} s; check the Quest is still connected')
+ except OSError as e:
+ raise QuestError(f'could not run adb: {e}')
+ if r.returncode != 0:
+ out = (r.stderr or '').strip() or (r.stdout or '').strip()
+ raise QuestError(_friendly(out) or out[-600:] or f'adb exited {r.returncode}')
+ return r.stdout
+
+
+# ---- Finding the Quest ------------------------------------------------------
+
+def _parse_devices(out):
+ found = []
+ for line in out.splitlines():
+ line = line.strip()
+ if not line or line.startswith('List of devices') or line.startswith('*'):
+ continue
+ parts = line.split(None, 1)
+ serial, rest = parts[0], (parts[1] if len(parts) > 1 else '')
+ state = 'no permissions' if rest.startswith('no permissions') else (rest.split() or ['unknown'])[0]
+ fields = dict(t.split(':', 1) for t in rest.split() if re.match(r'^[a-z_]+:', t))
+ found.append({'serial': serial, 'state': state, 'usb': 'usb' in fields,
+ 'model': fields.get('model', '').replace('_', ' '), 'manufacturer': ''})
+ return found
+
+
+def devices():
+ """Every adb device on this computer, each with a kind: quest, frame, android (another
+ Android device), frame-tunnel (Frame Control's own SSH-tunnelled connection to a Lepton
+ instance, never touched here), emulator, or unknown (not authorised, offline...)."""
+ found = _parse_devices(adb('devices', '-l', timeout=15))
+ for d in found:
+ if TUNNEL_RE.match(d['serial']):
+ d['kind'] = 'frame-tunnel'
+ elif d['serial'].startswith('emulator-'):
+ d['kind'] = 'emulator'
+ elif d['state'] != 'device':
+ d['kind'] = 'unknown'
+ else:
+ try:
+ props = adb('shell', 'getprop ro.product.manufacturer; getprop ro.product.model',
+ serial=d['serial'], timeout=15).splitlines() + ['', '']
+ except QuestError:
+ d['kind'] = 'unknown'
+ continue
+ d['manufacturer'], d['model'] = props[0].strip(), props[1].strip() or d['model']
+ maker = d['manufacturer'].lower()
+ d['kind'] = ('quest' if maker in QUEST_MAKERS or 'quest' in d['model'].lower()
+ else 'frame' if maker == 'valve' else 'android')
+ return found
+
+
+def find_quest(serial=None):
+ """The one connected, authorised Quest, or a QuestError saying what to do."""
+ found = devices()
+ if serial:
+ d = next((d for d in found if d['serial'] == serial), None)
+ if not d:
+ raise QuestError(f'No adb device {serial} on this computer. ' + NO_DEVICE)
+ if d['kind'] == 'quest':
+ return d
+ state = {'unauthorized': UNAUTHORISED, 'no permissions': NO_PERMISSIONS, 'offline': OFFLINE}.get(d['state'])
+ what = ' '.join(x for x in (d['manufacturer'] or d['kind'], d['model']) if x)
+ raise QuestError(state or f"{serial} isn't a Meta Quest ({what}).")
+ quests = [d for d in found if d['kind'] == 'quest']
+ if len(quests) == 1:
+ return quests[0]
+ if quests:
+ raise QuestError('Several Quests are connected: ' + ', '.join(f"{d['model']} ({d['serial']})" for d in quests)
+ + '. Choose one, or unplug the others.')
+ states = {d['state'] for d in found if d['kind'] == 'unknown'}
+ for state, message in (('unauthorized', UNAUTHORISED), ('no permissions', NO_PERMISSIONS), ('offline', OFFLINE)):
+ if state in states:
+ raise QuestError(message)
+ others = [d for d in found if d['kind'] in ('android', 'frame')]
+ if others:
+ raise QuestError(NO_DEVICE + ' (adb sees ' + ', '.join(
+ f"{d['manufacturer']} {d['model']}".strip() for d in others) + ", which isn't a Quest.)")
+ raise QuestError(NO_DEVICE)
+
+
+# ---- What's on it -------------------------------------------------------------
+
+def _package_script(packages):
+ parts = []
+ for p in packages:
+ parts += [f'echo @@{p}', f'pm path {p}', f'du -sk {OBB}/{p} 2>/dev/null', f'du -sk {DATA}/{p} 2>/dev/null']
+ return '; '.join(parts + ['true'])
+
+
+def _parse_packages(out, packages):
+ apps, current = {}, None
+ for line in out.splitlines():
+ line = line.strip()
+ if line.startswith('@@') and line[2:] in packages:
+ current = apps[line[2:]] = {'package': line[2:], 'apks': [], 'obb': None, 'data': None}
+ elif current is None or not line:
+ continue
+ elif line.startswith('package:'):
+ current['apks'].append(line[len('package:'):])
+ else:
+ size, _, where = line.partition('\t') if '\t' in line else line.partition(' ')
+ if size.isdigit():
+ key = 'obb' if where.strip().startswith(OBB + '/') else 'data' if where.strip().startswith(DATA + '/') else None
+ if key:
+ current[key] = int(size) * 1024
+ result = []
+ for p in packages:
+ app = apps.get(p)
+ if not app or not app['apks']:
+ continue # removed while listing
+ app['split'] = len(app['apks']) > 1
+ app['meta'] = p.startswith(META_APPS)
+ result.append(app)
+ return result
+
+
+def packages(serial):
+ """Third-party apps on the Quest: APK paths (several means a split APK), and the size
+ in bytes of the OBB and external data folders (None when there is none)."""
+ out = adb('shell', 'pm list packages -3', serial=serial)
+ names = sorted({l.split(':', 1)[1].strip() for l in out.splitlines() if l.startswith('package:')})
+ names = [n for n in names if android.PKG_RE.match(n)]
+ apps = []
+ for i in range(0, len(names), BATCH):
+ batch = names[i:i + BATCH]
+ apps += _parse_packages(adb('shell', _package_script(batch), serial=serial, timeout=120), batch)
+ return apps
+
+
+def package(serial, pkg):
+ apps = _parse_packages(adb('shell', _package_script([pkg]), serial=serial, timeout=60), [pkg])
+ if not apps:
+ raise QuestError(f"{pkg} isn't installed on the Quest")
+ return apps[0]
+
+
+def status(serial=None, with_apps=True):
+ """For the page: the Quest (or why there isn't one), what else adb sees, and its apps."""
+ try:
+ found = devices()
+ except QuestError as e:
+ return {'quest': None, 'devices': [], 'guidance': str(e), 'apps': []}
+ try:
+ quest = find_quest(serial)
+ except QuestError as e:
+ return {'quest': None, 'devices': found, 'guidance': str(e), 'apps': []}
+ return {'quest': quest, 'devices': found, 'guidance': None,
+ 'apps': packages(quest['serial']) if with_apps else []}
+
+
+# ---- Checking what was copied ---------------------------------------------------
+
+def inspect(package_name, base, splits=()):
+ """Refuse what can't run on the Frame (32-bit only, Android newer than Lepton's, splits
+ that can't be merged), before anything is copied to it; warn about what may not run."""
+ names = set()
+ for path in [base, *splits]:
+ try:
+ with zipfile.ZipFile(path) as z:
+ names |= set(z.namelist())
+ except (zipfile.BadZipFile, OSError) as e:
+ raise QuestError(f'{os.path.basename(path)} copied from the Quest is not a readable APK: {e}')
+ info = android.apk_info(base)
+ label = info['label']
+ if info['package'] != package_name:
+ raise QuestError(f"The APK copied for {package_name} says it is {info['package']}; not importing it")
+ platform_sdk = any(n.rsplit('/', 1)[-1] == 'libovrplatformloader.so' for n in names)
+ abis = sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2})
+ if abis and 'arm64-v8a' not in abis:
+ raise QuestError(f"{label} is a 32-bit-only build ({', '.join(abis)}). The Frame runs 64-bit ARM "
+ "(arm64-v8a) apps only; older Quest games are often 32-bit. Ask the developer "
+ "whether a 64-bit build exists.")
+ android.check_installable(dict(info, abis=abis)) # Android version
+ if splits:
+ android.split_entries(base, splits) # a split APK Frame Control can't merge fails now
+ warnings = [ENTITLEMENT] if platform_sdk else []
+ openxr = 'lib/arm64-v8a/libopenxr_loader.so' in names
+ if 'lib/arm64-v8a/libvrapi.so' in names and not openxr:
+ warnings.append(VRAPI)
+ return {'package': info['package'], 'label': label, 'version': info['version'],
+ 'version_code': info.get('version_code'), 'abis': abis,
+ 'vr': bool(info.get('vr') or openxr), 'platform_sdk': platform_sdk, 'warnings': warnings}
+
+
+# ---- Importing ------------------------------------------------------------------
+
+_import_lock = threading.Lock()
+_sleep = time.sleep
+
+
+def cache_root():
+ return Path(frame_host.cache_dir('quest-import'))
+
+
+def sweep_cache(now=None):
+ """Remove imports a crash left behind (older than CACHE_TTL)."""
+ root, now = cache_root(), now or time.time()
+ if root.is_dir():
+ for d in root.iterdir():
+ try:
+ if now - d.stat().st_mtime > CACHE_TTL:
+ shutil.rmtree(d, ignore_errors=True)
+ except OSError:
+ pass
+
+
+def _safe(name):
+ return re.sub(r'[^A-Za-z0-9._-]', '_', name)[:120] or 'part.apk'
+
+
+def _remote_sizes(serial, paths):
+ out = adb('shell', 'stat -c %s ' + ' '.join(shlex.quote(p) for p in paths), serial=serial)
+ return sum(int(x) for x in out.split() if x.isdigit())
+
+
+def _pull(serial, remote, local):
+ adb('pull', remote, str(local), serial=serial, timeout=PULL_TIMEOUT)
+
+
+def _wait_ready(meta):
+ """Until the app's Lepton instance has finished booting Android."""
+ container = f"lepton-steamlaunch-{int(meta['instance'])}"
+ deadline = time.monotonic() + READY_TIMEOUT
+ while time.monotonic() < deadline:
+ out = android.ssh(f"podman exec {container} /system/bin/sh -c 'getprop sys.boot_completed' "
+ "2>/dev/null || true", timeout=30)
+ if out.strip() == '1':
+ return
+ _sleep(3)
+ raise QuestError(f"{meta['label']} didn't start on the Frame within {READY_TIMEOUT // 60} minutes, so its "
+ "game data couldn't be added. Check it opens from the Steam library, then import again.")
+
+
+def push_data(meta, folder):
+ """Stream a pulled /sdcard/Android/data/ folder into the running instance as a
+ tar, written straight into the SSH transfer: nothing is staged on this computer."""
+ import frame_android_data
+ pkg = meta['package']
+ container = f"lepton-steamlaunch-{int(meta['instance'])}"
+ script = f'set -eu; mkdir -p {DATA}/{pkg}; cd {DATA}/{pkg}; tar -x -o -f -'
+ read_end, write_end = os.pipe()
+ state = {'count': 0, 'error': None}
+
+ def write():
+ try:
+ with os.fdopen(write_end, 'wb') as out, \
+ tarfile.open(fileobj=out, mode='w|', format=tarfile.GNU_FORMAT) as tar:
+ for path in sorted(Path(folder).rglob('*')):
+ if path.is_symlink() or not (path.is_file() or path.is_dir()):
+ continue # regular files and folders only
+ info = tar.gettarinfo(str(path), arcname=path.relative_to(folder).as_posix())
+ info.uid = info.gid = 0
+ info.uname = info.gname = ''
+ if path.is_file():
+ with path.open('rb') as f:
+ tar.addfile(info, f)
+ state['count'] += 1
+ else:
+ tar.addfile(info)
+ except (OSError, tarfile.TarError, ValueError) as e: # ValueError: the reader closed first
+ state['error'] = e
+
+ writer = threading.Thread(target=write, daemon=True)
+ writer.start()
+ try:
+ with os.fdopen(read_end, 'rb') as src:
+ frame_android_data._stream(shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script]),
+ src=src)
+ finally:
+ writer.join(60)
+ if state['error']:
+ raise QuestError(f'could not read the app files copied from the Quest: {state["error"]}')
+ return state['count']
+
+
+def space_needed(apk_bytes, merged_bytes, obb_bytes, data_bytes):
+ """Bytes an import needs free on this computer: the APKs as copied, a merged APK and the
+ install's patched copy of it (each up to merged_bytes), the OBB folder and the
+ Android/data folder (streamed to the Frame, never staged twice)."""
+ return apk_bytes + 2 * merged_bytes + obb_bytes + data_bytes
+
+
+def _check_space(work, need, pkg):
+ free = shutil.disk_usage(str(work)).free
+ if need > free:
+ raise QuestError(f'Importing {pkg} needs about {need / 1024**3:.1f} GB free on this computer '
+ f'({free / 1024**3:.1f} GB available in {work.parent})')
+
+
+def import_game(package_name, serial=None, data=False, progress=None, allow_downgrade=False):
+ """Copy package_name from the Quest to the Frame. progress(stage, percent).
+
+ Frame Control never starts the game itself. Its game data (OBB, and Android/data if
+ asked) can only be written into a running Lepton instance, and there is no way to
+ boot one without its game, so that's a second import: the person starts the game,
+ imports again, and Frame Control copies the data in and closes the game.
+
+ allow_downgrade: replace a newer version on the Frame with the Quest's older one."""
+ if not android.PKG_RE.match(package_name or ''):
+ raise QuestError('invalid package name')
+ report = progress or (lambda stage, percent=None: None)
+ if not _import_lock.acquire(blocking=False):
+ raise QuestError('Another Quest import is running; wait for it to finish')
+ try:
+ report('Looking for your Quest', 1)
+ quest = find_quest(serial)
+ app = package(quest['serial'], package_name)
+ sweep_cache()
+ work = cache_root() / f'{package_name}-{secrets.token_hex(4)}'
+ work.mkdir(parents=True)
+ try:
+ return _import(quest['serial'], app, data, work, report, allow_downgrade)
+ finally:
+ shutil.rmtree(work, ignore_errors=True)
+ finally:
+ _import_lock.release()
+
+
+def _running(pkg):
+ # strict: a failed `podman ps` raises, never reads as "not running".
+ return f'lepton-steamlaunch-{android.instance_id(pkg)}' in android.running_instances(strict=True)
+
+
+def _installed_code(pkg, meta):
+ """The installed version code: from the install record, else (installs made before
+ records kept it) read from the installed app.apk on the Frame; None if unknown."""
+ code = (meta or {}).get('version_code')
+ if isinstance(code, int) and not isinstance(code, bool):
+ return code
+ return android.installed_version_code(pkg) if meta else None
+
+
+def _version(name, code):
+ return ' '.join(x for x in (f'version {name}' if name else '', f'code {code}' if code is not None else 'unknown code') if x)
+
+
+def _check_downgrade(existing, checked, allow):
+ """Refuse to replace a newer install on the Frame with the Quest's older copy."""
+ if not existing or allow:
+ return
+ have, new = _installed_code(existing['package'], existing), checked['version_code']
+ if have is None or new is None:
+ raise QuestError(f"Frame Control can't tell which version of {checked['label']} is on the Frame, so it "
+ "won't replace it in case that one is newer. Allow replacing a newer version (or "
+ "--allow-downgrade) to install your Quest's copy anyway.")
+ if int(new) < int(have):
+ raise QuestError(f"The Frame has a newer {checked['label']} ({_version(existing.get('version'), have)}) than "
+ f"your Quest ({_version(checked['version'], new)}). Frame Control won't replace it with "
+ "an older version unless you allow it (Allow replacing a newer version, or "
+ "--allow-downgrade).")
+
+
+def _check_target(pkg, meta, checked):
+ """The install record of the game open on the Frame, if it is this package, in its own
+ instance, at the Quest's exact version code; else a QuestError."""
+ label = checked['label']
+ if (not meta or meta.get('package') != pkg or android._int(meta.get('instance')) != android.instance_id(pkg)):
+ raise QuestError(f"{label} is open on the Frame, but Frame Control has no matching install record for "
+ 'it. Close it, then import again to install it.')
+ have, new = _installed_code(pkg, meta), checked['version_code']
+ if have is None or new is None or int(have) != int(new):
+ raise QuestError(f"{label} is open on the Frame with a different version (Frame: "
+ f"{_version(meta.get('version'), have)}; Quest: {_version(checked['version'], new)}). "
+ "Close it, then import again to install the Quest's version.")
+ return meta
+
+
+def _check_android_package(meta, code):
+ """The package Android itself has installed in the instance is this one, at this version."""
+ pkg = meta['package']
+ container = f"lepton-steamlaunch-{int(meta['instance'])}"
+ out = android.ssh(f"podman exec {container} /system/bin/sh -c 'dumpsys package {pkg}' 2>/dev/null || true",
+ timeout=60)
+ # Only this package's own block (dumpsys can list other packages too).
+ block = re.search(r'^\s*Package \[' + re.escape(pkg) + r'\] \(.*?(?=^\s*Package \[|\Z)', out, re.M | re.S)
+ found = re.search(r'\bversionCode=(\d+)', block.group(0)) if block else None
+ if not found:
+ raise QuestError(f'Android in {container} does not have {pkg} installed')
+ if int(found.group(1)) != int(code):
+ raise QuestError(f'Android in {container} has {pkg} version code {found.group(1)}, not the '
+ f"Quest's {code}")
+
+
+def _close(pkg):
+ """Stop the game and confirm its instance is gone: (closed, why not)."""
+ try:
+ android.stop(pkg)
+ for _ in range(6):
+ if not _running(pkg):
+ return True, None
+ _sleep(2)
+ return False, 'it is still running'
+ except Exception as e: # never replaces the error that led here; unconfirmed counts as not closed
+ return False, str(e) or type(e).__name__
+
+
+def _import(serial, app, data, work, report, allow_downgrade=False):
+ pkg = app['package']
+ obb_bytes = app['obb'] or 0
+ data_bytes = (app['data'] or 0) if data else 0
+ apk_bytes = _remote_sizes(serial, app['apks'])
+ _check_space(work, space_needed(apk_bytes, apk_bytes, obb_bytes, data_bytes), pkg)
+ # 1. The APK (and its splits), checked before anything else is copied.
+ local = []
+ for i, remote in enumerate(app['apks']):
+ report(f'Copying the app from your Quest ({i + 1} of {len(app["apks"])})', 5 + 20 * i // len(app['apks']))
+ name = 'base.apk' if os.path.basename(remote) == 'base.apk' else f'{i:02d}-{_safe(os.path.basename(remote))}'
+ _pull(serial, remote, work / name)
+ if not (work / name).is_file():
+ raise QuestError(f'adb did not copy {remote} from the Quest')
+ local.append(work / name)
+ base = next((p for p in local if p.name == 'base.apk'), local[0])
+ splits = [p for p in local if p != base]
+ report('Checking it can run on the Frame', 27)
+ checked = inspect(pkg, str(base), [str(p) for p in splits])
+ warnings = list(checked['warnings'])
+ label = checked['label']
+ # A merge stores what splits compressed: count it uncompressed, now the files are here.
+ merged = base.stat().st_size + android.split_size([str(p) for p in splits])
+ _check_space(work, space_needed(0, merged, obb_bytes, data_bytes), pkg)
+ wants_data = app['obb'] is not None or bool(data and app['data'] is not None)
+ if not _running(pkg):
+ # 2a. Install. The game is never started here: see import_game. Checked and installed
+ # under the shared install lock, so nothing installs or removes it in between.
+ report('Installing on the Frame', 60)
+ with android._install_lock:
+ if _running(pkg):
+ raise QuestError(f'{label} was started on the Frame during the import. Close it, then import again.')
+ _check_downgrade(android.read_meta(pkg), checked, allow_downgrade)
+ meta = android.install(str(base), splits=[str(p) for p in splits], source='Meta Quest (USB)')
+ report('Done', 100)
+ message = f"Imported {meta['label']} from your Quest. It's in the Steam library."
+ if wants_data:
+ what = ' and '.join(x for x in ('its OBB game data' if app['obb'] is not None else '',
+ 'its Android/data files' if data and app['data'] is not None else '') if x)
+ message += (f" To add {what}, start {meta['label']} from the Steam library, leave it on its first "
+ "screen, then import it again here: Frame Control copies the data in and closes the game.")
+ note = android.layer_note(meta)
+ return {'message': ' '.join([message, *warnings, *([note] if note else [])]), 'app': meta, 'obb': [],
+ 'files': 0, 'warnings': warnings, 'split': bool(splits), 'needs_game_data': wants_data,
+ 'closed': None}
+ # 2b. The game is open on the Frame: add its data to exactly this install, never replacing it.
+ _check_target(pkg, android.read_meta(pkg), checked) # early, before copying the data off the Quest
+ if not wants_data:
+ raise QuestError(f"{label} is already installed and open on the Frame, and has no game data to add. "
+ 'Close it first to install it again.')
+ obb_files = []
+ if app['obb'] is not None:
+ report('Copying game data (OBB) from your Quest', 30)
+ (work / 'obb').mkdir()
+ _pull(serial, f'{OBB}/{pkg}', work / 'obb')
+ folder = work / 'obb' / pkg
+ files = sorted(p for p in folder.iterdir() if p.is_file()) if folder.is_dir() else []
+ obb_files = [p for p in files if re.fullmatch(OBB_NAME.format(re.escape(pkg)), p.name)]
+ skipped = [p.name for p in files if p not in obb_files]
+ if skipped:
+ warnings.append('Not copied, not standard OBB file names: ' + ', '.join(skipped))
+ data_folder = None
+ if data and app['data'] is not None:
+ report('Copying app files from your Quest', 50)
+ (work / 'data').mkdir()
+ _pull(serial, f'{DATA}/{pkg}', work / 'data')
+ data_folder = work / 'data' / pkg if (work / 'data' / pkg).is_dir() else None
+ result = add_game_data(pkg, checked, [str(p) for p in obb_files], data_folder, report, warnings)
+ result['split'] = bool(splits)
+ return result
+
+
+def add_game_data(pkg, checked, obb_files, data_folder, report, warnings=(), source='your Quest'):
+ """Copy game data into the game open on the Frame, then close it: the second step of a
+ two-step install (Import from my Quest, and My Meta library). checked is inspect()'s
+ result for the APK the data belongs to; obb_files are local OBB paths, data_folder a
+ local Android/data/ folder or None. Raises QuestError, after closing the game,
+ if anything fails."""
+ warnings = list(warnings)
+ label = checked['label']
+ copied_obb, copied_files, failure = [], 0, None
+ # Fetching the data can take a long time: check the target again, and hold the shared
+ # install lock (install, remove and install_obb take it too) until the game is closed.
+ with android._install_lock:
+ if not _running(pkg):
+ raise QuestError(f'{label} was closed on the Frame during the import. Start it, then import again.')
+ meta = _check_target(pkg, android.read_meta(pkg), checked)
+ closed, why = None, None
+ try:
+ report('Waiting for Android on the Frame', 70)
+ _wait_ready(meta)
+ _check_android_package(meta, checked['version_code'])
+ if obb_files:
+ report('Copying game data (OBB) to the Frame', 80)
+ copied_obb = android.install_obb(pkg, [str(p) for p in obb_files])['obb']
+ if data_folder:
+ report('Copying app files to the Frame', 92)
+ copied_files = push_data(meta, data_folder)
+ except Exception as e: # any failure, local (OSError) or on the Frame, is reported below
+ failure = e
+ finally:
+ # Whatever happened, even Ctrl-C part-way through a write, the game is closed so it
+ # never runs on half-copied data. A failed close never hides the original error.
+ interrupted = sys.exc_info()[0] is not None
+ closed, why = _close(pkg)
+ if interrupted and not closed:
+ print(f"Frame Control couldn't close {meta['label']} on the Frame ({why}); close it from Steam "
+ 'before playing.', file=sys.stderr)
+ if failure:
+ message = f"Copying {meta['label']}'s game data failed: {failure}."
+ message += (' The game was closed; start it and import again to retry.' if closed else
+ f" Frame Control also couldn't close the game ({why}): close it from Steam, then start it "
+ 'and import again.')
+ raise QuestError(message) from failure
+ report('Done', 100)
+ message = f"Added {meta['label']}'s game data from {source}"
+ message += ('; the game is closed, start it again from the Steam library.' if closed else
+ f", but Frame Control couldn't close the game ({why}). Close it from Steam before playing, so it "
+ 'starts with the new data.')
+ if copied_obb:
+ message += f' Added {len(copied_obb)} OBB file{"s" if len(copied_obb) != 1 else ""}.'
+ if copied_files:
+ message += f' Copied {copied_files} app file{"s" if copied_files != 1 else ""}.'
+ return {'message': ' '.join([message, *warnings]), 'app': meta, 'obb': copied_obb, 'files': copied_files,
+ 'warnings': warnings, 'split': False, 'needs_game_data': False, 'closed': closed}
+
+
+def main():
+ cmd, *args = sys.argv[1:] or ['help']
+ serial = args[args.index('--serial') + 1] if '--serial' in args and args.index('--serial') + 1 < len(args) else None
+ try:
+ if cmd == 'devices':
+ r = devices()
+ elif cmd == 'list':
+ r = packages(find_quest(serial)['serial'])
+ elif cmd == 'import':
+ names = [a for a in args if not a.startswith('--') and a != serial]
+ if len(names) != 1:
+ raise QuestError('usage: import PACKAGE [--serial SERIAL] [--data] [--allow-downgrade]')
+ r = import_game(names[0], serial, data='--data' in args, allow_downgrade='--allow-downgrade' in args,
+ progress=lambda stage, percent=None: print(f'{percent:>3}% {stage}' if percent is not None
+ else stage, file=sys.stderr))
+ else:
+ sys.exit(__doc__)
+ except (android.FrameError, OSError) as e:
+ sys.exit(f'error: {e}')
+ print(json.dumps(r, indent=1))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ui/frame_report.py b/ui/frame_report.py
index ffcfa9d..93aae3e 100644
--- a/ui/frame_report.py
+++ b/ui/frame_report.py
@@ -6,14 +6,25 @@ project as a `problem_report` event: only the maintainer can read it, and
nothing is published. It is sent whatever the analytics settings are, because
the person sends it deliberately. Diagnostics are scrubbed first
(frame_telemetry.scrub); the person's own words are sent as written.
+
+An email address goes with a report only when the person ticks "may contact me with
+follow-up questions" (contact_followup). Standing choices made in Settings are
+frame_contact.py's `contact_consent` events; `contacts` lists them.
"""
import os
import platform
+import re
+import shutil
+import subprocess
import sys
+import threading
import time
import uuid
+import frame_contact
+import frame_devices
import frame_host
+import frame_link
import frame_telemetry
KINDS = ('bug', 'idea', 'question', 'other')
@@ -23,6 +34,7 @@ LOG_LINES = 60
ACTIVITY_LINES = 25
frame = {} # the Frame's last known SteamOS build, set by server.status()
+link = None # the connector (frame_link.Link), set by server.main; None on the Frame itself
def u16(s):
@@ -54,9 +66,174 @@ def _log_tail():
return list(reversed(keep[-LOG_LINES:]))
+def ssh_path_kind(path):
+ """What sort of ssh a path is, never the path itself (it can hold a user name)."""
+ if not path:
+ return "not found"
+ p = str(path).replace("\\", "/").lower()
+ for marks, kind in ((("/system32/openssh/", "/sysnative/openssh/"), "Windows OpenSSH (System32)"),
+ (("/program files/openssh",), "OpenSSH in Program Files"),
+ (("/git/",), "Git for Windows"), (("msys", "cygwin"), "MSYS2/Cygwin"),
+ (("/opt/homebrew/", "/usr/local/", "linuxbrew"), "Homebrew or /usr/local"),
+ (("/nix/",), "Nix")):
+ if any(m in p for m in marks):
+ return kind
+ if p in ("/usr/bin/ssh", "/bin/ssh"):
+ return "system OpenSSH"
+ return "other"
+
+
+def _ssh_on_path():
+ """Every ssh on PATH, in the order they're found; the first is the one the app runs."""
+ names = ("ssh.exe", "ssh") if frame_host.WINDOWS else ("ssh",)
+ found, seen = [], set()
+ for d in os.get_exec_path():
+ for name in names:
+ cand = os.path.join(d, name)
+ key = os.path.normcase(os.path.abspath(cand))
+ if key not in seen and os.path.isfile(cand):
+ seen.add(key)
+ found.append(cand)
+ break
+ return found
+
+
+# Only a real banner at the very start ("OpenSSH_9.9p1, LibreSSL 3.3.6",
+# "OpenSSH_for_Windows_9.5p1, LibreSSL 3.8.2"): rebuilt from fixed names and its numbers.
+BANNER_RE = re.compile(r"OpenSSH_(for_Windows_)?(\d+)\.(\d+)(p\d+)?(?=[,\s]|$)")
+LIBRARY_RE = re.compile(r",?\s*(LibreSSL|OpenSSL) (\d+\.\d+\.\d+[a-z]?)(?=[,\s]|$)")
+
+
+def parse_ssh_version(said):
+ """"OpenSSH 9.9p1, LibreSSL 3.3.6"-style text from `ssh -V`'s output, or "unknown"."""
+ said = (said or "").lstrip()
+ m = BANNER_RE.match(said)
+ if not m:
+ return "unknown"
+ out = f"OpenSSH{' for Windows' if m.group(1) else ''} {m.group(2)}.{m.group(3)}{m.group(4) or ''}"
+ lib = LIBRARY_RE.match(said, m.end())
+ return out + (f", {lib.group(1)} {lib.group(2)}" if lib else "")
+
+
+def ssh_version(path):
+ """The version from `ssh -V` (it prints to stderr), nothing else it says."""
+ try:
+ r = frame_host.run_ssh([path, "-V"], capture_output=True, stdin=subprocess.DEVNULL, text=True,
+ errors="replace", timeout=5)
+ except (OSError, subprocess.SubprocessError) as e:
+ return f"couldn't run it ({type(e).__name__})"
+ return parse_ssh_version(r.stderr or r.stdout)
+
+
+def _ssh_check():
+ path = shutil.which("ssh")
+ if not path:
+ return "SSH: no ssh on PATH"
+ others = [ssh_path_kind(p) for p in _ssh_on_path()
+ if os.path.normcase(os.path.abspath(p)) != os.path.normcase(os.path.abspath(path))]
+ others = [k for i, k in enumerate(others) if k not in others[:i]]
+ return f"SSH: {ssh_path_kind(path)}, {ssh_version(path)}" + (f"; also on PATH: {', '.join(others)}" if others else "")
+
+
+_ssh = {"thread": None, "line": None}
+_ssh_lock = threading.Lock()
+SSH_WAIT = 0.3 # how long a report preview waits for the ssh check (PATH can hold slow network drives)
+
+
+def start_ssh_check():
+ """Look for ssh once, in the background (server.main starts it), so a report never waits
+ on PATH folders on slow or mapped drives."""
+ def run():
+ try:
+ line = _ssh_check()
+ except Exception as e: # a report must still go out
+ line = f"SSH: couldn't check ({type(e).__name__})"
+ _ssh["line"] = line
+ with _ssh_lock:
+ if _ssh["thread"] is None:
+ _ssh["thread"] = threading.Thread(target=run, name="report-ssh-check", daemon=True)
+ _ssh["thread"].start()
+ return _ssh["thread"]
+
+
+def ssh_line():
+ start_ssh_check().join(SSH_WAIT)
+ return _ssh["line"] or "SSH: still being checked"
+
+
+def config_line(alias):
+ """Whether ~/.ssh/config has the managed block for the alias, and a hand-written Host for it."""
+ try:
+ text = frame_devices.ssh_config().read_text(encoding="utf-8", errors="replace")
+ except FileNotFoundError:
+ return "~/.ssh/config: missing"
+ except OSError as e:
+ return f"~/.ssh/config: can't read it ({type(e).__name__})"
+ blocks = [b["alias"] for b in frame_devices.parse_blocks(text)]
+ outside, inside = [], None
+ for line in text.splitlines():
+ m = frame_devices.BLOCK_RE.fullmatch(line.strip())
+ if m:
+ inside = m.group(1)
+ elif inside and line.strip() == frame_devices.end_mark(inside):
+ inside = None
+ elif not inside:
+ outside.append(line)
+ own = any(alias in re.split(r"[\s=]+", ln.strip())[1:] for ln in outside
+ if re.match(r"(?i)\s*host[\s=]", ln))
+ return (f"~/.ssh/config: managed block for the active alias {'yes' if alias in blocks else 'no'} "
+ f"({len(blocks)} managed in all); hand-written Host for it {'yes' if own else 'no'}")
+
+
+def alias_kind(alias):
+ """`default ("frame")` or `custom`: a name someone chose can say who they are."""
+ return 'default ("frame")' if alias == "frame" else "custom"
+
+
+def connection_lines():
+ """A short summary of the connector in fixed words only: states, stages, error categories,
+ kinds of address, counts. No text from errors or ssh, no custom alias, never an address."""
+ if link is None:
+ return ["Connection: no connector (this server doesn't reach a headset over SSH)"]
+ snap = link.snapshot()
+ active = link.active_device()
+ alias = active.get("alias") or "?"
+ phase, err, via = snap.get("phase") or "idle", snap.get("error"), snap.get("via")
+ head = f"Connection: {phase}"
+ if phase == "connected" and via:
+ rtt = via.get("rtt_ms")
+ head += f" via {frame_link.address_kind(via.get('host'), via.get('ip'))}" + (f" ({rtt:g} ms)" if rtt is not None else "")
+ elif err:
+ head += f" at {err.get('stage')}"
+ head += f", attempt {snap.get('attempt') or 0}" + (f" ({snap['reason']})" if snap.get("reason") else "")
+ lines = [head]
+ kind = ("none set up" if active.get("none") else "a bare ssh alias" if active.get("transient")
+ else f"saved, {len(active.get('addresses') or [])} address(es)")
+ lines.append(f"Headsets: {len(link.reg.devices())} saved; active alias {alias_kind(alias)} ({kind})")
+ if err:
+ lines.append(f"Error: {err.get('stage')}, {frame_link.failure_category(err.get('message'), err.get('raw'))}")
+ last = link.last_failure
+ if last:
+ ago = max(0, int(frame_link.now() - last.get("at", 0)))
+ lines.append(f"Last failure: {last.get('stage')}, {last.get('category')}, {ago // 60} min {ago % 60} s ago")
+ lines.append(f"Addresses tried: {frame_link.probes_summary(snap.get('probes')) or 'none yet'}")
+ net = snap.get("network")
+ if net:
+ ts = net.get("tailscale") or {}
+ lines.append(f"Network: gateway {'yes' if net.get('gateway') else 'no'}, Tailscale "
+ f"{'on' if ts.get('up') else 'off' if ts.get('installed') else 'not installed'}")
+ for part in (ssh_line, lambda: config_line(alias)):
+ try:
+ lines.append(part())
+ except Exception as e: # a report must still go out
+ lines.append(f"({type(e).__name__} while checking SSH)")
+ return 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
+ and builds, and a connection summary (connection_lines: kinds of address and states, never
+ the addresses, so "the app can't find the headset" can be told apart); 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
@@ -69,7 +246,11 @@ def diagnostics(activity=(), include_logs=False, limit=DIAG_MAX):
f"Analytics: {levels}",
f"Report time: {time.strftime('%Y-%m-%d %H:%M %Z')}",
]
- out = frame_telemetry.scrub('\n'.join(env), limit=limit)
+ try:
+ conn = connection_lines()
+ except Exception as e: # never stop a report over its diagnostics
+ conn = [f"Connection: summary unavailable ({type(e).__name__})"]
+ out = frame_telemetry.scrub('\n'.join(env) + '\n\n' + '\n'.join(conn), 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)]),
@@ -107,9 +288,18 @@ def send(body):
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
title, text, diag = compose(body)
+ followup = frame_contact.flag(body, 'contactFollowup')
+ contact = str(body.get('contact') or '').strip() if followup else ''
+ if followup and not frame_contact.valid_email(contact):
+ raise ValueError('add your email address for follow-up questions, or untick that box')
+ started = time.time() # a removal from now on (even while saving the address) is redacted from the log
+ # It becomes the contact email in Settings, where it's changed or removed like any other.
+ contact_id, contact_rev = frame_contact.from_report(contact) if followup else ('', 0)
ref = uuid.uuid4().hex[:8].upper()
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
- 'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag,
+ 'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
+ # Only with an address: a later change from this copy (higher rev) can take it back.
+ 'contact_id': contact_id, 'contact_rev': contact_rev,
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
# Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
@@ -119,7 +309,9 @@ def send(body):
except frame_telemetry.SendError as e:
raise ReportError(str(e))
try:
- frame_telemetry.record_sent([event])
+ with frame_telemetry._lock: # the lock a removal holds while wiping its address
+ frame_contact.redact_removed(event, started)
+ frame_telemetry.record_sent([event])
except OSError:
pass # it was sent; failing to log it here mustn't make the person send it again
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
@@ -131,26 +323,109 @@ class ReportError(RuntimeError):
def inbox(days=30):
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
- frame_compat_db.sync uses)."""
+ frame_compat_db.sync uses). Column 10 is whether the person may be asked follow-up
+ questions now: 'withdrawn' when a later choice from the same copy took it back."""
import frame_compat_db
+ days = int(days)
res = frame_compat_db._posthog_query(
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
- "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics "
- f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
+ "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
+ "properties.contact_followup, properties.contact_id, properties.contact_rev "
+ f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {days} DAY "
"ORDER BY timestamp DESC LIMIT 200")
- return res.get('results') or []
+ rows = [r for r in res.get('results') or [] if isinstance(r, list) and len(r) == 13]
+ if any(r[11] and _yes(r[10]) for r in rows):
+ later = frame_compat_db._posthog_query(
+ "SELECT distinct_id, properties.email, properties.followup, ifNull(toInt(properties.rev), 0) "
+ "FROM events WHERE event = 'contact_consent' LIMIT 100000")
+ mark_withdrawn(rows, later.get('results') or [])
+ return rows
+
+
+def mark_withdrawn(reports, consents):
+ """Mark reports whose follow-up permission was taken back: the newest contact choice from
+ the same copy made after the report (a higher rev than it carries, not a later clock) no
+ longer agrees to follow-up questions at that address."""
+ newest = {}
+ for c in consents:
+ if not isinstance(c, list) or len(c) != 4:
+ continue
+ cid, email, followup, rev = c
+ try:
+ rev = int(rev or 0)
+ except (TypeError, ValueError):
+ continue
+ if rev > newest.get(str(cid), (-1,))[0]:
+ newest[str(cid)] = (rev, str(email or ''), followup)
+ for r in reports:
+ if not (r[11] and _yes(r[10])):
+ continue
+ try:
+ sent_at = int(r[12] or 0)
+ except (TypeError, ValueError):
+ sent_at = 0
+ rev, email, followup = newest.get(str(r[11]), (-1, '', None))
+ if rev > sent_at and not (_yes(followup) and email.strip().lower() == str(r[5] or '').strip().lower()):
+ r[10] = 'withdrawn'
+
+
+def _yes(v):
+ return v is True or str(v).lower() in ('true', '1')
+
+
+def contacts():
+ """{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest
+ contact_consent event agrees to each, oldest first. A withdrawal, or a change to another
+ address, replaces what came before, so withdrawn addresses are never listed. "Newest" is
+ the highest rev from that copy (then time), so every field comes from the same event
+ whatever order they arrived in or what the clocks said."""
+ import frame_compat_db
+ newest = "tuple(ifNull(toInt(properties.rev), 0), timestamp)"
+ res = frame_compat_db._posthog_query(
+ f"SELECT distinct_id, argMax(properties.email, {newest}), argMax(properties.updates, {newest}), "
+ f"argMax(properties.followup, {newest}), argMax(timestamp, {newest}) FROM events "
+ "WHERE event = 'contact_consent' GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000")
+ out = {'updates': [], 'followup': []}
+ for row in res.get('results') or []:
+ if not isinstance(row, list) or len(row) != 5:
+ continue
+ _, email, updates, followup, ts = row
+ email = str(email or '').strip()
+ if not frame_contact.valid_email(email):
+ continue
+ for kind, agreed in (('updates', updates), ('followup', followup)):
+ if _yes(agreed):
+ out[kind].append((email, str(ts or '')[:10]))
+ return out
+
+
+USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]'
def main():
cmd, *args = sys.argv[1:] or ['inbox']
+ if cmd == 'contacts':
+ kinds = args[:1] or ['updates', 'followup']
+ if not set(kinds) <= {'updates', 'followup'}:
+ sys.exit(USAGE)
+ found = contacts()
+ for kind in kinds:
+ print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}"
+ f" ({len(found[kind])})")
+ for email, since in found[kind]:
+ print(f" {email} (since {since})")
+ print()
+ return
if cmd != 'inbox':
- sys.exit('usage: frame_report.py inbox [days]')
+ sys.exit(USAGE)
for row in inbox(*(args[:1] or [30])):
- if not isinstance(row, list) or len(row) != 10:
- continue
- ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
+ ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
+ # Reports from before contact_followup existed only carried an address given for a reply.
+ reply = contact and (row[10] is None or _yes(row[10]))
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
- print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
+ print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
+ f"{', may follow up at ' + contact if reply else ''}"
+ f"{', follow-up permission since withdrawn' if row[10] == 'withdrawn' else ''}")
print(' ' + text.replace('\n', '\n '))
if diag:
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
diff --git a/ui/frame_status.py b/ui/frame_status.py
index 92ef7a1..998879a 100644
--- a/ui/frame_status.py
+++ b/ui/frame_status.py
@@ -6,6 +6,7 @@ error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
"""
import ctypes as C
import glob
+import hashlib
import math
import json
import os
@@ -121,7 +122,16 @@ def os_release():
k, _, v = line.partition("=")
out[k] = v.strip('"')
return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"),
- "variant": out.get("VARIANT_ID")}
+ "variant": out.get("VARIANT_ID"), "headset": headset_id()}
+
+
+def headset_id():
+ """A one-way hash of the headset's /etc/machine-id: the same for this headset on every
+ computer, and the machine-id itself never leaves the headset. None without one."""
+ mid = read("/etc/machine-id")
+ if not mid:
+ return None
+ return hashlib.sha256(("frame-control-headset:" + mid).encode()).hexdigest()[:16]
def volume():
@@ -292,6 +302,7 @@ def status():
"disk": {"root": disk("/"), "home": disk("/home")},
"memory": memory(),
"temp": max_temp(),
+ "thermalAlerts": thermal_alerts(),
"wifi": wifi(),
"ip": ip_addr(),
"volume": volume(),
diff --git a/ui/frame_telemetry.py b/ui/frame_telemetry.py
index 5771ac7..071b51c 100644
--- a/ui/frame_telemetry.py
+++ b/ui/frame_telemetry.py
@@ -18,7 +18,9 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the
same project 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
+computer, and sent without person profiles or GeoIP. Opens, installs and updates
+carry a country instead, worked out on this computer from its time zone setting
+(frame_timezones.py), to see roughly where Frame Control is used. 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.
@@ -43,6 +45,7 @@ from pathlib import Path
from urllib.parse import urlsplit
import frame_host
+import frame_timezones
HERE = Path(__file__).resolve().parent
STATE = frame_host.data_dir('telemetry')
@@ -50,16 +53,26 @@ SETTINGS = STATE / 'settings.json'
OUTBOX = STATE / 'outbox.jsonl'
SENT = STATE / 'sent.jsonl'
SENT_KEEP = 200
+FRAMES_SEEN_KEEP = 100 # build/headset pairs already reported, enough for several headsets' updates
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
+# Not faults in Frame Control: the headset asleep, away or not set up yet, or ssh and the headset
+# not trusting each other (a host key that changed, a key not authorised): the page says what to do
+# about each (Devices tab, Set Up Connection). The status poll
+# meets these every few seconds, so each is sent at most once per session, whatever the wording,
+# but only for an error marked where ssh ran as ssh failing to reach the headset
+# (frame_host.link_failure). The message alone isn't evidence: a download's "Connection reset by
+# peer", or a file name with ssh's words in it, keeps the usual window.
+EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth')
DEFAULT_HOST = 'https://us.i.posthog.com'
LEVELS = ('usage', 'compat', 'diagnostics')
+NOTICE_VERSION = 2 # raise when the notice lists something new: it's shown again (2: country)
# 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 = {'home', 'games', 'android', 'tools'}
+TABS = {'home', 'games', 'android', 'tools', 'devices', 'control'}
_lock = threading.RLock()
_send_lock = threading.Lock() # held while sending; consent changes wait for it
@@ -96,7 +109,7 @@ def blocked():
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,
+ 'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
'frames_seen': [], 'compat_sent': []}
@@ -138,12 +151,12 @@ def update_settings(changes):
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']
+ if changes.get('noticeShown'):
+ s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION
for level in LEVELS:
if level in changes:
s[level] = bool(changes[level])
- s['notice_shown'] = True
+ s['notice_shown'] = True # only the notice itself moves notice_version on
_save(s)
_drop_unwanted(s)
if changes.get('compat'):
@@ -152,10 +165,16 @@ def update_settings(changes):
return state()
+def _notice_current(s):
+ """Whether the notice this person saw lists everything that's sent now. If not, the page
+ shows it again, and what it added waits until it has been seen."""
+ return s['notice_shown'] and s['notice_version'] >= NOTICE_VERSION
+
+
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'],
+ return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': _notice_current(s),
'diagnostics': s['diagnostics'],
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
'sent': list(reversed(_read_lines(SENT)))[:50]}
@@ -229,9 +248,16 @@ def scrub(text, limit=2000):
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', t)
+ # A home folder's whole name ("C:\Users\Jane Doe", "/Users/O'Brien"), up to the next separator.
+ t = re.sub(r'''(?i)(/Users/|/home/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\n"]+''', r'\1', t)
+ # ssh's "user@host: ..." and "user@host's password:", the whole field: the user even with
+ # spaces or a DOMAIN\ prefix, the host even a full domain name. Before the email rule, which
+ # would otherwise take only the last word of the user.
+ t = re.sub(r'''(?m)(?:^|(?<=: )|(?<=\| ))[^@\n:|"<]{1,64}@[\w.\[\]%:<>-]+?(?=:(?:\s|$)|'s\s)''',
+ '@', t)
for pattern, repl in SCRUBS:
t = pattern.sub(repl, t)
+ t = re.sub(r'(?@', t) # any other word@host
t = IPV6_RE.sub(_ipv6, t)
for name in _user_names():
t = re.sub(r'\b%s\b' % re.escape(name), '', t)
@@ -240,17 +266,47 @@ def scrub(text, limit=2000):
# From the most to the least specific; the first match wins.
CATEGORIES = [
+ # A web-link download (frame_webinstall) that broke or didn't check out: not the headset,
+ # whatever the reason, and first so a file name in the message can't put it elsewhere.
+ ('download_failed', re.compile(r"\A(?:download failed: |download cut off at |downloaded \d+ bytes; |"
+ r"the server says \d+ bytes; )|doesn't match the manifest's sha256; "
+ r"not installing it\Z")),
('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')),
+ ('layer_missing', re.compile(r'OpenXR compatibility layer')),
+ ('apk_repack_failed', re.compile(r'could not prepare the APK for the Frame')),
+ ('tool_missing', re.compile(r"\[WinError 2\]|No such file or directory: '(?:ssh|scp|rsync|adb)")),
+ ('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name|'
+ r'not a readable APK|not an APK|APK manifest|in the APK (?:uses a compression|is )')),
('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')),
+ # The Frame-side scripts that talk to the Steam client's UI (frame/android/steam_shortcuts.py), which
+ # fail with their own words or a Python traceback, its last lines kept. Before frame_unreachable:
+ # "Connection refused" and "timed out" here are Steam's debug port on the Frame, not ssh.
+ ('steam_not_running', re.compile(r'SharedJSContext not found|'
+ r'URLError: |'
+ r'ConnectionRefusedError: \[Errno 111\]')),
+ ('steam_not_responding', re.compile(r'websocket handshake failed|(?:socket\.timeout|TimeoutError): timed out|'
+ r'(?:\A|\n)EOFError\Z')),
+ ('steam_js_error', re.compile(r'JS error: ')),
+ ('artwork_failed', re.compile(r'incomplete artwork render|artwork renderer did not produce|'
+ r'render is \d+ bytes of|could not prepare artwork|all five Steam artwork slots')),
+ # Copying the APK (or its launcher) to the Frame: rsync or scp ran out of time.
+ ('copy_timeout', re.compile(r'to the Frame timed out\Z')),
('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_auth', re.compile(r'(?i)Permission denied|Host key verification failed|'
+ r'REMOTE HOST IDENTIFICATION HAS CHANGED|host key is known for')),
+ # Anything ssh says about its own connection: "ssh: connect to host … port 22: " (Windows
+ # says "Unknown error"), a dropped shared connection (mux_client_…, client_loop), and Windows'
+ # "banner exchange: Connection to UNKNOWN port -1" (its ssh can't name a peer whose connect
+ # failed late). Not a bare "ssh exited 255": a command on the Frame can exit 255 too.
('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')),
+ r'Network is unreachable|Host is down|asleep|kex_exchange|banner exchange|'
+ r'ssh: connect to host |mux_client_|client_loop: ')),
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
+ # rsync or scp failed for another reason ("connection unexpectedly closed", or no words at all).
+ ('copy_failed', re.compile(r'\Acopying .+ to the Frame failed: ')),
('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')),
@@ -258,8 +314,16 @@ CATEGORIES = [
]
+# Python's own errors for a mistake in Frame Control's code, whatever their message says.
+BUG_ERRORS = (KeyError, IndexError, TypeError, AttributeError, NameError, AssertionError,
+ ZeroDivisionError, RecursionError, UnicodeError, json.JSONDecodeError)
+
+
def categorize(message):
- """(category, detail): a fixed category name, plus an Android installer code when there is one."""
+ """(category, detail): a fixed category name, plus an Android installer code when there is one.
+ message may be the exception itself: one of BUG_ERRORS is internal_error."""
+ if isinstance(message, BUG_ERRORS):
+ return 'internal_error', None
text = str(message or '')
for name, pattern in CATEGORIES:
m = pattern.search(text)
@@ -318,39 +382,64 @@ def page_event(body):
return {'queued': capture(name, props)}
+def country():
+ """The two-letter country of the computer's time zone setting, or None. Only names in the
+ tz database's table count, so nothing else in the setting can be sent. The app passes the
+ zone from Electron, which knows it on every OS; from a checkout it's read from /etc/localtime."""
+ tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':')
+ if not tz:
+ try:
+ tz = os.path.realpath('/etc/localtime').split('/zoneinfo/', 1)[1]
+ except (OSError, IndexError):
+ return None
+ return frame_timezones.COUNTRY.get(tz)
+
+
def app_started():
- """Once per server start: first install, an update, and one open a day."""
+ """Once per server start: first install, an update, and one open a day, with the country
+ (left out when sending if the notice this person saw came before it: flush)."""
if blocked():
return
with _lock:
s = settings()
version, today = app_version(), time.strftime('%Y-%m-%d')
+ cc = country()
+ where = {'country': cc} if cc else {}
if not s['installed_sent']:
- capture('app_installed')
+ capture('app_installed', where)
s['installed_sent'] = True
elif s['last_version'] and s['last_version'] != version:
- capture('app_updated', {'from_version': s['last_version']})
+ capture('app_updated', {'from_version': s['last_version'], **where})
if s['last_open_day'] != today:
- capture('app_opened')
+ capture('app_opened', where)
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}'
+def frame_seen(build, version, headset=None, saved=None):
+ """The Frame's SteamOS build (public build numbers), once per build for each headset.
+ headset: the anonymous hash from frame_status.headset_id; saved: how many headsets
+ this copy of the app has set up."""
+ headset = headset if isinstance(headset, str) and re.fullmatch(r'[0-9a-f]{16}', headset) else None
+ key = f'{build}/{version}' + (f'/{headset}' if headset else '')
with _lock:
s = settings()
if not build or key in s['frames_seen']:
return
- s['frames_seen'] = (s['frames_seen'] + [key])[-20:]
+ s['frames_seen'] = (s['frames_seen'] + [key])[-FRAMES_SEEN_KEEP:]
_save(s)
- capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
+ p = {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]}
+ if headset:
+ p['headset_id'] = headset
+ if isinstance(saved, int) and saved >= 0:
+ p['headsets_saved'] = saved
+ capture('frame_connected', p)
-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)."""
+def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props):
+ """kind: apk, flatpak, steam, title or web. props must already be public (no file names).
+ diagnose=False when the error is reported as a diagnostic elsewhere."""
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)
@@ -358,21 +447,41 @@ def install_finished(kind, ok, seconds=None, error=None, **props):
p['error_category'], code = categorize(error)
if code:
p['installer_code'] = code
+ elif not ok:
+ p['error_category'] = 'other'
capture('install_finished', p)
- if error is not None and not ok:
+ if error is not None and not ok and diagnose:
diagnostic(f'{kind} install failed', error)
+def link_failed(error):
+ """Whether an ssh helper marked this error (frame_host.link_failure) as ssh failing to reach the
+ headset: the error itself, or one it was re-raised from (`raise Failure(...) from e`)."""
+ for _ in range(10): # a cause chain is short; never loop on a cycle
+ if error is None:
+ return False
+ if getattr(error, 'frame_link_failed', False):
+ return True
+ error = getattr(error, '__cause__', None)
+ return False
+
+
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]}'
+ category = categorize(error)[0]
now = time.time()
with _lock:
- if now - _seen_errors.get(fingerprint, 0) < REPEAT_WINDOW:
- return
+ if category in EXPECTED_CATEGORIES and link_failed(error):
+ fingerprint = f'expected|{category}'
+ if fingerprint in _seen_errors:
+ return
+ else:
+ fingerprint = f'{where}|{message[:120]}'
+ 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 = []
@@ -385,7 +494,7 @@ def diagnostic(where, error, tb=None):
'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]},
+ 'where': scrub(where, 200), 'error_category': category},
level='diagnostics')
@@ -403,7 +512,7 @@ def compat_report(report):
if k in p:
p[k] = scrub(p[k], n)
src = str(p.pop('source', '') or '')
- if src == 'F-Droid':
+ if src in ('F-Droid', 'meta-store'): # fixed source tags, never a person's file name
p['source'] = src
elif src.startswith(('http://', 'https://')) and _scrub_url(src) != '':
p['source'] = _scrub_url(src)
@@ -497,10 +606,14 @@ def flush(timeout=20):
if blocked() or not settings()['notice_shown']:
return 0
with _lock:
- _drop_unwanted(settings())
+ s = settings()
+ _drop_unwanted(s)
batch = _read_lines(OUTBOX)[:100]
if not batch:
return 0
+ if not _notice_current(s): # what a newer notice added stays here until it's been seen
+ for e in batch:
+ e['properties'].pop('country', None)
try:
post(batch, timeout)
except SendError:
diff --git a/ui/frame_timezones.py b/ui/frame_timezones.py
new file mode 100644
index 0000000..5b05936
--- /dev/null
+++ b/ui/frame_timezones.py
@@ -0,0 +1,547 @@
+"""Time zone name -> two-letter country, from the IANA tz database 2026c (zone.tab, and
+the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
+Zones that aren't in one country, such as UTC, aren't here."""
+COUNTRY = {
+ 'Africa/Abidjan': 'CI',
+ 'Africa/Accra': 'GH',
+ 'Africa/Addis_Ababa': 'ET',
+ 'Africa/Algiers': 'DZ',
+ 'Africa/Asmara': 'ER',
+ 'Africa/Asmera': 'ER',
+ 'Africa/Bamako': 'ML',
+ 'Africa/Bangui': 'CF',
+ 'Africa/Banjul': 'GM',
+ 'Africa/Bissau': 'GW',
+ 'Africa/Blantyre': 'MW',
+ 'Africa/Brazzaville': 'CG',
+ 'Africa/Bujumbura': 'BI',
+ 'Africa/Cairo': 'EG',
+ 'Africa/Casablanca': 'MA',
+ 'Africa/Ceuta': 'ES',
+ 'Africa/Conakry': 'GN',
+ 'Africa/Dakar': 'SN',
+ 'Africa/Dar_es_Salaam': 'TZ',
+ 'Africa/Djibouti': 'DJ',
+ 'Africa/Douala': 'CM',
+ 'Africa/El_Aaiun': 'EH',
+ 'Africa/Freetown': 'SL',
+ 'Africa/Gaborone': 'BW',
+ 'Africa/Harare': 'ZW',
+ 'Africa/Johannesburg': 'ZA',
+ 'Africa/Juba': 'SS',
+ 'Africa/Kampala': 'UG',
+ 'Africa/Khartoum': 'SD',
+ 'Africa/Kigali': 'RW',
+ 'Africa/Kinshasa': 'CD',
+ 'Africa/Lagos': 'NG',
+ 'Africa/Libreville': 'GA',
+ 'Africa/Lome': 'TG',
+ 'Africa/Luanda': 'AO',
+ 'Africa/Lubumbashi': 'CD',
+ 'Africa/Lusaka': 'ZM',
+ 'Africa/Malabo': 'GQ',
+ 'Africa/Maputo': 'MZ',
+ 'Africa/Maseru': 'LS',
+ 'Africa/Mbabane': 'SZ',
+ 'Africa/Mogadishu': 'SO',
+ 'Africa/Monrovia': 'LR',
+ 'Africa/Nairobi': 'KE',
+ 'Africa/Ndjamena': 'TD',
+ 'Africa/Niamey': 'NE',
+ 'Africa/Nouakchott': 'MR',
+ 'Africa/Ouagadougou': 'BF',
+ 'Africa/Porto-Novo': 'BJ',
+ 'Africa/Sao_Tome': 'ST',
+ 'Africa/Timbuktu': 'ML',
+ 'Africa/Tripoli': 'LY',
+ 'Africa/Tunis': 'TN',
+ 'Africa/Windhoek': 'NA',
+ 'America/Adak': 'US',
+ 'America/Anchorage': 'US',
+ 'America/Anguilla': 'AI',
+ 'America/Antigua': 'AG',
+ 'America/Araguaina': 'BR',
+ 'America/Argentina/Buenos_Aires': 'AR',
+ 'America/Argentina/Catamarca': 'AR',
+ 'America/Argentina/ComodRivadavia': 'AR',
+ 'America/Argentina/Cordoba': 'AR',
+ 'America/Argentina/Jujuy': 'AR',
+ 'America/Argentina/La_Rioja': 'AR',
+ 'America/Argentina/Mendoza': 'AR',
+ 'America/Argentina/Rio_Gallegos': 'AR',
+ 'America/Argentina/Salta': 'AR',
+ 'America/Argentina/San_Juan': 'AR',
+ 'America/Argentina/San_Luis': 'AR',
+ 'America/Argentina/Tucuman': 'AR',
+ 'America/Argentina/Ushuaia': 'AR',
+ 'America/Aruba': 'AW',
+ 'America/Asuncion': 'PY',
+ 'America/Atikokan': 'CA',
+ 'America/Atka': 'US',
+ 'America/Bahia': 'BR',
+ 'America/Bahia_Banderas': 'MX',
+ 'America/Barbados': 'BB',
+ 'America/Belem': 'BR',
+ 'America/Belize': 'BZ',
+ 'America/Blanc-Sablon': 'CA',
+ 'America/Boa_Vista': 'BR',
+ 'America/Bogota': 'CO',
+ 'America/Boise': 'US',
+ 'America/Buenos_Aires': 'AR',
+ 'America/Cambridge_Bay': 'CA',
+ 'America/Campo_Grande': 'BR',
+ 'America/Cancun': 'MX',
+ 'America/Caracas': 'VE',
+ 'America/Catamarca': 'AR',
+ 'America/Cayenne': 'GF',
+ 'America/Cayman': 'KY',
+ 'America/Chicago': 'US',
+ 'America/Chihuahua': 'MX',
+ 'America/Ciudad_Juarez': 'MX',
+ 'America/Coral_Harbour': 'CA',
+ 'America/Cordoba': 'AR',
+ 'America/Costa_Rica': 'CR',
+ 'America/Coyhaique': 'CL',
+ 'America/Creston': 'CA',
+ 'America/Cuiaba': 'BR',
+ 'America/Curacao': 'CW',
+ 'America/Danmarkshavn': 'GL',
+ 'America/Dawson': 'CA',
+ 'America/Dawson_Creek': 'CA',
+ 'America/Denver': 'US',
+ 'America/Detroit': 'US',
+ 'America/Dominica': 'DM',
+ 'America/Edmonton': 'CA',
+ 'America/Eirunepe': 'BR',
+ 'America/El_Salvador': 'SV',
+ 'America/Ensenada': 'MX',
+ 'America/Fort_Nelson': 'CA',
+ 'America/Fort_Wayne': 'US',
+ 'America/Fortaleza': 'BR',
+ 'America/Glace_Bay': 'CA',
+ 'America/Godthab': 'GL',
+ 'America/Goose_Bay': 'CA',
+ 'America/Grand_Turk': 'TC',
+ 'America/Grenada': 'GD',
+ 'America/Guadeloupe': 'GP',
+ 'America/Guatemala': 'GT',
+ 'America/Guayaquil': 'EC',
+ 'America/Guyana': 'GY',
+ 'America/Halifax': 'CA',
+ 'America/Havana': 'CU',
+ 'America/Hermosillo': 'MX',
+ 'America/Indiana/Indianapolis': 'US',
+ 'America/Indiana/Knox': 'US',
+ 'America/Indiana/Marengo': 'US',
+ 'America/Indiana/Petersburg': 'US',
+ 'America/Indiana/Tell_City': 'US',
+ 'America/Indiana/Vevay': 'US',
+ 'America/Indiana/Vincennes': 'US',
+ 'America/Indiana/Winamac': 'US',
+ 'America/Indianapolis': 'US',
+ 'America/Inuvik': 'CA',
+ 'America/Iqaluit': 'CA',
+ 'America/Jamaica': 'JM',
+ 'America/Jujuy': 'AR',
+ 'America/Juneau': 'US',
+ 'America/Kentucky/Louisville': 'US',
+ 'America/Kentucky/Monticello': 'US',
+ 'America/Knox_IN': 'US',
+ 'America/Kralendijk': 'BQ',
+ 'America/La_Paz': 'BO',
+ 'America/Lima': 'PE',
+ 'America/Los_Angeles': 'US',
+ 'America/Louisville': 'US',
+ 'America/Lower_Princes': 'SX',
+ 'America/Maceio': 'BR',
+ 'America/Managua': 'NI',
+ 'America/Manaus': 'BR',
+ 'America/Marigot': 'MF',
+ 'America/Martinique': 'MQ',
+ 'America/Matamoros': 'MX',
+ 'America/Mazatlan': 'MX',
+ 'America/Mendoza': 'AR',
+ 'America/Menominee': 'US',
+ 'America/Merida': 'MX',
+ 'America/Metlakatla': 'US',
+ 'America/Mexico_City': 'MX',
+ 'America/Miquelon': 'PM',
+ 'America/Moncton': 'CA',
+ 'America/Monterrey': 'MX',
+ 'America/Montevideo': 'UY',
+ 'America/Montreal': 'CA',
+ 'America/Montserrat': 'MS',
+ 'America/Nassau': 'BS',
+ 'America/New_York': 'US',
+ 'America/Nipigon': 'CA',
+ 'America/Nome': 'US',
+ 'America/Noronha': 'BR',
+ 'America/North_Dakota/Beulah': 'US',
+ 'America/North_Dakota/Center': 'US',
+ 'America/North_Dakota/New_Salem': 'US',
+ 'America/Nuuk': 'GL',
+ 'America/Ojinaga': 'MX',
+ 'America/Panama': 'PA',
+ 'America/Pangnirtung': 'CA',
+ 'America/Paramaribo': 'SR',
+ 'America/Phoenix': 'US',
+ 'America/Port-au-Prince': 'HT',
+ 'America/Port_of_Spain': 'TT',
+ 'America/Porto_Acre': 'BR',
+ 'America/Porto_Velho': 'BR',
+ 'America/Puerto_Rico': 'PR',
+ 'America/Punta_Arenas': 'CL',
+ 'America/Rainy_River': 'CA',
+ 'America/Rankin_Inlet': 'CA',
+ 'America/Recife': 'BR',
+ 'America/Regina': 'CA',
+ 'America/Resolute': 'CA',
+ 'America/Rio_Branco': 'BR',
+ 'America/Rosario': 'AR',
+ 'America/Santa_Isabel': 'MX',
+ 'America/Santarem': 'BR',
+ 'America/Santiago': 'CL',
+ 'America/Santo_Domingo': 'DO',
+ 'America/Sao_Paulo': 'BR',
+ 'America/Scoresbysund': 'GL',
+ 'America/Shiprock': 'US',
+ 'America/Sitka': 'US',
+ 'America/St_Barthelemy': 'BL',
+ 'America/St_Johns': 'CA',
+ 'America/St_Kitts': 'KN',
+ 'America/St_Lucia': 'LC',
+ 'America/St_Thomas': 'VI',
+ 'America/St_Vincent': 'VC',
+ 'America/Swift_Current': 'CA',
+ 'America/Tegucigalpa': 'HN',
+ 'America/Thule': 'GL',
+ 'America/Thunder_Bay': 'CA',
+ 'America/Tijuana': 'MX',
+ 'America/Toronto': 'CA',
+ 'America/Tortola': 'VG',
+ 'America/Vancouver': 'CA',
+ 'America/Virgin': 'VI',
+ 'America/Whitehorse': 'CA',
+ 'America/Winnipeg': 'CA',
+ 'America/Yakutat': 'US',
+ 'America/Yellowknife': 'CA',
+ 'Antarctica/Casey': 'AQ',
+ 'Antarctica/Davis': 'AQ',
+ 'Antarctica/DumontDUrville': 'AQ',
+ 'Antarctica/Macquarie': 'AU',
+ 'Antarctica/Mawson': 'AQ',
+ 'Antarctica/McMurdo': 'AQ',
+ 'Antarctica/Palmer': 'AQ',
+ 'Antarctica/Rothera': 'AQ',
+ 'Antarctica/South_Pole': 'AQ',
+ 'Antarctica/Syowa': 'AQ',
+ 'Antarctica/Troll': 'AQ',
+ 'Antarctica/Vostok': 'AQ',
+ 'Arctic/Longyearbyen': 'SJ',
+ 'Asia/Aden': 'YE',
+ 'Asia/Almaty': 'KZ',
+ 'Asia/Amman': 'JO',
+ 'Asia/Anadyr': 'RU',
+ 'Asia/Aqtau': 'KZ',
+ 'Asia/Aqtobe': 'KZ',
+ 'Asia/Ashgabat': 'TM',
+ 'Asia/Ashkhabad': 'TM',
+ 'Asia/Atyrau': 'KZ',
+ 'Asia/Baghdad': 'IQ',
+ 'Asia/Bahrain': 'BH',
+ 'Asia/Baku': 'AZ',
+ 'Asia/Bangkok': 'TH',
+ 'Asia/Barnaul': 'RU',
+ 'Asia/Beirut': 'LB',
+ 'Asia/Bishkek': 'KG',
+ 'Asia/Brunei': 'BN',
+ 'Asia/Calcutta': 'IN',
+ 'Asia/Chita': 'RU',
+ 'Asia/Choibalsan': 'MN',
+ 'Asia/Chongqing': 'CN',
+ 'Asia/Chungking': 'CN',
+ 'Asia/Colombo': 'LK',
+ 'Asia/Dacca': 'BD',
+ 'Asia/Damascus': 'SY',
+ 'Asia/Dhaka': 'BD',
+ 'Asia/Dili': 'TL',
+ 'Asia/Dubai': 'AE',
+ 'Asia/Dushanbe': 'TJ',
+ 'Asia/Famagusta': 'CY',
+ 'Asia/Gaza': 'PS',
+ 'Asia/Harbin': 'CN',
+ 'Asia/Hebron': 'PS',
+ 'Asia/Ho_Chi_Minh': 'VN',
+ 'Asia/Hong_Kong': 'HK',
+ 'Asia/Hovd': 'MN',
+ 'Asia/Irkutsk': 'RU',
+ 'Asia/Istanbul': 'TR',
+ 'Asia/Jakarta': 'ID',
+ 'Asia/Jayapura': 'ID',
+ 'Asia/Jerusalem': 'IL',
+ 'Asia/Kabul': 'AF',
+ 'Asia/Kamchatka': 'RU',
+ 'Asia/Karachi': 'PK',
+ 'Asia/Kashgar': 'CN',
+ 'Asia/Kathmandu': 'NP',
+ 'Asia/Katmandu': 'NP',
+ 'Asia/Khandyga': 'RU',
+ 'Asia/Kolkata': 'IN',
+ 'Asia/Krasnoyarsk': 'RU',
+ 'Asia/Kuala_Lumpur': 'MY',
+ 'Asia/Kuching': 'MY',
+ 'Asia/Kuwait': 'KW',
+ 'Asia/Macao': 'MO',
+ 'Asia/Macau': 'MO',
+ 'Asia/Magadan': 'RU',
+ 'Asia/Makassar': 'ID',
+ 'Asia/Manila': 'PH',
+ 'Asia/Muscat': 'OM',
+ 'Asia/Nicosia': 'CY',
+ 'Asia/Novokuznetsk': 'RU',
+ 'Asia/Novosibirsk': 'RU',
+ 'Asia/Omsk': 'RU',
+ 'Asia/Oral': 'KZ',
+ 'Asia/Phnom_Penh': 'KH',
+ 'Asia/Pontianak': 'ID',
+ 'Asia/Pyongyang': 'KP',
+ 'Asia/Qatar': 'QA',
+ 'Asia/Qostanay': 'KZ',
+ 'Asia/Qyzylorda': 'KZ',
+ 'Asia/Rangoon': 'MM',
+ 'Asia/Riyadh': 'SA',
+ 'Asia/Saigon': 'VN',
+ 'Asia/Sakhalin': 'RU',
+ 'Asia/Samarkand': 'UZ',
+ 'Asia/Seoul': 'KR',
+ 'Asia/Shanghai': 'CN',
+ 'Asia/Singapore': 'SG',
+ 'Asia/Srednekolymsk': 'RU',
+ 'Asia/Taipei': 'TW',
+ 'Asia/Tashkent': 'UZ',
+ 'Asia/Tbilisi': 'GE',
+ 'Asia/Tehran': 'IR',
+ 'Asia/Tel_Aviv': 'IL',
+ 'Asia/Thimbu': 'BT',
+ 'Asia/Thimphu': 'BT',
+ 'Asia/Tokyo': 'JP',
+ 'Asia/Tomsk': 'RU',
+ 'Asia/Ujung_Pandang': 'ID',
+ 'Asia/Ulaanbaatar': 'MN',
+ 'Asia/Ulan_Bator': 'MN',
+ 'Asia/Urumqi': 'CN',
+ 'Asia/Ust-Nera': 'RU',
+ 'Asia/Vientiane': 'LA',
+ 'Asia/Vladivostok': 'RU',
+ 'Asia/Yakutsk': 'RU',
+ 'Asia/Yangon': 'MM',
+ 'Asia/Yekaterinburg': 'RU',
+ 'Asia/Yerevan': 'AM',
+ 'Atlantic/Azores': 'PT',
+ 'Atlantic/Bermuda': 'BM',
+ 'Atlantic/Canary': 'ES',
+ 'Atlantic/Cape_Verde': 'CV',
+ 'Atlantic/Faeroe': 'FO',
+ 'Atlantic/Faroe': 'FO',
+ 'Atlantic/Jan_Mayen': 'SJ',
+ 'Atlantic/Madeira': 'PT',
+ 'Atlantic/Reykjavik': 'IS',
+ 'Atlantic/South_Georgia': 'GS',
+ 'Atlantic/St_Helena': 'SH',
+ 'Atlantic/Stanley': 'FK',
+ 'Australia/ACT': 'AU',
+ 'Australia/Adelaide': 'AU',
+ 'Australia/Brisbane': 'AU',
+ 'Australia/Broken_Hill': 'AU',
+ 'Australia/Canberra': 'AU',
+ 'Australia/Currie': 'AU',
+ 'Australia/Darwin': 'AU',
+ 'Australia/Eucla': 'AU',
+ 'Australia/Hobart': 'AU',
+ 'Australia/LHI': 'AU',
+ 'Australia/Lindeman': 'AU',
+ 'Australia/Lord_Howe': 'AU',
+ 'Australia/Melbourne': 'AU',
+ 'Australia/NSW': 'AU',
+ 'Australia/North': 'AU',
+ 'Australia/Perth': 'AU',
+ 'Australia/Queensland': 'AU',
+ 'Australia/South': 'AU',
+ 'Australia/Sydney': 'AU',
+ 'Australia/Tasmania': 'AU',
+ 'Australia/Victoria': 'AU',
+ 'Australia/West': 'AU',
+ 'Australia/Yancowinna': 'AU',
+ 'Brazil/Acre': 'BR',
+ 'Brazil/DeNoronha': 'BR',
+ 'Brazil/East': 'BR',
+ 'Brazil/West': 'BR',
+ 'Canada/Atlantic': 'CA',
+ 'Canada/Central': 'CA',
+ 'Canada/Eastern': 'CA',
+ 'Canada/Mountain': 'CA',
+ 'Canada/Newfoundland': 'CA',
+ 'Canada/Pacific': 'CA',
+ 'Canada/Saskatchewan': 'CA',
+ 'Canada/Yukon': 'CA',
+ 'Chile/Continental': 'CL',
+ 'Chile/EasterIsland': 'CL',
+ 'Cuba': 'CU',
+ 'Egypt': 'EG',
+ 'Eire': 'IE',
+ 'Europe/Amsterdam': 'NL',
+ 'Europe/Andorra': 'AD',
+ 'Europe/Astrakhan': 'RU',
+ 'Europe/Athens': 'GR',
+ 'Europe/Belfast': 'GB',
+ 'Europe/Belgrade': 'RS',
+ 'Europe/Berlin': 'DE',
+ 'Europe/Bratislava': 'SK',
+ 'Europe/Brussels': 'BE',
+ 'Europe/Bucharest': 'RO',
+ 'Europe/Budapest': 'HU',
+ 'Europe/Busingen': 'DE',
+ 'Europe/Chisinau': 'MD',
+ 'Europe/Copenhagen': 'DK',
+ 'Europe/Dublin': 'IE',
+ 'Europe/Gibraltar': 'GI',
+ 'Europe/Guernsey': 'GG',
+ 'Europe/Helsinki': 'FI',
+ 'Europe/Isle_of_Man': 'IM',
+ 'Europe/Istanbul': 'TR',
+ 'Europe/Jersey': 'JE',
+ 'Europe/Kaliningrad': 'RU',
+ 'Europe/Kiev': 'UA',
+ 'Europe/Kirov': 'RU',
+ 'Europe/Kyiv': 'UA',
+ 'Europe/Lisbon': 'PT',
+ 'Europe/Ljubljana': 'SI',
+ 'Europe/London': 'GB',
+ 'Europe/Luxembourg': 'LU',
+ 'Europe/Madrid': 'ES',
+ 'Europe/Malta': 'MT',
+ 'Europe/Mariehamn': 'AX',
+ 'Europe/Minsk': 'BY',
+ 'Europe/Monaco': 'MC',
+ 'Europe/Moscow': 'RU',
+ 'Europe/Nicosia': 'CY',
+ 'Europe/Oslo': 'NO',
+ 'Europe/Paris': 'FR',
+ 'Europe/Podgorica': 'ME',
+ 'Europe/Prague': 'CZ',
+ 'Europe/Riga': 'LV',
+ 'Europe/Rome': 'IT',
+ 'Europe/Samara': 'RU',
+ 'Europe/San_Marino': 'SM',
+ 'Europe/Sarajevo': 'BA',
+ 'Europe/Saratov': 'RU',
+ 'Europe/Simferopol': 'UA',
+ 'Europe/Skopje': 'MK',
+ 'Europe/Sofia': 'BG',
+ 'Europe/Stockholm': 'SE',
+ 'Europe/Tallinn': 'EE',
+ 'Europe/Tirane': 'AL',
+ 'Europe/Tiraspol': 'MD',
+ 'Europe/Ulyanovsk': 'RU',
+ 'Europe/Uzhgorod': 'UA',
+ 'Europe/Vaduz': 'LI',
+ 'Europe/Vatican': 'VA',
+ 'Europe/Vienna': 'AT',
+ 'Europe/Vilnius': 'LT',
+ 'Europe/Volgograd': 'RU',
+ 'Europe/Warsaw': 'PL',
+ 'Europe/Zagreb': 'HR',
+ 'Europe/Zaporozhye': 'UA',
+ 'Europe/Zurich': 'CH',
+ 'GB': 'GB',
+ 'GB-Eire': 'GB',
+ 'Hongkong': 'HK',
+ 'Iceland': 'IS',
+ 'Indian/Antananarivo': 'MG',
+ 'Indian/Chagos': 'IO',
+ 'Indian/Christmas': 'CX',
+ 'Indian/Cocos': 'CC',
+ 'Indian/Comoro': 'KM',
+ 'Indian/Kerguelen': 'TF',
+ 'Indian/Mahe': 'SC',
+ 'Indian/Maldives': 'MV',
+ 'Indian/Mauritius': 'MU',
+ 'Indian/Mayotte': 'YT',
+ 'Indian/Reunion': 'RE',
+ 'Iran': 'IR',
+ 'Israel': 'IL',
+ 'Jamaica': 'JM',
+ 'Japan': 'JP',
+ 'Kwajalein': 'MH',
+ 'Libya': 'LY',
+ 'Mexico/BajaNorte': 'MX',
+ 'Mexico/BajaSur': 'MX',
+ 'Mexico/General': 'MX',
+ 'NZ': 'NZ',
+ 'NZ-CHAT': 'NZ',
+ 'Navajo': 'US',
+ 'PRC': 'CN',
+ 'Pacific/Apia': 'WS',
+ 'Pacific/Auckland': 'NZ',
+ 'Pacific/Bougainville': 'PG',
+ 'Pacific/Chatham': 'NZ',
+ 'Pacific/Chuuk': 'FM',
+ 'Pacific/Easter': 'CL',
+ 'Pacific/Efate': 'VU',
+ 'Pacific/Enderbury': 'KI',
+ 'Pacific/Fakaofo': 'TK',
+ 'Pacific/Fiji': 'FJ',
+ 'Pacific/Funafuti': 'TV',
+ 'Pacific/Galapagos': 'EC',
+ 'Pacific/Gambier': 'PF',
+ 'Pacific/Guadalcanal': 'SB',
+ 'Pacific/Guam': 'GU',
+ 'Pacific/Honolulu': 'US',
+ 'Pacific/Johnston': 'UM',
+ 'Pacific/Kanton': 'KI',
+ 'Pacific/Kiritimati': 'KI',
+ 'Pacific/Kosrae': 'FM',
+ 'Pacific/Kwajalein': 'MH',
+ 'Pacific/Majuro': 'MH',
+ 'Pacific/Marquesas': 'PF',
+ 'Pacific/Midway': 'UM',
+ 'Pacific/Nauru': 'NR',
+ 'Pacific/Niue': 'NU',
+ 'Pacific/Norfolk': 'NF',
+ 'Pacific/Noumea': 'NC',
+ 'Pacific/Pago_Pago': 'AS',
+ 'Pacific/Palau': 'PW',
+ 'Pacific/Pitcairn': 'PN',
+ 'Pacific/Pohnpei': 'FM',
+ 'Pacific/Ponape': 'FM',
+ 'Pacific/Port_Moresby': 'PG',
+ 'Pacific/Rarotonga': 'CK',
+ 'Pacific/Saipan': 'MP',
+ 'Pacific/Samoa': 'AS',
+ 'Pacific/Tahiti': 'PF',
+ 'Pacific/Tarawa': 'KI',
+ 'Pacific/Tongatapu': 'TO',
+ 'Pacific/Truk': 'FM',
+ 'Pacific/Wake': 'UM',
+ 'Pacific/Wallis': 'WF',
+ 'Pacific/Yap': 'FM',
+ 'Poland': 'PL',
+ 'Portugal': 'PT',
+ 'ROC': 'TW',
+ 'ROK': 'KR',
+ 'Singapore': 'SG',
+ 'Turkey': 'TR',
+ 'US/Alaska': 'US',
+ 'US/Aleutian': 'US',
+ 'US/Arizona': 'US',
+ 'US/Central': 'US',
+ 'US/East-Indiana': 'US',
+ 'US/Eastern': 'US',
+ 'US/Hawaii': 'US',
+ 'US/Indiana-Starke': 'US',
+ 'US/Michigan': 'US',
+ 'US/Mountain': 'US',
+ 'US/Pacific': 'US',
+ 'US/Samoa': 'AS',
+ 'W-SU': 'RU',
+}
diff --git a/ui/frame_touch.py b/ui/frame_touch.py
index 867f492..8f1be7d 100644
--- a/ui/frame_touch.py
+++ b/ui/frame_touch.py
@@ -25,7 +25,9 @@ Standard library only (ctypes for libei), like the rest of what runs on the Fram
import ctypes
import json
import os
+import re
import select
+import shutil
import subprocess
import sys
import time
@@ -53,14 +55,41 @@ def say(state, **more):
# ---- which panel has focus -----------------------------------------------------
-def xprop_root(display, name):
+PROPS = ("GAMESCOPE_FOCUSABLE_WINDOWS", "GAMESCOPE_FOCUSED_WINDOW", "GAMESCOPE_FOCUS_DISPLAY")
+TOOL_TIMEOUT = 5.0 # seconds for any one xprop or xwininfo
+PANELS_DEADLINE = 12.0 # seconds for a whole `panels` run: well inside the server's 20 s for /api/panels
+DEADLINE = [None] # when the current `panels` run must stop asking X tools (monotonic), if it has one
+
+
+class OutOfTime(Exception):
+ """The current run's deadline passed: stop and report what was found."""
+
+
+def budget(limit=TOOL_TIMEOUT):
+ """Seconds an X tool may take now: its own limit, cut to what's left of the run's deadline."""
+ if DEADLINE[0] is None:
+ return limit
+ left = DEADLINE[0] - time.monotonic()
+ if left <= 0:
+ raise OutOfTime()
+ return min(limit, left)
+
+
+def read_props(display, limit=TOOL_TIMEOUT):
+ """gamescope's root properties on one display, in one xprop call: {name: [values]} for
+ those the display has, {} if none (or xprop didn't answer)."""
try:
- out = subprocess.run(["xprop", "-root", name], env=dict(os.environ, DISPLAY=display),
- capture_output=True, text=True, timeout=5).stdout
+ out = subprocess.run(["xprop", "-root", *PROPS], env=dict(os.environ, DISPLAY=display),
+ capture_output=True, text=True, timeout=budget(limit)).stdout
except (OSError, subprocess.SubprocessError):
- return []
- values = out.split("=", 1)[1] if "=" in out else ""
- return [int(v) for v in values.replace(",", " ").split() if v.isdigit()]
+ return {}
+ props = {}
+ for line in out.splitlines():
+ name, sep, values = line.partition("=")
+ name = name.split("(", 1)[0].strip()
+ if sep and name in PROPS:
+ props[name] = [int(v) for v in values.replace(",", " ").split() if v.isdigit()]
+ return props
def window_info(display, window):
@@ -68,7 +97,7 @@ def window_info(display, window):
try:
which = ["-root"] if window == "root" else ["-id", str(window)]
out = subprocess.run(["xwininfo", *which], env=dict(os.environ, DISPLAY=display),
- capture_output=True, text=True, timeout=5).stdout
+ capture_output=True, text=True, timeout=budget()).stdout
except (OSError, subprocess.SubprocessError):
return None
if "IsViewable" not in out:
@@ -86,13 +115,58 @@ def window_info(display, window):
def displays():
- return sorted(f":{n[1:]}" for n in os.listdir("/tmp/.X11-unix") if n[1:].isdigit())
+ """Every X display on the Frame: the sockets in /tmp/.X11-unix, and the abstract ones
+ X servers on Linux usually listen on as well (in /proc/net/unix), in case a SteamOS
+ build stops sharing /tmp/.X11-unix with SSH sessions."""
+ found = set()
+ try:
+ found.update(int(n[1:]) for n in os.listdir("/tmp/.X11-unix") if n[:1] == "X" and n[1:].isdigit())
+ except OSError:
+ pass
+ try:
+ with open("/proc/net/unix") as f:
+ found.update(int(n) for n in re.findall(r"@/tmp/\.X11-unix/X(\d+)", f.read()))
+ except OSError:
+ pass
+ return [f":{n}" for n in sorted(found)]
+
+
+# The input helper (`frame_touch.py` reading events) lives for a whole Control session and
+# reads focus often, so it remembers which display has gamescope's properties: found, for
+# good; not found, for ROOT_RETRY. A `panels` run is a fresh process each time, so this
+# saves it nothing; it costs nothing either while :0 has the properties.
+_ROOT = [] # [display, when it was given up on, or None if found]
+ROOT_PROBE_TIMEOUT = 2.0 # seconds for each display other than :0, so a stalled one can't use the run
+ROOT_RETRY = 30.0
+
+
+def gamescope():
+ """(display, properties): the X display whose root window carries gamescope's panel
+ properties (GAMESCOPE_FOCUSABLE_WINDOWS and friends), and what they say now.
+
+ That's :0 on every build seen so far (SteamOS 0.3.0 to 0.4.1), so :0 is read first and,
+ when it has them, used with no other probe. Only if it hasn't are the other displays
+ asked, briefly each; with none, :0 and no properties.
+ """
+ if _ROOT and (_ROOT[1] is None or time.monotonic() - _ROOT[1] < ROOT_RETRY):
+ return _ROOT[0], read_props(_ROOT[0])
+ props, found = read_props(":0"), ":0"
+ if not props:
+ found = None
+ for display in displays():
+ if display != ":0":
+ props = read_props(display, ROOT_PROBE_TIMEOUT)
+ if props:
+ found = display
+ break
+ _ROOT[:] = [found, None] if found else [":0", time.monotonic()]
+ return _ROOT[0], props
def window_pid(display, window):
try:
out = subprocess.run(["xprop", "-id", str(window), "_NET_WM_PID"], env=dict(os.environ, DISPLAY=display),
- capture_output=True, text=True, timeout=5).stdout
+ capture_output=True, text=True, timeout=budget()).stdout
except (OSError, subprocess.SubprocessError):
return None
value = out.rsplit("=", 1)[-1].strip() if "=" in out else ""
@@ -120,20 +194,20 @@ def locate(window, pid):
"root": [root.get("width", info["width"]), root.get("height", info["height"])]}
-def focusable():
- """gamescope's focusable windows as (window, app id, pid)."""
- t = xprop_root(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")
+def focusable(props=None):
+ """gamescope's focusable windows as (window, app id, pid), from props if already read."""
+ t = (gamescope()[1] if props is None else props).get("GAMESCOPE_FOCUSABLE_WINDOWS", [])
return [tuple(t[i:i + 3]) for i in range(0, len(t) - 2, 3)]
-def focus_display():
- """The display of the focused window, from GAMESCOPE_FOCUS_DISPLAY on :0's root.
+def focus_display(props=None):
+ """The display of the focused window, from GAMESCOPE_FOCUS_DISPLAY on gamescope's root.
gamescope writes the name (":1") as 32-bit items, so its first four bytes land,
little-endian, in the first value: 12602 is 0x313A, ":1" (steamcompmgr.cpp;
seen 2026-09-29).
"""
- values = xprop_root(":0", "GAMESCOPE_FOCUS_DISPLAY")
+ values = (gamescope()[1] if props is None else props).get("GAMESCOPE_FOCUS_DISPLAY")
if not values:
return None
name = (values[0] & 0xFFFFFFFF).to_bytes(4, "little").split(b"\0", 1)[0].decode("ascii", "replace")
@@ -141,19 +215,23 @@ def focus_display():
def focus_now():
- """Just which window and display have focus: two property reads, for checking a press."""
- window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
- return (window or None, focus_display() if window else None)
+ """Just which window and display have focus: one property read, for checking a press."""
+ props = gamescope()[1]
+ window = (props.get("GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
+ return (window or None, focus_display(props) if window else None)
-def focus():
+def focus(props=None):
"""The panel that has focus in the headset: window, display, name and sizes (gamescope
- publishes the window and its display on :0's root)."""
- window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
+ publishes the window and its display on its root display, :0 so far). props: gamescope's
+ properties if already read."""
+ if props is None:
+ props = gamescope()[1]
+ window = (props.get("GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
if not window:
return {"window": None}
- app, pid = next(((a, p) for w, a, p in focusable() if w == window), (None, None))
- display = focus_display()
+ app, pid = next(((a, p) for w, a, p in focusable(props) if w == window), (None, None))
+ display = focus_display(props)
info = window_info(display, window) if display else None
if info:
root = window_info(display, "root") or {}
@@ -164,18 +242,77 @@ def focus():
return {**panel, "app": app} if panel else {"window": None}
-def panels():
- """Every app panel (gamescope's focusable windows), for watching one that hasn't focus."""
- now = focus()
- found = []
- for window, app, pid in focusable():
- panel = locate(window, pid)
- if panel and panel["width"] > 1 and panel["height"] > 1 and \
- not any(f["window"] == window and f["display"] == panel["display"] for f in found):
- panel.pop("root", None)
- found.append({**panel, "app": app, "focused": (window, panel["display"]) ==
- (now.get("window"), now.get("display"))})
- return {"focus": now.get("window"), "focus_display": now.get("display"), "panels": found}
+def panels(limit=None):
+ """Every app panel (gamescope's focusable windows), for watching one that hasn't focus.
+
+ One deadline (PANELS_DEADLINE) covers the whole run, inside the server's timeout: past
+ it, the panels found so far come back with a "why" saying the headset was too slow.
+ """
+ DEADLINE[0] = time.monotonic() + (PANELS_DEADLINE if limit is None else limit)
+ now, found, unfound, late = {}, [], 0, False
+ try:
+ props = gamescope()[1]
+ now = focus(props)
+ for window, app, pid in focusable(props):
+ panel = locate(window, pid)
+ unfound += panel is None
+ if panel and panel["width"] > 1 and panel["height"] > 1 and \
+ not any(f["window"] == window and f["display"] == panel["display"] for f in found):
+ panel.pop("root", None)
+ found.append({**panel, "app": app, "focused": (window, panel["display"]) ==
+ (now.get("window"), now.get("display"))})
+ late = time.monotonic() >= DEADLINE[0] # the last read may have been cut short
+ except OutOfTime:
+ late = True
+ finally:
+ DEADLINE[0] = None
+ out = {"focus": now.get("window"), "focus_display": now.get("display"), "panels": found}
+ if late:
+ out["why"] = ("The headset took too long to list its panels (its X tools didn't answer in time). "
+ + ("Some may be missing. " if found else "") + "Try again, or use the Headset view.")
+ elif not found:
+ out["why"] = why_none(unfound)
+ return out
+
+
+# The headset's Linux desktop, by process name (docs/how-the-frame-works.md).
+DESKTOP = ("plasmashell", "kwin_wayland", "kwin_wayland_wrapper", "steamos-nested-desktop", "startplasma-wayland")
+
+
+def desktop_running():
+ """Whether the headset's Linux desktop (the nested Plasma session) is running."""
+ try:
+ pids = [p for p in os.listdir("/proc") if p.isdigit()]
+ except OSError:
+ return False
+ for pid in pids:
+ try:
+ with open(f"/proc/{pid}/comm") as f:
+ if f.read().strip() in DESKTOP:
+ return True
+ except OSError:
+ continue
+ return False
+
+
+def why_none(listed):
+ """Why panels() found nothing, in words the person at the computer can act on.
+ listed: how many of the windows gamescope lists aren't X11 windows on any display."""
+ missing = [tool for tool in ("xprop", "xwininfo") if not shutil.which(tool)]
+ if missing:
+ return (f"This SteamOS build has no {' or '.join(missing)}, which Frame Control uses to find the "
+ "headset's panels. Use the Headset view instead, and please report your SteamOS version.")
+ if listed:
+ # gamescope lists windows, but none is an X11 window on any display: a Wayland
+ # window (a desktop drawn straight to gamescope, say) can't be found or captured this way.
+ return (f"The headset has {listed} panel{'s' if listed > 1 else ''} open, but Frame Control can't "
+ f"find {'its' if listed == 1 else 'their'} window. This SteamOS build may draw "
+ f"{'it' if listed == 1 else 'them'} in a way the Desktop view can't capture yet. "
+ "Use the Headset view instead, and please report your SteamOS version.")
+ if desktop_running():
+ return ("The headset's desktop is running, but SteamOS isn't listing it as a panel. Look at it in "
+ "the headset (or close and reopen it), then try again, or use the Headset view.")
+ return "No app panel is open in the headset. Open the desktop or an app in the headset, then try again."
def to_root(panel, fx, fy):
diff --git a/ui/frame_webinstall.py b/ui/frame_webinstall.py
index 2f2e006..972c256 100644
--- a/ui/frame_webinstall.py
+++ b/ui/frame_webinstall.py
@@ -456,7 +456,9 @@ def dispatch(path, name=None, exe=None, progress=None, source=None):
m = frame_android.install(path, source=source or os.path.basename(path))
except frame_android.FrameError as e:
raise WebInstallError(str(e))
- return {"message": f"Installed {m['label']} as its own app in the Steam library", "kind": kind, "result": m}
+ message = f"Installed {m['label']} as its own app in the Steam library"
+ note = frame_android.layer_note(m)
+ return {"message": f"{message}. {note}" if note else message, "kind": kind, "result": m}
try:
import frame_titles
except ImportError as e:
diff --git a/ui/index.html b/ui/index.html
index e45eae6..293be87 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -54,6 +54,10 @@
nav a:hover { color: var(--bright); }
nav a.on { color: var(--blue); border-bottom-color: var(--blue); }
.spacer { flex: 1; }
+ /* The connection pill and the buttons after it, pushed to the right: closer together than the tabs, and the
+ pill (not the buttons) gives way when the window is narrow, so nothing is pushed past the right edge. */
+ .hdr-end { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 1 auto; margin-left: auto; }
+ .hdr-end > :not(.pill, .devsel) { flex: none; }
.chip { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 3px;
background: var(--btn); color: var(--text); font-size: 13px; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
@@ -63,12 +67,13 @@
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; align-items: start; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
+ @media (max-width: 1366px) { header { gap: 20px; } nav a { padding: 0 11px; } }
/* The header gives way as the window narrows: the wordmark goes, then the tabs tighten, then the
connection pill drops its second line. It fits down to the app's 760 px minimum, with room for
the macOS window buttons the app adds on the left. */
- header > button:not(.pill), header > .chip { flex: none; }
- header > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; }
- @media (max-width: 1500px) { header { gap: 22px; } }
+ .hdr-end > button:not(.pill), .hdr-end > .chip { flex: none; }
+ .hdr-end > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; }
+ @media (min-width: 1367px) and (max-width: 1500px) { header { gap: 22px; } }
@media (max-width: 1360px) { .brand b { display: none; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 1030px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; }
@@ -87,7 +92,7 @@
.page { display: flex; flex-direction: column; gap: 26px; }
.page:not(.on) { display: none; }
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
- @media (max-width: 1200px) { nav kbd { display: none; } }
+ @media (max-width: 1366px) { nav kbd { display: none; } } /* the tabs' titles still name the key */
/* ---- offline banner ---- */
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
@@ -129,7 +134,7 @@
/* The dialog scrolls when the steps are open in a short window; its buttons stay in reach. */
#connDlg .rep-actions { position: sticky; bottom: -22px; margin: 16px -22px -22px; padding: 12px 22px;
background: #1e2329; border-top: 1px solid rgba(255,255,255,.06); }
- .devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
+ .devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; min-width: 90px; }
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
.dlg::backdrop { background: rgba(0,0,0,.55); }
@@ -167,7 +172,7 @@
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 8px; font: inherit; font-size: 13px; }
.addr .t { overflow-wrap: anywhere; }
- .addr .s { white-space: normal; }
+ .addr .s, .dev-problem .s { white-space: normal; }
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
.addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } }
@@ -212,7 +217,7 @@
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
.seg button.on { background: var(--btn-hi); color: var(--bright); }
- input[type=text], input[type=search], input[type=url], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
+ input[type=text], input[type=search], input[type=url], input[type=password], input[type=email], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 9px 11px; font: inherit; }
textarea { resize: vertical; min-height: 76px; }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
@@ -330,7 +335,12 @@
.shot-card .row { flex-wrap: nowrap; }
.shot-card .grow { flex: 1; min-width: 0; }
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- .shot-card .s { color: var(--muted); font-size: 12px; }
+ .shot-card .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .ctx-menu { position: fixed; z-index: 1000; min-width: 200px; padding: 4px; border-radius: 4px; background: #232c38;
+ box-shadow: 0 10px 28px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.08); }
+ .ctx-menu button { display: block; width: 100%; height: 30px; padding: 0 10px; text-align: left; background: none; }
+ .ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--blue); color: #fff; outline: none; }
+ .ctx-menu hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 4px 2px; }
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
@@ -368,6 +378,11 @@
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; }
+ .contact-opts { display: flex; gap: 6px 18px; flex-wrap: wrap; margin-top: 8px; }
+ .contact-opts label { display: inline-flex; gap: 7px; align-items: center; cursor: pointer; }
+ .contact-opts input { width: 15px; height: 15px; margin: 0; accent-color: var(--blue); }
+ #contactNotice input[type=email] { width: min(320px, 100%); margin-top: 8px; padding: 7px 10px; }
+ #cEmail { max-width: 420px; }
.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); }
@@ -455,8 +470,9 @@
.cat-card .s { color: var(--muted); font-size: 12px; }
.cat-card .why { color: var(--muted); font-size: 11.5px; line-height: 1.4; }
.cat-card .row { margin-top: auto; }
- .v-works, .v-likely { color: var(--green-hi); } .v-maybe { color: var(--warn); }
- .v-unlikely, .v-no { color: var(--bad); }
+ .v-works, .v-likely, .item .s.v-works, .item .s.v-likely { color: var(--green-hi); }
+ .v-maybe, .item .s.v-maybe { color: var(--warn); }
+ .v-unlikely, .v-no, .item .s.v-unlikely, .item .s.v-no { color: var(--bad); } /* over .item .s's grey */
.vbadge { font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; margin-left: auto; }
@media (max-width: 1100px) { .and-grid { grid-template-columns: 1fr; } }
/* ---- Steam games (owned + store) ---- */
@@ -516,6 +532,12 @@
header .pill { min-width: 104px; }
@media (max-width: 374px) { .brand { display: none; } } /* the smallest phones: the pill needs the room */
header .chip { height: 30px; padding: 0 10px; }
+ /* Let connection text yield before battery and header actions. */
+ .hdr-end { flex: 1; gap: 6px; }
+ .hdr-end .pill, .hdr-end .devsel { min-width: 0; }
+ .hdr-end .pill { padding: 0 8px; gap: 6px; }
+ .hdr-end .pill > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
+ #reportBtn, #refreshAll { width: 38px; padding: 0; justify-content: center; }
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
@@ -542,6 +564,9 @@
.and-btns { padding-left: 0; }
.banner { flex-wrap: wrap; }
.banner .s { white-space: normal; }
+ /* Reserve the message row; connection actions wrap below it. */
+ #offline { align-items: flex-start; }
+ #offline > .grow { flex: 1 0 calc(100% - 36px); overflow-wrap: anywhere; }
button { height: 38px; }
button.small { height: 32px; }
.actions button { height: 46px; }
@@ -713,6 +738,7 @@
@media(prefers-reduced-motion:reduce) { html, #scroll { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
+
@@ -731,20 +757,22 @@
Android3Tools4Devices5
+ Control6
-
+
-
Headset
@@ -771,22 +799,71 @@
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,
+ your operating system, your country (from the computer's time zone setting), which tabs you use, whether installs on the Frame worked, and how many headsets you use
+ (a one-way hash of each headset's machine ID, the same on every computer, so they're counted once). 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.
Privacy settingsShare more to help fix problemsOK
+
Can't reach the Frame
Retrying every few seconds. Everything fills in when it answers.
Details
+ Check the new key…Set Up Connection…Retry now
+
+
+
Your Frame, at a glance
Control Center
Everything you can see, change and configure. Find a setting, check your headset, or pick up where you left off.
+
+
+
+
Steam Frame
Connect your Frame to read live metricsRefresh metrics
+
+
BatteryUnavailableConnect to read charge and power
+
Storage freeUnavailableUser storage unavailable
+
Memory usedUnavailableMemory sensor unavailable
+
Compositor FPSUnavailableCompositor FPS · not game FPS
+
+ System information unavailable
+
+
Wi-Fi network
Unavailable
Wi-Fi signal
Unavailable
+
IP address
Unavailable
Uptime
Unavailable
+
Battery power · signed
Unavailable
Charger
Unavailable
+
Battery temperature
Unavailable
Battery health
Unavailable
+
Battery estimate
Unavailable
+
Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.
+
+
+
+
+
+
+
×
+
+
No matching controls
Try a different word or browse all categories.
Reset search & category
+
+
+
+
@@ -805,6 +882,7 @@
CaptureLiveControl
+ CopySave
@@ -878,7 +956,7 @@
Save new to this computer
Loading…
-
Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
+
Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to ~/Pictures/SteamFrame. Right-click for more.
@@ -1024,6 +1102,61 @@
library under its own name and opens in its own panel. Test launches it and records
whether it stays up; Report records how it worked for you.
+
+
Install queue
+
+
Games from the store, your Quest and your Meta library install one after another.
+ Remove takes a game out before it starts.
+
+
+
Import from my Quest
+ Look for my Quest
+
Plug your Quest into this computer by USB, put it on, then Look for my Quest.
+
+
+
+
+
Copies games you own from your own Quest (developer mode on) to the Frame. A game with
+ OBB data takes two imports: the first installs it; start it from the Steam library, then import again to
+ copy its data in. Frame Control never starts the game itself. Games that
+ use Meta's licence check install with a warning: on the Frame that check can't reach Meta, so they may quit,
+ hang, or run without online features. Frame Control won't remove that check. How it works
+
+
+
My Meta library
Experimental
+ Refresh
+ Disconnect
+
Off. Nothing is sent to Meta until you connect.
+
Experimental. This uses Meta's undocumented internal API with your own Meta
+ access token. It may stop working whenever Meta changes things, and using it may be against Meta's terms; your
+ account is your responsibility. It only lists and downloads games your account owns, installs them as they are,
+ and never removes a licence check.
+
+
+
+ Sign in with Meta…
+ or paste your token:
+ Sign out of Meta in Frame Control
+
+
+ Connect
+
How to get your token.
+ Sign in with Meta (desktop app) opens Meta's login page in its own Frame Control window and takes the token
+ from there; it never reads your browser.
+ Frame Control checks it with Meta, then keeps it in a private file on this computer (never on the Frame, and
+ never in reports). Disconnect deletes it.
+
+
+
+
+
+
Install downloads the game from Meta and installs it like Import from my Quest; a game with OBB
+ data takes a second Install while it is open. Install and test also starts it, adds its data, watches it for
+ about 30 seconds and records whether it ran; Test again does just the test. Results go to the compatibility
+ reports, shared only if you share compatibility results. How it works
+
Android display
LeptonRefresh
@@ -1206,8 +1339,9 @@
Privacy & updates
+ Installs, opens, version, operating system, country (from the time zone setting, to see roughly where it's used), tabs used, and whether installs on the Frame
+ worked (with an error category, never the message). F-Droid package names only. Each headset's SteamOS
+ build, a one-way hash of its machine ID (to count headsets, not just installs), and how many you've set up.
@@ -1215,6 +1349,17 @@
Error messages and where in Frame Control they happened, with your home
folder, user name, addresses and keys removed.
+
Contact email (optional)
+
+
Sent privately to the Frame Control maintainer, never shared or published.
+ Updates are occasional release notices; follow-up questions are mainly about problem reports you send.
Show what's been sent
Report a problemCheck for updates
@@ -1344,9 +1489,12 @@
placeholder="e.g. Installing an APK stops at 'copying to the Frame'">
-
+
+
+
+ Frame Control's version, your OS, the Frame's SteamOS build, and a connection summary in fixed words: how connecting went, the kinds of address tried, your ssh version and whether ~/.ssh/config has the headset's entry. No error text, no custom alias name, and never the addresses themselves.Show exactly what's included