diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f131fe4..b7624f0 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: +# A newer push to the same pull request supersedes a run still in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: checks: runs-on: ubuntu-latest @@ -17,7 +22,9 @@ jobs: with: node-version: "24" - name: Install zsh - run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh + # The runner image's third-party apt sources (packages.microsoft.com) sometimes + # fail to refresh; Ubuntu's own lists still update, and the install fails if not. + run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh - name: Script syntax run: | sh -n ui/local-bin/ssh @@ -84,6 +91,8 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install zsh - run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh + # The runner image's third-party apt sources (packages.microsoft.com) sometimes + # fail to refresh; Ubuntu's own lists still update, and the install fails if not. + run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh - name: End-to-end tests run: scripts/e2e.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29cfd85..8ed1e88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,14 +2,22 @@ name: release # Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches # the installers to that tag's GitHub release (created as a draft if missing). -# Pull requests that touch the app build the same installers as artifacts. +# Pull requests that change how the installers are built (the Electron app, the +# macOS helper it compiles, or this workflow) build the same installers as +# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and +# are tested by the checks workflow; run this one by hand to package them early. on: push: tags: ["v*"] pull_request: - paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"] + paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"] workflow_dispatch: +# A newer push to the same pull request supersedes a build still in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: write @@ -94,7 +102,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: | gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber + # Tagged builds are attached to the release above; only other builds need artifacts. - uses: actions/upload-artifact@v4 + if: ${{ !startsWith(github.ref, 'refs/tags/') }} with: name: frame-control-${{ matrix.os }} path: | diff --git a/README.md b/README.md index fb40d32..858561e 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 | @@ -222,7 +224,7 @@ Frame's software fits together, all checked against a real headset and labelled | [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing | | [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset | | [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel | -| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test | +| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM | | [Open questions](docs/open-questions.md) | What's still unchecked |
diff --git a/app/main.js b/app/main.js index 7d77675..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, nativeImage, 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); @@ -347,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 dbd09df..8467e4a 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "frame-control", - "version": "0.4.4", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frame-control", - "version": "0.4.4", + "version": "0.4.7", "license": "MIT", "devDependencies": { "electron": "^44.4.5", diff --git a/app/package.json b/app/package.json index 25d5f41..93e695c 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "frame-control", "productName": "Frame Control", - "version": "0.4.4", + "version": "0.4.7", "description": "Desktop app for managing a Valve Steam Frame over SSH", "private": true, "main": "main.js", @@ -39,6 +39,7 @@ "preload.js", "install-link.js", "updater.js", + "meta-signin.js", "package.json", "build/icon.png" ], @@ -50,6 +51,7 @@ "*.py", "*.html", "*.js", + "*.css", "telemetry.json" ] }, diff --git a/app/preload.js b/app/preload.js index 6dcec17..740d94b 100644 --- a/app/preload.js +++ b/app/preload.js @@ -6,7 +6,8 @@ // 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", { @@ -39,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/docs/apk-repos.md b/docs/apk-repos.md index f4d67b0..185692a 100644 --- a/docs/apk-repos.md +++ b/docs/apk-repos.md @@ -58,8 +58,61 @@ form shows the pinned one ("Trusted on first use: …") so you can compare it. Re-adding an existing URL preserves its pin; changing it requires deliberately removing and re-adding it. +### Repositories behind a login + +Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own +client, the login can be part of the address +(`https://user:pass@example.org/fdroid/repo`), or given separately: in the +store's **Add a source** form under "This repository needs a login", in the +server API (`username` and `password` beside `url`), or on the command line, +where the password is read from standard input so it stays out of shell history +and `ps`: + +```sh +printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin +``` + +How the login is kept and used: + +- **Kept out of the address.** The `user:pass@` part is removed before anything + is saved, so the saved repository URL, its id, the source list, job results + and the page never contain it. The store only says "Uses a login". +- **Stored separately.** Logins live in + `frame_host.data_dir('source-credentials.json')` + (`~/Library/Application Support/Frame Control/source-credentials.json` on + macOS), written with mode 0600 and keyed by source id. They are stored in + plain text there, protected by file permissions only (on Windows, + the folder's normal ACLs). Removing the repository deletes its login, and + the file goes when the last login is removed. Each change holds a lock + file (`source-credentials.lock`), so the app and the command line can't undo + each other's saves or removals. +- **HTTPS only.** An `http://` address with a login is refused before any request. +- **Inside the repository only.** The `Authorization` header is decided + separately for every request, redirects included: index, `entry.jar`, + APKs and the store's artwork proxy. It is sent only when the request's + scheme, host and port match the repository's and its path is inside the + repository's directory by whole path segments. A redirect to another host + or port, or to another path on the same server, gets no login, and that + request's own `Authorization` header is removed. Paths with `.` or `..` + segments (plain or percent-encoded), encoded `/` or `\`, double encoding + or `;` get no login, since a server could normalise them to a path + outside the repository. Index file names like that are refused outright. +- **Never echoed.** Logins are not written to logs, error messages, API + responses or the cache. The CLI prints only the saved source. +- **Checked before saving.** As with any repository, the whole index must + download and verify before anything is saved. HTTP 401 (or 403 when a login + was sent) stops the add with "check the username and password" and saves + nothing: no repository, no login, no rollback state. A 403 with a login is + not treated as rate limiting. +- **Same verification.** The login changes only who may download. Signature, + fingerprint pin or TOFU, index hash and rollback checks apply exactly as for + any other repository. + +Re-adding the same address with a new login replaces the saved one. Re-adding +it without a login keeps the saved one. + The API for the search/server integration is in `ui/apk_sources/fdroid.py`: -`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`, +`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`, `set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes `sources`, `search`, `details`, and `download` from the shared source contract. This change supplies the CLI and API; the integrated source-management UI is diff --git a/docs/apk-sources.md b/docs/apk-sources.md index 0611e98..71d51f8 100644 --- a/docs/apk-sources.md +++ b/docs/apk-sources.md @@ -45,10 +45,23 @@ search. Results outside the curated list stay page-only, even if a repository claims an open-source license. This prevents an arbitrary tagged mirror from becoming a trusted downloader. Extend the curated JSON after provenance review. -Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API -quota. Tokens are sent only to `api.github.com`, never written to the cache, -never sent to asset hosts, and removed on redirects. The adapter does not -read `gh` credentials automatically. Metadata is cached for one hour under +For a higher API quota, add a GitHub access token in the store's source +settings (the GitHub row's token field) or through the server API +(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and +`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still +works and wins over a saved token. A saved token is kept with repository +logins in `source-credentials.json` (mode 0600, see +[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list +reports only whether a token is set, where it came from and whether GitHub +rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to +`api.github.com`, never written to the cache, never sent to asset hosts (public +release downloads don't need them), and removed on redirects. If GitHub answers +401 to the token, the request is retried once without it, as Obtainium does. +The rejection is logged without the token and shown in source settings. The +token is not tried again until it is saved again or the app restarts. Rate-limit +backoff is kept per login (anonymous, or each token), so saving a token after +anonymous requests were limited lets the next request go out with it. The +adapter does not read `gh` credentials automatically. Metadata is cached for one hour under `frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request fetches at most ten releases. Rate-limit errors are surfaced without retry loops. Asset IDs and release tags are not Android version codes: metadata 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/frame-control.md b/docs/frame-control.md index d60af80..6d0d500 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -17,11 +17,12 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810 ## Features -The window has five tabs: **Home** (headset view, status, screenshots), +The window has six tabs: **Home** (headset view, status, screenshots), **Games** (installed games, sideloaded titles, getting games), **Android** (apps, the catalogue, display settings, reports), **Tools** (sending files and text, -Flatpaks, remote and power) and **Devices** (your headsets and their addresses). -Keys 1–5 switch between them. Files can be dropped anywhere in the window. A +Flatpaks, remote and 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. Files can be dropped anywhere in the window. A connection pill in the header always shows which headset, which network this computer is on, the address in use or being tried, and each step of connecting as it happens; click it for the whole timeline. When the Frame can't be @@ -96,6 +97,61 @@ counts them while they run. 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 `app/` is an Electron shell. It starts `ui/server.py` on a free loopback port 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 3412850..2531979 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -33,15 +33,28 @@ 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` (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` | @@ -51,12 +64,15 @@ 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`, `frame_not_set_up`, `frame_auth`, -`frame_unreachable`, `frame_disk_full`, `download_failed`, `flatpak`, -`cancelled`, `lepton` 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: +`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 @@ -74,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. 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/streaming.md b/docs/streaming.md index 05a95c6..821c4eb 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -21,6 +21,20 @@ The confidence labels are the same as in [ssh.md](ssh.md). | **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. | | VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. | +**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop +(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so +Remote Desktop never asks for a user or password. It warns that the certificate +(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to +frame" box with the username blank. Any user but `steamos` gets "User does not +exist, or could not be authenticated". Signing in as `steamos` with the +Developer Mode password opens a Plasma (X11) desktop within about 6 seconds. +It's a new session on display `:10`, separate from what the headset shows, +and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves +it running, and the next sign-in reconnects to it. To end it, find its +session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`) +and run `loginctl terminate-session `; the headset's own session keeps +running. + **Recommendation for A:** start with Steam Link for macOS, because Valve documents it. Use Windows App (RDP) when you want a proper Linux desktop on the Mac with keyboard, mouse, and clipboard. diff --git a/docs/testing.md b/docs/testing.md index 82a1826..e45a762 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,6 +8,7 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste | Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked | | Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame | | Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID | +| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed | ## Unit tests @@ -15,6 +16,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`. @@ -163,6 +187,86 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had accepted. The fake now refuses them the same way, and Frame Control makes ids Steam accepts. +## Windows test VM + +The unit tests run on Windows in CI, but the app itself doesn't. Anything that +depends on the Windows desktop (Remote Desktop, the installer, the bundled +Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a +[dockur/windows](https://github.com/dockur/windows) container on a Linux machine +with KVM, driven from the Mac with `scripts/windows-vm.sh`. + +Setting it up, once, on the Linux machine: + +- **Windows comes from Microsoft.** The container downloads the Windows 11 + image from Microsoft on first start. Windows runs unactivated, which is fine + for testing. Don't use activation workarounds or third-party Windows images. +- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH), + `127.0.0.1:8006:8006` (the web console) and, if you need it, + `127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use + `restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is + testing, and a `docker stop` shuts Windows down cleanly. +- **Give it SSH on first sign-in.** The container runs `/oem/install.bat` + once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell + as its default shell, and put a dedicated public key (for example + `~/.ssh/id_ed25519_winvm` on the Mac) in + `C:\ProgramData\ssh\administrators_authorized_keys`. +- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app + and the tests. + +Using it, with `WINVM_HOST` set to the Linux machine's ssh alias: + +```sh +scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes) +scripts/windows-vm.sh put Frame-Control-Setup-x64.exe +scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait' +scripts/windows-vm.sh shot screen.png # what's on its screen +scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot +scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names +scripts/windows-vm.sh down # shut Windows down +``` + +- **Clicks go through a scheduled task.** Commands over SSH run in a + session with no desktop, so `click` and `scroll` write the position to a + file, and a scheduled task running as the signed-in user replays it. The + VM's screen must be signed in; it is after `up`. Clicks and scrolls sent at + the same time run one after another. QEMU's own `mouse_move` is relative + and drifts, so the script doesn't use it. +- **Screenshots may not show the pointer.** Check the result of a click (a + menu that opens, a button that changes) rather than the pointer's position. +- **Windows' `ssh` waits for stdin.** The script closes it for every command. + Do the same if you run `ssh` in the VM by hand. +- **Starting the app.** Run Frame Control in the signed-in session, not over + SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the + one `click` uses. + +**Testing against a real Frame.** The VM reaches the headset on the LAN like +any other computer. Run **Set Up Connection** in the VM's Frame Control once; +that makes the VM's keys. So the VM doesn't keep access between test runs, +afterwards take the lines it added out of the headset's +`~/.ssh/authorized_keys`: the ones matching the VM's +`~/.ssh/id_ed25519_frame.pub` and, if pairing made one, +`~/.ssh/id_rsa_frame_devkit.pub`. Check that `ssh -o BatchMode=yes frame true` +in the VM now fails. Then, around each run: + +```sh +scripts/windows-vm.sh frame-key add # let the VM's key into the headset +# ... test ... +scripts/windows-vm.sh frame-key remove # and take it out again +``` + +`add` appends the VM's key tagged `windows-vm-test`, unless the headset +already trusts that key through another line. `remove` deletes only the exact +line `add` wrote, so it doesn't undo what Set Up Connection did, and it leaves +the file alone if it can't rewrite it. Follow the shared-device +procedure in [Headset smoke test](#headset-smoke-test) before installing or +launching anything on the headset. + +**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on +BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0, +connect it to the Frame and follow Remote Desktop through to the Frame's +desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)). +`frame-key` left `authorized_keys` byte-for-byte as it was. + ## Owned media player `tests/test_media.py` covers layout evidence and overrides, OU eye ordering, diff --git a/docs/vr-apks.md b/docs/vr-apks.md index e88613c..08b8bb8 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 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/e2e.sh b/scripts/e2e.sh index c8d4ef0..75ad349 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -23,7 +23,8 @@ started=$SECONDS print "==> Building fakeframe-frame (from $base) and fakeframe-host" # Quiet when it works; if a build fails, build again with the full log so CI shows why. -build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } } +# That second build also rides out a flaky package mirror: only its failure is fatal. +build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 } build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe 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/scripts/windows-vm.sh b/scripts/windows-vm.sh new file mode 100755 index 0000000..ff6db1c --- /dev/null +++ b/scripts/windows-vm.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env zsh +# Mac or Linux: drive a Windows 11 test VM for Frame Control's Windows build. +# The VM is a dockur/windows container on another machine; this reaches it over +# SSH through that machine. See docs/testing.md#windows-test-vm. +# +# Usage: scripts/windows-vm.sh [args] +# up | down | status start it (waits for SSH), shut Windows down cleanly, show state +# ps '' run PowerShell as the VM's user +# put [] copy a file in (default: the user's Downloads) +# shot save the VM's screen +# click click screen pixel x,y in the signed-in session +# scroll turn the wheel n notches at x,y (negative scrolls down) +# keys ... type QEMU key names: a, shift-a, ret, tab, esc, spc ... +# frame-key add|remove let the VM's Frame Control key into the headset (`frame` +# alias) for a test run, then take it out again +# +# Set WINVM_HOST to the ssh alias of the machine running the container. Optional: +# WINVM_CONTAINER (frame-winvm), WINVM_USER (frame), WINVM_PORT (2222: the VM's +# sshd, published on that machine's loopback), WINVM_KEY (~/.ssh/id_ed25519_winvm). +set -euo pipefail +setopt extendedglob + +host=${WINVM_HOST:?set WINVM_HOST to the ssh alias of the machine running the VM} +ctr=${WINVM_CONTAINER:-frame-winvm} +user=${WINVM_USER:-frame} +port=${WINVM_PORT:-2222} +key=${WINVM_KEY:-$HOME/.ssh/id_ed25519_winvm} +opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new + -o UserKnownHostsFile=${TMPDIR:-/tmp}/windows-vm-known_hosts -i $key -J $host) +TAG=windows-vm-test # comment on the VM's key in the headset's authorized_keys + +die() { print -u2 "windows-vm: $*"; exit 1 } +int() { [[ $1 == (-|)<-99999> ]] || die "not a whole number (up to 99999): $1" } +# These go into commands run by a shell on the other machine, so keep them plain. +for v in $host $ctr $user; do [[ $v == [A-Za-z0-9_.]##[A-Za-z0-9_.-]# ]] || die "not a plain name: $v"; done +[[ $port == <1-65535> ]] || die "WINVM_PORT isn't a port: $port" + +# Windows' OpenSSH waits for stdin to close, so it always gets /dev/null. The +# script travels UTF-16 base64-encoded, so no quoting survives two shells. +vm_ps() { + local b64=$(print -rn -- "\$ProgressPreference = 'SilentlyContinue'"$'\n'"$1" | + iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n') + ssh $opts -p $port $user@127.0.0.1 "powershell -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand $b64" /dev/null } + +# Input has to come from the signed-in desktop session, not SSH's session 0, so +# a scheduled task running as the user replays one click or wheel turn written +# to input.txt, then deletes the file to say it's done. Any error stops it +# before that, so the caller times out instead of reporting a click. +INPUT_PS1='$ErrorActionPreference = "Stop" +$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+" +Add-Type -Namespace WinVm -Name Input -MemberDefinition @" +[DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); +[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); +[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.IntPtr extra); +"@ +[WinVm.Input]::SetProcessDPIAware() | Out-Null +if (-not [WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1])) { throw "SetCursorPos failed" } +Start-Sleep -Milliseconds 150 +if ($a[2] -eq "click") { + [WinVm.Input]::mouse_event(0x2, 0, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60 + [WinVm.Input]::mouse_event(0x4, 0, 0, 0, [IntPtr]::Zero) +} else { [WinVm.Input]::mouse_event(0x800, 0, 0, 120 * [int]$a[3], [IntPtr]::Zero) } +Remove-Item "$PSScriptRoot\input.txt"' + +pointer() { # x y click|wheel [notches] + local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n') + vm_ps '$ErrorActionPreference = "Stop" +$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $mine = $false +# Giving up: end the helper first, or it could wake up and act in a later request. +function Abandon { + Stop-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue + foreach ($i in 1..25) { + if ((Get-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue).State -ne "Running") { break } + Start-Sleep -Milliseconds 200 + } + Remove-Item $req -ErrorAction SilentlyContinue +} +trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Abandon }; exit 1 } +# One request at a time: the helper, the task and input.txt are shared. Windows +# frees the mutex when this process ends, however it ends. +$lock = New-Object Threading.Mutex($false, "windows-vm-input") +try { $mine = $lock.WaitOne(15000) } catch [Threading.AbandonedMutexException] { $mine = $true } +if (-not $mine) { [Console]::Error.WriteLine("windows-vm: another click or scroll is still in progress"); exit 1 } +New-Item -ItemType Directory -Force $d | Out-Null +[IO.File]::WriteAllText($req, "'"$*"'") +[IO.File]::WriteAllText("$d\input.ps1", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'$b64'"))) +$act = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$d\input.ps1`"" +$who = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive +Register-ScheduledTask -TaskName WindowsVmInput -Action $act -Principal $who -Force | Out-Null +Start-ScheduledTask -TaskName WindowsVmInput +foreach ($i in 1..50) { if (-not (Test-Path $req)) { exit 0 }; Start-Sleep -Milliseconds 200 } +Abandon +[Console]::Error.WriteLine("windows-vm: no input after 10 s: is $env:USERNAME signed in on the VM screen, and is x,y on it?"); exit 1' +} + +(( $# )) || die "usage: see the top of $0" +cmd=$1; shift +case $cmd in + up) + ssh $host "docker start $ctr" >/dev/null + for i in {1..60}; do + vm_ps 'exit 0' 2>/dev/null && { print "up"; exit 0 } + sleep 5 + done + die "Windows didn't answer on SSH within 5 minutes" ;; + down) # the container turns SIGTERM into an ACPI shutdown and waits for Windows + ssh $host "docker stop -t 150 $ctr" >/dev/null && print "down" ;; + status) ssh $host "docker ps -a --filter 'name=^$ctr\$' --format '{{.Names}}: {{.Status}}'" ;; + ps) (( $# == 1 )) || die "usage: ps ''"; vm_ps "$1" ;; + put) + [[ -f ${1:-} ]] || die "usage: put []" + scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" " + print "screendump /tmp/windows-vm-shot.ppm" | monitor + sleep 1 + ssh $host "docker exec $ctr sh -c 'cat /tmp/windows-vm-shot.ppm && rm /tmp/windows-vm-shot.ppm'" | python3 -c ' +import re, struct, sys, zlib +d = sys.stdin.buffer.read() +m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+255\s", d) or sys.exit("windows-vm: no screen dump") +w, h = int(m[1]), int(m[2]); px = d[m.end():] +raw = b"".join(b"\0" + px[y * w * 3:(y + 1) * w * 3] for y in range(h)) +chunk = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b)) +open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) +' $1 + print $1 ;; + click) (( $# == 2 )) || die "usage: click "; int $1; int $2; pointer $1 $2 click ;; + scroll) (( $# == 3 )) || die "usage: scroll "; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;; + keys) + (( $# )) || die "usage: keys ..." + for k; do [[ $k == [a-z0-9_.,/=-]## ]] || die "not a QEMU key name: $k"; done + for k; do print "sendkey $k"; done | monitor ;; + frame-key) + [[ ${1:-} == (add|remove) ]] || die "usage: frame-key add|remove" + pub=(${=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r')}) + [[ ${pub[1]:-} == ssh-ed25519 && ${pub[2]:-} == [A-Za-z0-9+/=]## ]] || + die "the VM has no Frame Control key yet: run Set Up Connection in the app first" + # $1: add|remove, $2: the key's base64, $3: the exact line this script owns. + ssh frame "sh -s -- $1 '$pub[2]' '$pub[1] $pub[2] $TAG'" <<'EOF' +f=~/.ssh/authorized_keys +if [ "$1" = add ]; then + if grep -qxF "$3" "$f"; then exit 0; fi + if grep -qF "$2" "$f"; then echo "the headset already trusts this key through another entry; left as it is"; exit 0; fi + [ -z "$(tail -c 1 "$f")" ] || echo >> "$f" # a last line without a newline would swallow ours + echo "$3" >> "$f" +else + t=$(mktemp "$f.XXXXXX") || exit 1 + grep -vxF "$3" "$f" > "$t" # 0: lines left, 1: none left, more: couldn't read or write + if [ $? -gt 1 ] || ! chmod 600 "$t" || ! mv "$t" "$f"; then + rm -f "$t"; echo "couldn't rewrite $f; it's unchanged" >&2; exit 1 + fi +fi +EOF + print "frame-key $1: done" ;; + *) die "unknown command: $cmd (see the top of $0)" ;; +esac diff --git a/tests/control_center.cjs b/tests/control_center.cjs new file mode 100644 index 0000000..f516741 --- /dev/null +++ b/tests/control_center.cjs @@ -0,0 +1,471 @@ +// Real Control Center logic with a minimal DOM; no network, browser or headset. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const cc = require('../ui/control-center.js'); + +const sample = () => ({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(){},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'].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,scrollIntoView(){scrolled=id;}}); + return sections.get(id); + },live:false,toggleLive(){},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)+'\nPAGES.push("devices", "control");',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:'tools',privacy:'tools',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'); +}); 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/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/source_login.mjs b/tests/page/source_login.mjs new file mode 100644 index 0000000..de8ab47 --- /dev/null +++ b/tests/page/source_login.mjs @@ -0,0 +1,79 @@ +// The store's "Add a source" login handling and GitHub token field, run in node against +// the real functions from ui/index.html. A login typed into the address must leave the +// address field (into the login fields) before anything is sent, and stay out of it. +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 { moveLogin, sourceToken } = new Function( + "const esc = s => String(s).replace(/[&<>\"']/g, c => `&#${c.charCodeAt(0)};`); const storeName = s => s;\n" + + grab("decodeLenient") + grab("moveLogin") + grab("sourceToken") + "return { moveLogin, sourceToken };")(); + +function form(url, username = "", password = "") { + const details = { open: false }; + const field = value => ({ value, closest: () => details }); + return { details, elements: { url: field(url), username: field(username), password: field(password) } }; +} + +let f = form("https://alice:s3cr%40t@repo.example/fdroid/repo"); +moveLogin(f); +if (f.elements.url.value !== "https://repo.example/fdroid/repo") fail("address still holds the login: " + f.elements.url.value); +if (f.elements.username.value !== "alice" || f.elements.password.value !== "s3cr@t") fail("login not moved into its fields"); +if (!f.details.open) fail("login fields not shown"); + +f = form("https://alice:fromurl@repo.example/repo", "typed", "typedpw"); +moveLogin(f); +if (f.elements.url.value.includes("fromurl") || f.elements.username.value !== "typed" || f.elements.password.value !== "typedpw") + fail("typed login replaced, or address kept the login"); + +// Undecodable logins and unusual spellings the URL parser accepts still lose their login; undecodable bytes stay as typed. +for (const [url, address, user, pass] of [ + ["https://alice:100%done@repo.example/private/repo", "https://repo.example/private/repo", "alice", "100%done"], + ["https:\t//alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"], + ["ht\ntps://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"], + ["https:alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"], + ["\u0000https://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"], + ["https://alice:%FF@repo.example/private/repo", "https://repo.example/private/repo", "alice", "%FF"], + ["https://al%20ice:p%40ss:word@repo.example/r", "https://repo.example/r", "al ice", "p@ss:word"], + ["https://alice:p%FF\u00e4@repo.example/repo", "https://repo.example/repo", "alice", "p%FF\u00e4"], + ["fdroidrepos://alice:pw@repo.example/repo?fingerprint=AB", "fdroidrepos://repo.example/repo?fingerprint=AB", "alice", "pw"], + ["https://alice@repo.example/repo", "https://repo.example/repo", "alice", ""], +]) { + f = form(url); + moveLogin(f); + if (f.elements.url.value !== address) fail(`address kept the login: ${url} -> ${f.elements.url.value}`); + if (f.elements.username.value !== user || f.elements.password.value !== pass) + fail(`login for ${url}: ${f.elements.username.value} / ${f.elements.password.value}`); +} + +// An address the parser rejects that may hold a login is refused and left as typed, never sent. +for (const url of ["https://alice\\name:pw@repo.example/repo", "https:\\\\alice:s3cret@repo.example/repo", "fdroidrepos://alice:p\\w@repo.example/repo", "https://alice:s3cret@repo example/repo", "https:alice:s3cret@repo example/repo", "https://repo.example:bad\\a@b"]) { + f = form(url); + if (moveLogin(f) !== false) fail("accepted an invalid address with a login: " + url); + if (f.elements.username.value || f.elements.password.value) fail("guessed a login from an invalid address: " + url); +} + +for (const url of ["https://repo.example/repo", "not a url", "https://repo.example/a@b", "fdroidrepos:///a@b", "fdroidrepos:/a@b", "https://repo.example/r?x=a@b#c@d"]) { + f = form(url); + moveLogin(f); + if (f.elements.url.value !== url || f.elements.username.value) fail("changed an address without a login: " + url); +} + +const html = sourceToken({ id: "github", name: "GitHub releases", token: { set: true, from: "saved", rejected: true } }); +if (!html.includes("rejected") || !html.includes('data-clear-token="github"')) fail("token row: " + html); +if (sourceToken({ id: "fdroid", name: "F-Droid" }) !== "") fail("token field on a source without one"); +const slow = "https://a:" + "%FF".repeat(4000) + "@repo.example/r", t0 = Date.now(); +f = form(slow); moveLogin(f); +if (Date.now() - t0 > 200 || f.elements.password.value !== "%FF".repeat(4000)) fail("decoding malformed escapes is slow or wrong"); +console.log("ok"); diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py index 6b4e3ed..29f8511 100644 --- a/tests/test_apk_search.py +++ b/tests/test_apk_search.py @@ -325,7 +325,7 @@ class EndpointTests(SettingsTest): mod.add_repo.side_effect = SourceError('repository fingerprint mismatch') job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job']) self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix - for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'): + for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://repo.example:x/'): self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400) self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200) mod.remove_repo.assert_called_once_with(source_id='fdroid') diff --git a/tests/test_comfort.py b/tests/test_comfort.py index 43a456f..d2e0e93 100644 --- a/tests/test_comfort.py +++ b/tests/test_comfort.py @@ -256,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 ef008d6..1866540 100644 --- a/tests/test_comfort_ui.py +++ b/tests/test_comfort_ui.py @@ -59,3 +59,32 @@ const log = ()=>{}, toast = ()=>{}; ''' 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_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_fdroid_sources.py b/tests/test_fdroid_sources.py index 6ec0b2b..3f18e63 100644 --- a/tests/test_fdroid_sources.py +++ b/tests/test_fdroid_sources.py @@ -81,7 +81,7 @@ class Repositories(unittest.TestCase): state.clear() self.addCleanup(state.clear) - def fetch(self, url, path, maximum): + def fetch(self, url, path, maximum, auth=None): name = url.rsplit('/', 1)[-1] if self.v1 and name == 'entry.jar': raise urllib.error.HTTPError(url, 404, 'missing', None, None) @@ -311,7 +311,7 @@ class Repositories(unittest.TestCase): source = self.add() other = dict(source, id='other-repo') started, release = threading.Event(), threading.Event() - def fetch(url, path, maximum): + def fetch(url, path, maximum, auth=None): if threading.current_thread().name == 'slow': started.set() release.wait(5) @@ -355,7 +355,7 @@ class Repositories(unittest.TestCase): self.files['entry.jar'], _ = entry_jar(50) source = fdroid.add_repo(URL) jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]} - def fetch(url, path, maximum): + def fetch(url, path, maximum, auth=None): name = threading.current_thread().name if name in jars and url.endswith('entry.jar'): Path(path).write_bytes(jars[name]) @@ -401,7 +401,7 @@ class Repositories(unittest.TestCase): source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None) self.v1 = True inner = [] - def fetch(url, path, maximum): + def fetch(url, path, maximum, auth=None): if url.endswith('index-v1.jar') and not inner: inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now self.v1 = False @@ -471,7 +471,7 @@ class Repositories(unittest.TestCase): source = self.add() self.expire(source) release = threading.Event() - def slow(url, path, maximum): + def slow(url, path, maximum, auth=None): release.wait(5) self.fetch(url, path, maximum) self.fetch_mock.side_effect = slow @@ -528,7 +528,7 @@ class Repositories(unittest.TestCase): self.expire(source) before = self.fetch_mock.call_count release = __import__('threading').Event() - def slow(url, path, maximum): + def slow(url, path, maximum, auth=None): release.wait(5) self.fetch(url, path, maximum) self.fetch_mock.side_effect = slow diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index 08cd4eb..ed0be0d 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -121,7 +121,27 @@ class LauncherTests(unittest.TestCase): FIXTURES = ROOT / 'tests/fixtures/library' +def wait_for_idle_resolvers(timeout=10): + # Name lookups that outlast their deadline give their slot back from their own thread, + # which a busy runner may not schedule before the next test; wait until all are back. + from apk_sources import _images + held = [] + try: + end = time.monotonic() + timeout + while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())): + held.append(1) + if len(held) == 4: + return + raise AssertionError('artwork name lookups from an earlier test are still running') + finally: + for _ in held: + _images._resolvers.release() + + class ArtworkTests(unittest.TestCase): + def setUp(self): + wait_for_idle_resolvers() + def test_source_inputs_and_url(self): from apk_sources import _images data = (FIXTURES / 'icon.png').read_bytes() @@ -232,10 +252,7 @@ class ArtworkTests(unittest.TestCase): self.assertEqual(sum('Too many' in e for e in errors), 2) finally: gate.set() - deadline = time.monotonic() + 5 - while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False): - time.sleep(0.01) - _images._resolvers.release() # the stuck lookups finished and gave their slots back + wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back def test_resolver_slot_released_when_thread_cannot_start(self): from apk_sources import _images @@ -243,10 +260,14 @@ class ArtworkTests(unittest.TestCase): for _ in range(6): with self.assertRaises(RuntimeError): _images.get('https://example.org/a.png', deadline=time.monotonic() + 1) - for _ in range(4): # every slot came back - self.assertTrue(_images._resolvers.acquire(blocking=False)) - for _ in range(4): - _images._resolvers.release() + held = 0 + try: + while held < 4 and _images._resolvers.acquire(blocking=False): + held += 1 + self.assertEqual(held, 4) # every slot came back + finally: + for _ in range(held): # even on failure, so later tests don't inherit the leak + _images._resolvers.release() def test_deadline_covers_name_resolution(self): import threading @@ -423,6 +444,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'), \ @@ -486,6 +520,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 850d118..01d29c2 100644 --- a/tests/test_frame_apk.py +++ b/tests/test_frame_apk.py @@ -93,6 +93,20 @@ class ApkInfo(unittest.TestCase): frame_apk.MAX_MANIFEST = limit self.assertLess(peak, 8 * 1024**2) + def test_damaged_download_is_an_apk_error(self): + # Corrupt deflate data used to escape as zlib.error, which the upload path didn't catch + # (a 500, and no install report) and frame_android.install reported as 'other'. + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z: + z.writestr('AndroidManifest.xml', manifest('com.example.bad', 0x7f010000, 0x7f010001, 21) * 20) + data = bytearray(buf.getvalue()) + at = data.index(b'PK\x03\x04') + start = at + 30 + sum(struct.unpack_from(' { + 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_leads_with_the_reason(self): + # The header pill cut off "Offline: checking the headset's identity failed · retry in…": + # the reason comes first, in a few words, and only the countdown gets cut. + self.run_page(r''' + link.s = changed; + renderPill(); + assert.match($('conn').innerHTML, /Office Frame<\/b>SSH identity changed · retry in 12 s<\/span>/); + assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.'); + // Without a short reason: the stage that failed. + link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}}; + renderPill(); + assert.match($('conn').innerHTML, /Checking the headset's identity failed<\/span>/); +''') + + 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'