diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7bd5b13..539b850 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..5483f4d 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and This is a first public test, so reports are really useful, especially from Windows and Linux. The quickest way is **Report a problem** in the app (the -warning-sign button at the top, or **Help → Report a Problem…**). It adds +speech-bubble button at the top, or **Help → Report a Problem…**). It adds diagnostics with personal details removed, shows you exactly what's included, and sends it privately to the maintainer; nothing is published. Without the app, use the [feedback form](https://frame-control.pages.dev/feedback/). Please include: @@ -213,16 +213,18 @@ 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 | | [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input | -| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet | +| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers | | [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes | | [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 aaecdb5..ed53dd9 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"); @@ -12,6 +12,7 @@ const path = require("path"); const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link"); const updater = require("./updater"); const { preferenceStore } = require("./preferences"); +const { createMetaSignIn } = require("./meta-signin"); const run = promisify(execFile); @@ -352,6 +353,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 58fd182..8467e4a 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "frame-control", - "version": "0.4.6", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frame-control", - "version": "0.4.6", + "version": "0.4.7", "license": "MIT", "devDependencies": { "electron": "^44.4.5", diff --git a/app/package.json b/app/package.json index 346c6d7..2933148 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "frame-control", "productName": "Frame Control", - "version": "0.4.6", + "version": "0.4.7", "description": "Desktop app for managing a Valve Steam Frame over SSH", "private": true, "main": "main.js", @@ -40,6 +40,7 @@ "install-link.js", "updater.js", "preferences.js", + "meta-signin.js", "package.json", "build/icon.png" ], diff --git a/app/preload.js b/app/preload.js index 3f8b265..9b6e5f5 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", { @@ -43,6 +44,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/agents.md b/docs/agents.md index 76ee9c1..20cc82f 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics. ## Assistant panel -Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`. +Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`. To put the same page in the headset, with the HTTP server still running: ```sh diff --git a/docs/apk-repos.md b/docs/apk-repos.md index 25a86dd..66919d3 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 7caca71..48e45be 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 cef0885..966483e 100644 --- a/docs/apks.md +++ b/docs/apks.md @@ -39,6 +39,12 @@ section below), instead of going into Lepton Development: run at once alongside Lepton Development, each in its own container (`lepton-steamlaunch-`, ADB on 5556, 5557, …). +Split APKs (a base plus `split_*.apk` files) install as one app: +`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one +APK, so splits that add native libraries or assets are merged into the base +and re-signed; density and language splits are refused. See +[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame. + Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched from the shortcut, stopped, and relaunched with their data intact. ADB and Lepton Development aren't involved. diff --git a/docs/devices.md b/docs/devices.md index 20fe3e7..6d4bf50 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -3,8 +3,9 @@ Frame Control can manage more than one Steam Frame, and each headset can be reached at more than one address: a LAN IP at home, another at the office, its mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices** -tab (key 5) lists them, and the connection pill in the header shows what the -app is doing to reach the one in use, step by step, as it happens. +tab (key 5) lists them. The connection pill in the header says whether the one in +use is connected and how (Tailscale, or the network's name); click it to add, +edit or reorder that headset's addresses, or to see each step of connecting. The code is in three modules, all stdlib-only Python on your computer: @@ -57,7 +58,7 @@ and ranks them: 5. addresses that only ever worked on other networks; 6. Tailscale addresses while Tailscale is off. -Your order on the Devices tab breaks ties. The best-ranked address that answers +Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers wins; one that answers first waits up to 0.35 s for a better-ranked one that is still trying. If SSH to the winner fails in a way another address could fix (a different device answered there, or the link dropped), the next one that @@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit services and checks `ALIAS.local` and `frame.local`. +**The pill's dialog** lists the same addresses, with what each one answered, and +can add, edit, reorder and remove them without leaving the page you're on. When +the headset reports a LAN IP on the same network as this computer and that IP +isn't saved, it offers to add it. The offer puts the address first in the list, +so on that network it wins over the Tailscale name; away from home the Tailscale +name still leads. A new or edited address is tested straight away. While +connected, **Reconnect** applies your changes now rather than at the next +connection: it tries the addresses again, ranked as above, and the best-ranked +one that answers promptly wins. It doesn't pick a particular address. + ## Networks A network is told apart by its default gateway: the router's IP address plus its diff --git a/docs/evidence/mods-2026-09-29.md b/docs/evidence/mods-2026-09-29.md new file mode 100644 index 0000000..605a6c2 --- /dev/null +++ b/docs/evidence/mods-2026-09-29.md @@ -0,0 +1,122 @@ +# UEVR from Frame Control, 2026-09-29 + +**Verified observations**, with limits below. Same device and software as +[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID +`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the +Frame's AEST clock. Headset tests ran under the shared +`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40, +12:44–12:52 and 13:01–13:03. Battery 45 % → +93 %, on charge throughout. + +**Not observed:** the VR image, head tracking and controller input. SteamVR +reported `activity_level` 3 (user interaction timeout; the headset was not +being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo +capture was a uniform dark frame. Frame submits prove the app is presenting +to SteamVR, not that the image is correct. + +## Manual injection (11:13–11:28) + +Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was +started with the game process's Wine variables, and its WPF window drew +through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk +(64-bit, PCD3D_SM5))`, a Wine-side process id. + +- `--attach=Drop-Win64-Shipping` didn't inject. The button still read + **Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config` + was not restored on the next start. +- XTest clicks landed on the game: `XQueryPointer` reported child + `0x2000001` (the game) at the injector's coordinates, and + `XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that. +- `XSendEvent` button events to the injector window worked. After OpenVR and + Inject, `/proc//maps` contained + `drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to + Proton's `vrclient_x64.dll`. UEVR's log: + +```text +[11:17:32.948] Hooking D3D11 +[11:17:33.020] Hooked DirectX 11 +[11:17:34.263] Framework initialized +[11:17:34.475] [VR] Requested runtime: openvr_api.dll +[11:17:39.791] UGameViewportClient::Draw called for the first time. +[11:17:39.792] is stereo enabled called! +``` + +It also logged repeated `Failed to initialize Framework on DirectX 12` (probing +before it chose D3D11) and failed Unreal scans, for example `Failed to locate +r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`. +SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for +`steam.app.1067310`. `vrcmd --stats` then showed `"key" : +"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later. + +A second session's injector exited within about 20 seconds. The third, +started fresh, injected again (`frame_submits` 1412). After the game was +stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test +files were removed. + +## Through Frame Control (11:33–11:49) + +Local `ui/server.py` against the `frame` alias. First the API, then the +page's **VR mod** dialog, driven with a headless Chromium: + +| Step | Result | +|---|---| +| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") | +| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written | +| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) | +| `uninstall` while running | Refused: "the game is running; quit it first" | +| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) | +| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes | +| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) | +| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 | +| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone | + +## The GUI injector wasn't reliable (12:02–12:40) + +After review fixes, cold starts through the API with UEVR's own injector: + +- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that + relaunched the injector didn't rescue a bad session. In both failures of + the last batch, the first injector ignored the clicks and the next two + never showed a window within 60 s. +- Captures of the ignoring injector (`xwd`) showed its first-drawn frame, + with OpenVR still unselected. The injector's log had no error. + +Lock released at 12:40 before redesigning. + +## Frame Control's own injector (12:44–12:52) + +`start` now runs `frame_inject.py` under python.org's embeddable Python +3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in +the game's Wine session. The old install was removed and the new one +installed without the headset lock, since neither launches anything. The +prefix listing after removal matched the pre-install state; the new install +is 47 MB. + +| Step | Result | +|---|---| +| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` | +| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check | +| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) | +| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) | +| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) | + +Battery 98–100 % on charge. Lock released at 12:52. + +## After the second review (13:01–13:03) + +`frame_inject.py` now declares every Win32 signature, checks each result, +frees its remote buffers and closes its handles; uninstall accepts only our +folder and UEVR's own settings folders. Rechecked: install; three cold starts +injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging +`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall +left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache, +receipts or processes. Frame Control's injector total: 12 of 12 cold starts +through the API, plus the UI run. + +## Left on the Frame + +- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still + installed; nothing else from this work is left. +- No `UEVRInjector.exe` or game processes were left running. Steam and + SteamVR were running at the end. +- The test lock was released at 13:03. diff --git a/docs/family-comfort.md b/docs/family-comfort.md index e2f1afe..98b94b5 100644 --- a/docs/family-comfort.md +++ b/docs/family-comfort.md @@ -58,7 +58,7 @@ not been verified and is not guaranteed. ## Casting -**Cast headset view** starts the existing headset Live view and requests full +**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full screen where supported. Show that screen to people in the room, or use the computer/phone's own screen mirroring. It creates no new stream transport, public URL or LAN server. iPhone uses the inline viewer if full screen is not diff --git a/docs/frame-control.md b/docs/frame-control.md index ae92938..1696757 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -17,15 +17,21 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810 ## Features -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), **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 +The window has six tabs: **Home** (what the headset's doing and what you'd do +next, the headset view, screenshots, family and comfort, the keyboard and +trackpad, and VR performance, folded away), **Games** (installed games, +sideloaded titles, getting games), **Android** (apps, the catalogue, display +settings, reports), **Tools** (in the headset: your computer's windows, the media +player and the panel switcher; then sending files and text, Flatpaks, remote and +power), **Devices** (your headsets and their addresses), and **Control** (the +searchable Control Center, metrics, supported settings and headset guidance). +Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**: +privacy and updates, library artwork and the assistant. The battery chip opens +the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS +build. Files can be dropped anywhere in the window. A +connection pill in the header always shows which headset and whether it's +connected, and how (Tailscale, or the network's name). Click it to add, edit or +reorder the headset's addresses, or to see each step of connecting. When the Frame can't be reached, a banner says why in plain words, what was tried, and counts down to the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar counts them while they run. @@ -239,5 +245,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes require a separate user approval; power also retains its password prompt. The assistant uses a user-chosen endpoint and sends nothing until the user opts in for a message. Screenshot context is separately opt-in. Model replies cannot -operate the headset. Tools → Open assistant opens the page; the linked guide +operate the headset. Settings → Open assistant settings opens the page; the linked guide covers putting it in a Chromium panel on the Frame. diff --git a/docs/how-the-frame-works.md b/docs/how-the-frame-works.md index 72aa156..2d4cbf9 100644 --- a/docs/how-the-frame-works.md +++ b/docs/how-the-frame-works.md @@ -52,6 +52,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600 | The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` | | Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) | | **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) | +| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc//environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) | | **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d ` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) | | Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` | | **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id ` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame | diff --git a/docs/img/uevr-dialog.png b/docs/img/uevr-dialog.png new file mode 100644 index 0000000..18876d7 Binary files /dev/null and b/docs/img/uevr-dialog.png differ diff --git a/docs/iphone.md b/docs/iphone.md index debc954..6381799 100644 --- a/docs/iphone.md +++ b/docs/iphone.md @@ -114,8 +114,8 @@ don't. ## Family and comfort -The shared Home card sets session limits, breaks and check-ins, and offers -**Cast headset view**. **Enable / test notifications** requests iOS notification +The shared Home card sets session limits, breaks and check-ins; **Cast to this +screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification permission and sends a local test. These are local notifications, not APNs push; iOS background suspension can interrupt phone alerts. The headset timer still runs. See [the behavior and verification limits](family-comfort.md). 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/mods.md b/docs/mods.md index 27a7c2a..8fb7a9c 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -1,11 +1,14 @@ # Flat-to-VR mods and Beat Saber songs -**Status: feasibility work, not an installer.** Frame Control does not yet -manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26) -stays open: neither UEVR injection nor Beat Saber custom-song playback has -been verified on this Frame. Alex has an owned copy on a Quest 2; that copy -has not been inspected. There is no Mods button until the underlying -install, playback and removal have been checked. +**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber +songs are blocked.** Installed games have a **VR mod** button. It installs +UEVR, starts the game with UEVR injected, and removes UEVR again. That whole +loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own +UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so +the VR image, head tracking and controls haven't been seen yet. Beat Saber +custom songs need an owned Beat Saber on the Frame, which there isn't +([below](#beat-saber-songs-first)). +[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those. The mod manager must be Frame Control's own implementation. Mods and songs are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other @@ -15,27 +18,87 @@ unofficial mod mirrors are part of this work. ## Per-game support -Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64, -with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only +Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID +**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only the observation stated, not a promise that the game is playable. “Documented” means an upstream source describes it; “inferred” means it still needs a test. | Game / build | Mod or content | Evidence and support status | Next check | |---|---|---|---| | Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. | -| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. | +| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. | | Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. | | Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. | -| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. | +| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. | | Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. | | Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. | | Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. | | Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. | | Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. | -The [test record](evidence/mods-2026-09-28.md) distinguishes process startup, -visible output and failures. It also records a SteamVR restart during the -shared session, which prevents attributing the failed UEVR attempt to FEX. +The test records ([2026-09-28](evidence/mods-2026-09-28.md), +[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup, +visible output, injection and failures. + +## UEVR from Frame Control + +![The VR mod dialog while UEVR runs in Gravitas](img/uevr-dialog.png) + +**Get games → an installed game → VR mod.** The dialog says whether the game +can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove +UEVR**. The server route is `POST /api/mods {"action": "status" | "install" | +"start" | "uninstall", "appid": …}`, and the work happens on the Frame in +[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library, +like `frame_steam.py`. + +- **Which games.** Unreal Engine games, recognised by their + `…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked + with Gravitas, and HL2 VR is correctly refused). The game needs a Proton + prefix, so it must have been played once. +- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05) + and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/). + Each is checked against a pinned SHA-256; Python's comes from python.org's + release-file API. Archives are unpacked into a staging folder, rejecting + entries that escape it, symlinks and oversized contents, then moved into + `/drive_c/frame-control/` (about 47 MB). Nothing goes into the + game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists + what to remove, including UEVR's own settings folders only if they didn't + exist before. Install and remove refuse while the game is running, and only + one mod action runs at a time. +- **Play in VR.** Launches the game through Steam if it isn't running and + waits for its window. It then runs a short script under that Python, with + the game's Proton, prefix and Wine settings read from the game process, so + both share one Wine session. The script repeats what UEVR's own injector does + when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and + call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in + UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a + `LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start` + then checks that `UEVRBackend.dll` is mapped into the game and reads + SteamVR's frame count for the app (`vrcmd --stats`). +- **Remove.** Deletes what the receipt lists, then the download cache once + no game uses it. Verified: the prefix's file listing matched its + pre-install state afterwards. + +Why not UEVR's own injector (verified 2026-09-29): + +- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB + of downloads) in the prefix. +- Under gamescope, the full-screen game keeps the pointer and focus. XTest + clicks, raising or refocusing the injector window don't change that, so in + the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease` + events straight to its window does work. +- `--attach=` didn't inject under Wine, and it didn't restore its + saved OpenVR choice. +- Driven that way, it injected in 6 of 8 cold starts. In a bad session every + new injector ignored input or never showed its window, even after + relaunching. Its process list reads each process's main window title, a + cross-process window message; a window that isn't answering would block its + UI thread (inferred from the frontend's source, not proven). Without a + `HOME` it also died in .NET at startup. +- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI. +- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for + Gravitas, and some of its Unreal engine scans fail on this older UE4 game. + Neither stopped the injection. ## Beat Saber: songs first @@ -62,7 +125,10 @@ or dependency for Frame Control. ## Requirements for our manager -These are **planned**, not implemented or verified: +For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by +fake-library tests (`tests/test_mods.py`), apart from checking ownership +through Steam; item 6 is met for Gravitas. Beat Saber songs would need the +same, and none of it is built for them: 1. Resolve the selected Steam game's real library, installed build, executable architecture and Proton prefix. Confirm ownership through Steam; a directory @@ -91,7 +157,11 @@ These are **planned**, not implemented or verified: - [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05) and [author's usage instructions](https://github.com/praydog/UEVR#getting-started). - [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json), - including the SHA-512 hashes used for the test runtimes. + including the SHA-512 hashes used for the injector tests. +- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs` + (`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows. +- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the + embeddable Python's `sha256_sum`. - [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr), including per-game installation and the need to preserve original DLLs. - [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151) diff --git a/docs/panels.md b/docs/panels.md index 5b17828..6c8478b 100644 --- a/docs/panels.md +++ b/docs/panels.md @@ -125,7 +125,7 @@ a limit on the number of floating panels. separate from Frame Control. Public reports describe some Proton support; Windows-only does not by itself prove a Frame app cannot run. Local status and sources are in [VR utilities](vr-utilities.md). -- **Our performance HUD:** Home → VR comfort and performance → Open HUD in +- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in headset creates its own gamescope panel using built-in tools. It needs no third-party overlay app. [Metrics and verification](vr-utilities.md). diff --git a/docs/privacy.md b/docs/privacy.md index 2c7b2b3..af26741 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -90,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. @@ -124,8 +132,8 @@ runs. ## Report a problem -**Report a problem** is the warning-sign button in the header, also in the -Privacy panel and under **Help → Report a Problem…**. It sends the report +**Report a problem** is the speech-bubble button in the header, also in the +Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report privately to Frame Control's PostHog project as a `problem_report` event, the same way as the analytics above, so only the maintainer can read it and nothing is published. It works whatever the analytics settings are, because 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/sidequest.md b/docs/sidequest.md index eba0f02..23311d0 100644 --- a/docs/sidequest.md +++ b/docs/sidequest.md @@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here. - `search()` raises a user-readable `SourceError` with the browse URL (zero limit returns no rows). It does not invent app results or report a false “no matching games”. The aggregate search UI should surface this source error. + In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest + with a link to its website instead of an on/off switch. - `details()` accepts a numeric listing id and returns its canonical page link, `downloadable: False`, empty versions/tags/headsets and the `images` shape `{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id; diff --git a/docs/steam-games.md b/docs/steam-games.md index ddaa4c7..5548925 100644 --- a/docs/steam-games.md +++ b/docs/steam-games.md @@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's Steam Frame rating, installs them on the Frame, and searches the Steam store. This page covers how it works underneath, so you can do the same from a shell. -For flat-to-VR mods and Beat Saber custom songs, see the -[per-game feasibility table](mods.md). Mod support is separate from Steam's -Frame rating; there is no mod installer yet. +Installed Unreal Engine games also have a **VR mod** button that installs, +starts and removes UEVR; see [VR mods](mods.md) for how it works and the +per-game table. Mod support is separate from Steam's Frame rating. ## How it works 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 7234cd7..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 @@ -186,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..240bb91 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as an installed app with a blank tile. Artwork preference is **SteamGridDB → source images → generated fallback**. -Set the optional free key in Frame Control's **Library artwork settings**, or +Set the optional free key in Frame Control's **Settings → Library artwork**, or `STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment settings override the saved key. Without a key there are no provider calls or warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never @@ -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/docs/vr-utilities.md b/docs/vr-utilities.md index 0e73669..6e2db25 100644 --- a/docs/vr-utilities.md +++ b/docs/vr-utilities.md @@ -11,7 +11,7 @@ checks below. The PR stays draft. ## Our performance HUD -On **Home → VR comfort and performance**, the app shows a timestamped sample +On **Home → VR performance** (folded until you open it), the app shows a timestamped sample with each status refresh (30 seconds, or Refresh). **Open HUD in headset** starts our text HUD as a gamescope panel, refreshed every two seconds. In the SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock @@ -26,7 +26,7 @@ it twice reuses the existing process. | Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. | | System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. | | GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. | -| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. | +| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. | OpenVR uses background application mode, which does not start SteamVR or keep it running. This mode also returned live timing in a read-only device probe. diff --git a/docs/vr-video.md b/docs/vr-video.md index 2232915..eab6080 100644 --- a/docs/vr-video.md +++ b/docs/vr-video.md @@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path. ## Use it -In **Tools → Media in the headset**, send a file, choose its layout and press +In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press **Play**. **Theatre** gives it a larger screen and an 85% black surround. **Stop** removes both. Refresh reads the library and the player's state. The screen follows your head; it isn't a saved world-space panel. 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/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 index c74597f..2bc56f2 100644 --- a/tests/control_center.cjs +++ b/tests/control_center.cjs @@ -64,7 +64,7 @@ function sharedPage({productionApi=false} = {}) { const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise; }; Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){}, - padReset(){},ctrlReset(){},resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}}); + padReset(){},ctrlReset(){},resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}}); vm.runInContext(`const UI_KEY = 'test-only'; const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null}; const disp = {}, gm = {seq:0}, cat = {}, wi = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0}; 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/page/store_detail.mjs b/tests/page/store_detail.mjs index 276d773..6de1043 100644 --- a/tests/page/store_detail.mjs +++ b/tests/page/store_detail.mjs @@ -19,8 +19,8 @@ function grab(name) { fail(`unbalanced ${name}`); } const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage", - "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody", - "updateInstall", "openStoreApp", "loadOfferDetails"]; + "GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody", + "updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"]; const code = names.map(grab).join(""); // A fake page: elements by id. The install panel's elements exist only while @@ -34,8 +34,10 @@ function page(api = async () => ({}), androidApps = []) { const document = { activeElement: null, querySelector: () => null }; const console = { error() {} }; const calls = []; - const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", code + "; return { renderAppDetail, openStoreApp };")( - $, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps); + const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp };")( + $, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps, + async (title, key, work) => work(), () => {}, async (title, work) => work(), + { open() { fail("an in-app installation opened an external page"); } }, 0); return { els, sourceState, calls, ...fns }; } @@ -76,7 +78,7 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid { const p = page(async () => { throw new Error("offline"); }); p.sourceState.apps = [{ name: "Broken", offers: [] }]; - await p.openStoreApp(0); + await p.openStoreApp(0, true); if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error"); if (p.calls.length) fail("asked for details of a listing that couldn't render"); } @@ -98,4 +100,18 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request"); if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request"); } +// The featured Install starts a new or failed job, while a job already running or +// installed opens its details without another install or a launch command. +for (const [job, expected] of [[null, 1], [{ done: false }, 0], + [{ done: true, result: { package: offer.package } }, 0], [{ done: true, error: "offline" }, 1]]) { + const p = page(async path => path === "/api/sources/install" ? { package: offer.package } : {}); + p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }]; + if (job) p.sourceState.jobs.set(`${offer.source}:${offer.id}`, job); + await p.openStoreApp(0, true); + if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('id="detailInstall"')) fail("hero installation didn't open details"); + const installs = p.calls.filter(([path]) => path === "/api/sources/install"); + if (installs.length !== expected) fail(`expected ${expected} installation requests, got ${installs.length}`); + if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer"); + if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app"); +} console.log("ok"); diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py index fea420c..bc3fae0 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_ui.py b/tests/test_comfort_ui.py index 1866540..5d4b8e7 100644 --- a/tests/test_comfort_ui.py +++ b/tests/test_comfort_ui.py @@ -56,6 +56,48 @@ const log = ()=>{}, toast = ()=>{}; assert.equal($('sessionMinutes').disabled,false); assert.equal($('sessionCancel').disabled,true); })().catch(e=>{console.error(e);process.exitCode=1;}); +''' + result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_a_finished_session_leaves_the_defaults(self): + """A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0.""" + page = (ROOT / 'ui/index.html').read_text(encoding='utf-8') + code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')] + setup = r''' +const assert = require('node:assert/strict'); +const elements = new Map(); +const $ = id => { + // The form's own defaults, as in the page's HTML. + const value = {sessionMinutes:'30', breakMinutes:'20', stillMinutes:'30'}[id] || ''; + if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value, defaultValue:value, + checked:true, defaultChecked:true, type: /Alert$/.test(id) ? 'checkbox' : 'number'}); + return elements.get(id); +}; +const window = {frameApp:{notify:async()=>{}}}; +const log = ()=>{}, toast = ()=>{}; +''' + checks = r''' +renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false}, + events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]}); +assert.equal($('sessionMinutes').value, '30'); +assert.equal($('breakMinutes').value, '20'); +assert.equal($('batteryAlert').checked, true); +assert.match($('comfortEvents').textContent, /Session ended/); +assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time +renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]}); +assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings +assert.equal($('heatAlert').checked, false); +// The page stays open while that session ends: the next one starts from the defaults again. +renderComfort({id:'new', active:false, time:900, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, + events:[{id:'new:1',kind:'finished',time:899,message:'Session ended'}]}); +assert.equal($('sessionMinutes').value, '30'); +assert.equal($('breakMinutes').value, '20'); +assert.equal($('heatAlert').checked, true); +renderComfort({id:'new', active:false, time:905, options:{minutes:45}, events:[]}); +$('sessionMinutes').value = '50'; // what's typed for the next session +renderComfort({id:'new', active:false, time:910, options:{minutes:45}, events:[]}); +assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone ''' result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) diff --git a/tests/test_devices.py b/tests/test_devices.py index 2e838bf..1705dc5 100644 --- a/tests/test_devices.py +++ b/tests/test_devices.py @@ -323,6 +323,20 @@ class Pins(Base): class Registry(Base): + def test_an_address_added_first_wins_on_its_own_network(self): + # The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has + # worked here before. The LAN address has to go ahead of it to be used at home. + d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"]) + self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0) + self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True) + self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it + addrs = self.reg.get(d["id"])["addresses"] + self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"]) + at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)] + self.assertEqual(at_home[0], "192.168.1.40") + away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)] + self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads + def test_address_editing(self): d = self.reg.add_device("frame-3", hosts=["192.168.1.40"]) a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS") 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 3f1c7f7..bf74eba 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -121,8 +121,26 @@ 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() from apk_sources import _images _images._dns.clear() # each test answers lookups its own way self.addCleanup(_images._dns.clear) @@ -237,10 +255,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 @@ -248,10 +263,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 diff --git a/tests/test_install_queue.py b/tests/test_install_queue.py new file mode 100644 index 0000000..1dfc443 --- /dev/null +++ b/tests/test_install_queue.py @@ -0,0 +1,127 @@ +"""The install queue (server.queue_job): Android, Quest and Meta installs run one after another.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +from pathlib import Path +import sys +import threading +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / 'ui')) +import server # noqa: E402 + + +def wait(job, timeout=5): + end = time.time() + timeout + while time.time() < end: + j = server.job_status(f'id={job}') + if j['done']: + return j + time.sleep(0.01) + raise AssertionError(f'job {job} never finished') + + +class InstallQueue(unittest.TestCase): + def setUp(self): + self.assertEqual(server._queue, []) + + def blocker(self): + """A queued install that runs until released.""" + go, started = threading.Event(), threading.Event() + + def work(): + started.set() + go.wait(5) + return {'message': 'first done'} + job = server.queue_job('Install first', work)['job'] + self.assertTrue(started.wait(5)) + self.addCleanup(go.set) + return job, go + + def test_runs_in_order_one_at_a_time(self): + order, running = [], [] + + def work(name): + def run(report): + running.append(name) + self.assertEqual(len(running), 1, 'two installs ran at once') + report('Copying', 50) + time.sleep(0.02) + order.append(name) + running.remove(name) + return {'message': f'{name} done'} + return run + first, go = self.blocker() + started = [server.queue_job(f'Install {n}', work(n), progress=True) for n in ('a', 'b', 'c')] + self.assertEqual([s['queued'] for s in started], [1, 2, 3]) + self.assertIn('queued, 3 before it', started[2]['message']) + q = server.install_queue()['jobs'] + self.assertEqual([j['label'] for j in q], ['Install first', 'Install a', 'Install b', 'Install c']) + self.assertEqual([j['queued'] for j in q], [False, True, True, True]) + self.assertEqual(q[1]['stage'], 'Queued: 1 install before it') + self.assertEqual(q[3]['stage'], 'Queued: 3 installs before it') + go.set() + self.assertEqual(wait(first)['message'], 'first done') + for s, n in zip(started, 'abc'): + j = wait(s['job']) + self.assertEqual((j['message'], j['error'], j['queued']), (f'{n} done', None, False)) + self.assertEqual(order, ['a', 'b', 'c']) + self.assertEqual(server.install_queue(), {'jobs': []}) + + def test_a_failure_does_not_stop_the_rest(self): + def fail(): + raise server.Failure('no room on the Frame') + bad = server.queue_job('Install bad', fail)['job'] + good = server.queue_job('Install good', lambda: {'message': 'ok'})['job'] + self.assertEqual(wait(bad)['error'], 'no room on the Frame') + self.assertEqual(wait(good)['message'], 'ok') + + def test_a_waiting_install_can_be_removed_but_not_a_running_one(self): + ran = [] + first, go = self.blocker() + second = server.queue_job('Install second', lambda: ran.append(2) or {})['job'] + third = server.queue_job('Install third', lambda: ran.append(3) or {})['job'] + self.assertIn('Removed Install second', server.queue_action({'action': 'remove', 'job': second})['message']) + j = server.job_status(f'id={second}') + self.assertTrue(j['done'] and j['cancelled']) + self.assertEqual(server.job_status(f'id={third}')['stage'], 'Queued: 1 install before it') + for body, status in (({'action': 'remove', 'job': first}, 409), ({'action': 'remove', 'job': second}, 409), + ({'action': 'nope', 'job': third}, 400)): + with self.assertRaises(server.Failure) as e: + server.queue_action(body) + self.assertEqual(e.exception.status, status) + go.set() + wait(third) + self.assertEqual(ran, [3]) + + def test_runs_only_on_the_headset_it_was_queued_for(self): + class Link: + device = 'frame-a' + + def active_device(self): + return {'id': self.device} + link = Link() + with patch.object(server, 'LINK', link): + first, go = self.blocker() + ran = [] + later = server.queue_job('Install later', lambda: ran.append(1) or {})['job'] + link.device = 'frame-b' + go.set() + self.assertIn('switched headsets', wait(later)['error']) + self.assertEqual(ran, []) + + def test_installs_from_each_place_are_queued(self): + with patch.object(server, 'ensure_master'), patch.object(server, 'queue_job', return_value={'job': 'j'}) as q: + server.quest({'action': 'import', 'package': 'com.example.game'}) + server.meta({'action': 'install', 'app': '123'}) + with patch.object(server.frame_catalog, 'app'): + server.android({'action': 'install', 'package': 'com.example.game'}) + server.android({'action': 'install', 'package': 'com.example.game', 'url': 'https://f-droid.org/x.apk'}) + with patch.object(server.apk_search, 'resolve', return_value=(None, {'enabled': True})): + server.source_install({'source': 'fdroid', 'id': 'com.example.game'}) + self.assertEqual(q.call_count, 5) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_link.py b/tests/test_link.py index c60970f..86cc359 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -897,6 +897,47 @@ class Connecting(unittest.TestCase): self.assertEqual(self.routes, []) self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c)) + def test_a_test_started_earlier_cant_overwrite_a_newer_one(self): + d = self.device("nothing.invalid") + entered = {1: threading.Event(), 2: threading.Event()} + release = {1: threading.Event(), 2: threading.Event()} + calls = [] + + def probe(host, port, update=None): + calls.append(host) + n = len(calls) + entered[n].set() + release[n].wait(10) + return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None} + + tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)] + with mock.patch.object(fl, "probe", probe): + try: + tests[0].start() + self.assertTrue(entered[1].wait(5), "the first test never probed") + tests[1].start() + self.assertTrue(entered[2].wait(5), "the second test never probed") + # The first finishes while the second is still probing: it mustn't show its + # rows or mark the second done. + release[1].set() + tests[0].join(10) + self.assertFalse(tests[0].is_alive()) + running = self.link.snapshot()["tests"][d["id"]] + self.assertFalse(running["done"]) + self.assertEqual(running["rows"][0]["detail"], "Waiting") + release[2].set() + tests[1].join(10) + self.assertFalse(tests[1].is_alive()) + finally: + for e in release.values(): + e.set() + for t in tests: + if t.ident: # started + t.join(10) + result = self.link.snapshot()["tests"][d["id"]] + self.assertTrue(result["done"]) + self.assertEqual(result["rows"][0]["detail"], "test 2") + def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self): self.device("localhost") self.hosts({"localhost": "ok"}) diff --git a/tests/test_link_ui.py b/tests/test_link_ui.py index 1e1012d..2ea9ba1 100644 --- a/tests/test_link_ui.py +++ b/tests/test_link_ui.py @@ -56,18 +56,19 @@ class LinkUI(unittest.TestCase): 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. + def test_the_pill_keeps_the_failure_detail_in_its_tooltip(self): + # The connection redesign keeps the state and retry countdown in the pill, + # with the full failure explanation in its tooltip and details dialog. self.run_page(r''' link.s = changed; renderPill(); - assert.match($('conn').innerHTML, /Office Frame<\/b>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.'); + assert.match($('conn').innerHTML, /Office Frame<\/b>Offline · retrying in 12 s<\/span>/); + assert.ok($('conn').title.includes(changed.error.message)); // Without a short reason: the stage that failed. link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}}; renderPill(); - assert.match($('conn').innerHTML, /Checking the headset's identity failed<\/span>/); + assert.match($('conn').innerHTML, /Offline<\/span>/); + assert.ok($('conn').title.includes('Something.')); ''') def test_the_banner_never_doubles_a_full_stop(self): diff --git a/tests/test_meta.py b/tests/test_meta.py new file mode 100644 index 0000000..db1c4f9 --- /dev/null +++ b/tests/test_meta.py @@ -0,0 +1,1266 @@ +"""My Meta library, Experimental (ui/frame_meta.py), against a fake Meta (GraphQL answers and +download server built from the shapes the public tools document) and a fake Frame. Offline: +nothing here reaches Meta, a headset or the compatibility database.""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import io +import json +import os +import re +from pathlib import Path +import sys +import threading +import unittest +from unittest.mock import patch +import urllib.parse + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android # noqa: E402 +import frame_meta as meta # noqa: E402 +import frame_quest as quest # noqa: E402 +import frame_telemetry # noqa: E402 +import test_quest # noqa: E402 (its APK builder; importing a module adds no tests here) + +PKG = test_quest.PKG +TOKEN = 'OCAtestToken1234567890abcdefGHIJ-_xyz' +OTHER_TOKEN = 'FRLAnotherToken0987654321zyxwvuts' +APK = test_quest.apk_bytes(files=test_quest.OPENXR, version_code=100, version_name='1.0') +OBB = b'obb data ' * 100 +PUBLIC = {'157.240.22.35'} +REAL_SEND = meta._send # before any test replaces it with a fake Meta +REAL_SSH = android.ssh +PRINTABLE = ''.join(chr(c) for c in range(32, 127)) + + +def entitlements(apk=APK, obb=OBB, platform_sdk=False): + binary = {'id': '9001', 'package_name': PKG, 'version': '1.0', 'version_code': 100, 'size': str(len(apk)), + 'obb_binary': {'id': '9002', 'file_name': 'game-data.obb', 'size': len(obb)}, + 'asset_files': {'nodes': [{'id': '9003', 'file_name': 'level1.pak', 'size': 3, 'is_required': True}, + {'id': '9004', 'file_name': 'dlc.pak', 'size': 3, 'is_required': False}]}} + return {'data': {'viewer': {'user': {'active_entitlements': {'nodes': [ + {'active_state': 'ACTIVE', 'grant_reason': 'PURCHASE', + 'item': {'id': '111', 'display_name': 'Quest Game', 'platform': 'ANDROID_6DOF', 'latest_supported_binary': binary}}, + # Details come from a second query (only the binary id in the list). + # A real account's purchases come back PERMANENT. + {'active_state': 'PERMANENT', 'item': {'id': '222', 'display_name': 'Another Game', 'platform': 'ANDROID_6DOF', + 'latest_supported_binary': {'id': '9100'}}}, + {'active_state': 'ACTIVE', 'item': {'id': '333', 'display_name': 'A Rift Game', 'platform': 'PC'}}, + {'active_state': 'ACTIVE', 'expiration_time': 1000, 'item': {'id': '444', 'display_name': 'Expired', + 'platform': 'ANDROID_6DOF'}}, + {'active_state': 'INACTIVE', 'item': {'id': '555', 'display_name': 'Refunded', 'platform': 'ANDROID_6DOF'}}, + ]}}}}} + + +class Body(io.BytesIO): + pass + + +class FakeMeta: + """graph.oculus.com and securecdn.oculus.com (which redirects to a CDN host), in memory.""" + + def __init__(self, test, token=TOKEN, apk=APK, obb=OBB, redirect=True): + self.token, self.redirect, self.requests = token, redirect, [] + self.files = {'9001': apk, '9002': obb, '9003': b'pak', '9004': b'dlc'} + self.entitlements = entitlements(apk, obb) + self.details = {'9100': {'data': {'node': {'id': '9100', 'package_name': 'org.example.another', 'version': '2', + 'version_code': '7', 'size': 10, 'obb_binary': None, + 'asset_files': {'edges': []}}}}} + self.addresses = {} # host -> addresses its (fake) DNS gives + self.hold = None # an Event the "who am I" answer waits for + self.cdn_headers = {} + self.location = 'https://scontent.oculuscdn.com/v/{id}?sig=abc' + self.lookups = [] + for p in (patch.object(meta, '_send', side_effect=self.send), + patch.object(meta.socket, 'getaddrinfo', side_effect=self.getaddrinfo)): + p.start() + test.addCleanup(p.stop) + + def getaddrinfo(self, host, port, *args, **kwargs): + self.lookups.append(host) + return [(10 if ':' in a else 2, 1, 6, '', (a, port)) for a in sorted(self.addresses.get(host, PUBLIC))] + + def answer(self, status, body, headers=None): + raw = body if isinstance(body, bytes) else json.dumps(body).encode() + return status, dict(headers or {}), Body(raw) + + def send(self, url, data=None, headers=None, timeout=30): + self.requests.append({'url': url, 'data': data, 'headers': dict(headers or {})}) + p = urllib.parse.urlsplit(url) + if p.hostname == 'graph.oculus.com': + form = dict(urllib.parse.parse_qsl(data.decode())) + if form.get('access_token') != self.token: + return self.answer(400, {'error': {'type': 'OAuthException', 'code': 190, 'message': + f"Invalid OAuth access token {form.get('access_token')}"}}) + doc = form['doc_id'] + if doc == meta.DOC_VIEWER: + if self.hold: + self.hold.wait(5) + return self.answer(200, {'data': {'viewer': {'user': {'id': '1234', 'alias': 'player_one'}}}}) + if doc == meta.DOC_ENTITLEMENTS: + return self.answer(200, self.entitlements) + if doc == meta.DOC_BINARY: + return self.answer(200, self.details[json.loads(form['variables'])['binaryID']]) + return self.answer(200, {'errors': [{'message': 'unknown doc'}]}) + q = dict(urllib.parse.parse_qsl(p.query)) + if p.hostname == 'securecdn.oculus.com': + if q.get('access_token') != self.token: + return self.answer(401, b'') + if q['id'] not in self.files: + return self.answer(404, b'') + if self.redirect: + return self.answer(302, b'', {'Location': self.location.format(id=q['id'], token=self.token)}) + return self.answer(200, self.files[q['id']], {'Content-Length': str(len(self.files[q['id']]))}) + if p.hostname == 'scontent.oculuscdn.com': + body = self.files[p.path.rsplit('/', 1)[-1]] + headers = dict({'Content-Length': str(len(body))}, **self.cdn_headers) + return self.answer(200, body, {k: v for k, v in headers.items() if v is not None}) + return self.answer(404, b'') + + def downloads(self): + return [r for r in self.requests if 'oculus.com/binaries' in r['url'] or 'oculuscdn' in r['url']] + + +def connected(test, **kw): + fake = FakeMeta(test, **kw) + meta.connect(TOKEN) + test.addCleanup(meta.disconnect) + return fake + + +class Tokens(unittest.TestCase): + def tearDown(self): + meta.disconnect() + + def test_token_format(self): + self.assertEqual(meta.check_token(f' {TOKEN}\n'), TOKEN) + self.assertEqual(meta.check_token('Bearer ' + OTHER_TOKEN), OTHER_TOKEN) + for bad, why in (('', 'Paste'), ('hello world', 'unusual'), ('ABCDEFGHIJKLMNOPQRSTUVWX', 'OC or FRL'), + ('OC' + '%7C' * 10, 'URL-encoded'), ('OC|752908224809889|', 'app token'), + ('OC123456789012345', 'incomplete')): + with self.assertRaisesRegex(meta.MetaError, why): + meta.check_token(bad) + + def test_connect_checks_then_stores_owner_only(self): + fake = FakeMeta(self) + result = meta.connect(TOKEN) + self.assertTrue(result['connected']) + self.assertEqual(result['user'], {'name': 'player_one'}) + self.assertNotIn(TOKEN, json.dumps(result)) + self.assertNotIn(TOKEN, json.dumps(meta.status())) + path = meta.account_path() + self.assertEqual(json.loads(path.read_text())['token'], TOKEN) + if os.name != 'nt': + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertFalse([p for p in path.parent.iterdir() if p.name.startswith('.meta-account-')]) # no temp left + # The token went to Meta's API host only, over HTTPS, in the POST body (not the address). + request = fake.requests[0] + self.assertTrue(request['url'].startswith('https://graph.oculus.com/')) + self.assertNotIn(TOKEN, request['url']) + self.assertIn(urllib.parse.quote(TOKEN, safe=''), request['data'].decode()) + self.assertEqual(dict(urllib.parse.parse_qsl(request['data'].decode()))['doc_id'], meta.DOC_VIEWER) + + def test_rejected_token_is_not_stored_or_echoed(self): + FakeMeta(self, token=OTHER_TOKEN) + with self.assertRaises(meta.TokenRejected) as e: + meta.connect(TOKEN) + self.assertNotIn(TOKEN, str(e.exception)) + self.assertFalse(meta.account_path().exists()) + self.assertFalse(meta.status()['connected']) + + def test_disconnect_deletes_the_file(self): + FakeMeta(self) + meta.connect(TOKEN) + meta.disconnect() + self.assertFalse(meta.account_path().exists()) + with self.assertRaisesRegex(meta.MetaError, 'Connect your Meta account first'): + meta.library() + meta.disconnect() # twice is fine + + def test_disconnect_wins_over_a_connect_in_flight(self): + fake = FakeMeta(self) + fake.hold = threading.Event() + errors = [] + + def connect(): + try: + meta.connect(TOKEN) + except meta.MetaError as e: + errors.append(e) + thread = threading.Thread(target=connect) + thread.start() + for _ in range(500): + if fake.requests: + break + threading.Event().wait(0.01) + meta.disconnect() # while Meta is still answering "who am I" + fake.hold.set() + thread.join(10) + self.assertEqual(len(errors), 1) + self.assertIn('disconnected', str(errors[0])) + self.assertFalse(meta.account_path().exists()) + self.assertFalse(meta.status()['connected']) + + +class SignInAttempts(unittest.TestCase): + """Sign in with Meta (app/meta-signin.js): a token from an attempt the app gave up on is + never kept, whether the cancel comes while it is checked or just after it was saved.""" + + def tearDown(self): + meta.disconnect() + + def held_connect(self, fake, attempt): + """Start connect(TOKEN, attempt) and wait until Meta is answering "who am I".""" + fake.hold = threading.Event() + errors = [] + + def connect(): + try: + meta.connect(TOKEN, attempt=attempt) + except meta.MetaError as e: + errors.append(e) + thread = threading.Thread(target=connect) + thread.start() + for _ in range(500): + if fake.requests: + break + threading.Event().wait(0.01) + return thread, errors + + def test_cancelling_while_meta_answers_wins(self): + fake = FakeMeta(self) + thread, errors = self.held_connect(fake, 'attempt-during-0003') + self.assertIn('attempt-during-0003', meta._attempts) + meta.cancel_signin('attempt-during-0003') # the window closed while Meta was answering + fake.hold.set() + thread.join(10) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0], meta.SignInCancelled) + self.assertFalse(meta.account_path().exists()) + self.assertFalse(meta.status()['connected']) + self.assertEqual(meta._attempts, {}) + + def test_other_cancels_cant_push_out_one_in_flight(self): + fake = FakeMeta(self) + thread, errors = self.held_connect(fake, 'attempt-held-0004') + meta.cancel_signin('attempt-held-0004') + for i in range(300): + meta.cancel_signin(f'attempt-noise-{i:04d}') + fake.hold.set() + thread.join(10) + self.assertIsInstance(errors[0], meta.SignInCancelled) + self.assertFalse(meta.account_path().exists()) + self.assertEqual(meta._attempts, {}) # unknown ids left nothing behind + + def test_a_cancel_just_after_the_save_removes_that_token(self): + FakeMeta(self) + self.assertTrue(meta.connect(TOKEN, attempt='attempt-saved-0005')['connected']) + self.assertFalse(meta.cancel_signin('attempt-saved-0005')['connected']) + self.assertFalse(meta.account_path().exists()) + + def test_a_cancel_between_the_save_and_the_cleanup_removes_that_token(self): + # The connect has saved and let go of the lock, but hasn't yet dropped its attempt + # from the in-flight list when the cancel arrives. + FakeMeta(self) + real_status = meta.status + seen = {} + + def cancel_in_the_gap(): + # status() runs after the save; put the attempt back as still listed, as it is + # in the moment before connect's cleanup, and cancel there. + if 'done' not in seen and meta._saved_by[0] == 'attempt-gap-0011': + seen['done'] = True + with meta._lock: + meta._attempts['attempt-gap-0011'] = {'cancelled': False, 'running': 1} + meta.cancel_signin('attempt-gap-0011') + seen['listed'] = 'attempt-gap-0011' in meta._attempts + with meta._lock: + del meta._attempts['attempt-gap-0011'] + return real_status() + with patch.object(meta, 'status', side_effect=cancel_in_the_gap): + meta.connect(TOKEN, attempt='attempt-gap-0011') + self.assertTrue(seen.get('listed')) + self.assertFalse(meta.account_path().exists()) + self.assertFalse(meta.status()['connected']) + + def test_a_cancel_while_still_listed_marks_and_never_touches_another_account(self): + FakeMeta(self) + meta.connect(TOKEN) # a pasted token owns the account + with meta._lock: + meta._attempts['attempt-listed-0012'] = {'cancelled': False, 'running': 1} + try: + meta.cancel_signin('attempt-listed-0012') + self.assertTrue(meta._attempts['attempt-listed-0012']['cancelled']) + finally: + with meta._lock: + meta._attempts.pop('attempt-listed-0012', None) + self.assertTrue(meta.status()['connected']) + + def test_a_cancel_never_removes_a_later_account(self): + FakeMeta(self) + meta.connect(TOKEN, attempt='attempt-first-0006') + meta.connect(TOKEN, attempt='attempt-second-0007') # a later sign-in owns the account now + meta.cancel_signin('attempt-first-0006') + self.assertTrue(meta.status()['connected']) + meta.connect(TOKEN) # a pasted token + meta.cancel_signin('attempt-second-0007') + self.assertTrue(meta.status()['connected']) + meta.disconnect() + meta.connect(TOKEN, attempt='attempt-third-0008') + meta.disconnect() + meta.connect(TOKEN) + meta.cancel_signin('attempt-third-0008') # its account was disconnected already + self.assertTrue(meta.status()['connected']) + + def test_unknown_attempts_change_nothing(self): + FakeMeta(self) + meta.cancel_signin('attempt-unknown-0009') + self.assertTrue(meta.connect(TOKEN, attempt='attempt-unknown-0009')['connected']) + self.assertEqual(meta._attempts, {}) + + def test_a_rejected_token_leaves_no_attempt_behind(self): + FakeMeta(self, token=OTHER_TOKEN) + with self.assertRaises(meta.TokenRejected): + meta.connect(TOKEN, attempt='attempt-rejected-0010') + self.assertEqual(meta._attempts, {}) + + def test_attempt_ids_are_checked(self): + FakeMeta(self) + for bad in ('short', 'x' * 65, 'has space here', 'a/b/c/d/e', 123, ''): + with self.assertRaisesRegex(meta.MetaError, 'attempt'): + meta.connect(TOKEN, attempt=bad) + with self.assertRaisesRegex(meta.MetaError, 'attempt'): + meta.cancel_signin(bad) + with self.assertRaisesRegex(meta.MetaError, 'attempt'): + meta.cancel_signin(None) + self.assertFalse(meta.account_path().exists()) + + +class Redaction(unittest.TestCase): + def setUp(self): + FakeMeta(self) + meta.connect(TOKEN) + self.addCleanup(meta.disconnect) + + def test_every_form_of_the_token(self): + def encoded(text, fmt): + return ''.join(fmt % ord(c) for c in text) + forms = [TOKEN, TOKEN.upper(), TOKEN.lower(), TOKEN.swapcase(), urllib.parse.quote(TOKEN, safe=''), + urllib.parse.quote_plus(TOKEN)] + # Fully percent-encoded, of the token recased too, with either case of hex digits, and mixed. + for text in (TOKEN, TOKEN.lower(), TOKEN.upper(), TOKEN.swapcase()): + forms += [encoded(text, '%%%02X'), encoded(text, '%%%02x')] + forms.append(''.join(c if i % 2 else '%%%02x' % ord(c.lower()) for i, c in enumerate(TOKEN))) + # Percent-encoded twice (%25XX), as an error quoting an encoded URL shows it: recased too. + once = encoded(TOKEN, '%%%02X') + forms += [urllib.parse.quote(once, safe=''), urllib.parse.quote(urllib.parse.quote(TOKEN, safe=''), safe='')] + for text in (TOKEN, TOKEN.lower(), TOKEN.swapcase()): + forms += [encoded(text, '%%25%02X'), encoded(text, '%%25%02x')] + forms.append(''.join(('%%25%02x' if i % 3 == 0 else '%%%02X' if i % 3 == 1 else '%s') % (ord(c) if i % 3 < 2 else c) + for i, c in enumerate(TOKEN.swapcase()))) + for form in forms: + out = meta.redact(f'GET https://example.invalid/x?id=1&tok={form} failed') + self.assertNotIn(form, out) + self.assertIn(meta.REDACTED, out) + plain = urllib.parse.unquote(urllib.parse.unquote(out)).lower() + self.assertNotIn(TOKEN[2:12].lower(), plain) + self.assertNotIn(TOKEN[-10:].lower(), plain) # the whole credential, not just its start + # Any start of it of 8+ characters (as a cut elsewhere leaves), however written. + for n in (8, 12, 20): + for prefix in (TOKEN[:n], TOKEN[:n].lower(), encoded(TOKEN[:n].lower(), '%%%02x'), + encoded(TOKEN[:n].swapcase(), '%%25%02X'), encoded(TOKEN[:n], '%%25%02x')): + self.assertNotIn(prefix, meta.redact(f'cut: {prefix}', token=TOKEN)) + + def test_token_shaped_values_that_are_not_stored(self): + for text in (f'access_token={OTHER_TOKEN}&x=1', f'"access_token": "{OTHER_TOKEN}"', + f'Authorization: Bearer {OTHER_TOKEN}', f'OAuth {OTHER_TOKEN}', f'error near {OTHER_TOKEN} here', + f'ACCESS%5FTOKEN%3D{OTHER_TOKEN}'): + self.assertNotIn(OTHER_TOKEN[3:15], meta.redact(text), text) + self.assertEqual(meta.redact('com.oculus.systemactivities is missing'), 'com.oculus.systemactivities is missing') + self.assertEqual(meta.redact(None), '') + + def test_a_token_sharing_the_known_tokens_start_is_redacted_whole(self): + known, other = 'OCabcdefX123456789012', 'OCabcdefY123456789012' + for text in (other, f'see {other} here', f'{other}{known}', f'{known} {other}'): + out = meta.redact(text, token=known) + self.assertNotIn('123456789012', out, text) + self.assertNotIn('abcdef', out, text) + + def test_a_known_token_with_oc_inside_its_start_is_redacted_whole(self): + known = 'OCaOCabcdef123456789012' + enc = ''.join('%%%02x' % ord(c) for c in known[:3]) + known[3:] + for text in (f'error near {known} here', f'error near %4fCaOCabcdef123456789012 here', + f'error near oCaOCabcdef123456789012 here', f'error near {enc} here', + f'error near {known.swapcase()} here', f'x {known}{known} y'): + out = meta.redact(text, token=known) + self.assertTrue(out.startswith('error near ') or out.startswith('x '), out) + for frag in ('Ca', '4fCa', 'abcdef', '123456789012'): + self.assertNotIn(frag, out.replace('error near', '').replace(meta.REDACTED, ''), (text, out)) + + def test_a_partial_match_before_the_token_cannot_hide_its_start(self): + known = 'OCabcdefOCabcdefg1234567890' + for text in (known[:8].lower() + known.lower(), known[:9] + known, 'xx' + known[:8] + known + 'yy'): + out = meta.redact(text, token=known) + self.assertNotIn('1234567890', out, (text, out)) + self.assertNotIn('g123', out, (text, out)) + import time + t0 = time.monotonic(); meta.redact(('OCabcdef' * 12000)[:100000], token=known) + self.assertLess(time.monotonic() - t0, 2.0) + + def test_redacted_before_truncation(self): + for cut in range(1, len(TOKEN) + 1): + text = 'x' * 50 + TOKEN + ' tail' + out = meta.redact(text, limit=50 + cut) + self.assertLessEqual(len(out), 50 + cut) + self.assertNotIn(TOKEN[:6], out) + self.assertEqual(meta.redact('short', limit=100), 'short') + + +class Hosts(unittest.TestCase): + def setUp(self): + self.fake = FakeMeta(self) + + def test_only_plain_https(self): + for url in ('http://securecdn.oculus.com/x', 'https://user:pw@securecdn.oculus.com/x', + 'https://securecdn.oculus.com:8443/x', 'ftp://securecdn.oculus.com/x'): + with self.assertRaisesRegex(meta.MetaError, 'plain HTTPS'): + meta.check_url(url) + self.assertEqual(meta.check_url('https://scontent.oculuscdn.com/v/1'), 'https://scontent.oculuscdn.com/v/1') + self.assertEqual(self.fake.lookups, []) # the URL check never looks anything up + + def test_public_addresses_only(self): + for ip in ('10.0.0.5', '127.0.0.1', '169.254.169.254', '100.64.0.1', '192.0.0.8', '0.0.0.0', + '224.0.0.1', '::1', '::', 'fd00::1', 'fe80::1', 'ff02::1', 'fec0::1', '64:ff9b::a00:1', + '::ffff:10.0.0.1', '2002:a00:1::1', '2001::1', '2001:db8::1', 'not an ip'): + self.assertFalse(meta.public_address(ip), ip) + for ip in ('157.240.22.35', '8.8.8.8', '2a03:2880:f12f:83:face:b00c:0:25de'): + self.assertTrue(meta.public_address(ip), ip) + self.fake.addresses['cdn.example.com'] = {'157.240.22.35', 'ff02::1'} + with self.assertRaisesRegex(meta.MetaError, 'private or local'): + meta.resolve_public('cdn.example.com') + self.assertEqual(meta.resolve_public('scontent.oculuscdn.com'), [('157.240.22.35', 443)]) + + def test_connects_only_to_the_checked_address(self): + self.fake.addresses['scontent.oculuscdn.com'] = {'157.240.22.35', '157.240.22.36'} + tried = [] + + def connect(host, address, timeout, deadline): + tried.append((host, address, deadline)) + if address[0].endswith('35'): + raise OSError('unreachable') + return 'connection' + with patch.object(meta, '_connect', side_effect=connect): + self.assertEqual(meta._public_connection('scontent.oculuscdn.com', 60), 'connection') + self.assertEqual([a for _, a, _ in tried], [('157.240.22.35', 443), ('157.240.22.36', 443)]) + self.assertEqual(len({d for _, _, d in tried}), 1) # one shared deadline + self.assertEqual(self.fake.lookups, ['scontent.oculuscdn.com']) # resolved once + self.fake.addresses['evil.example'] = {'10.1.1.1'} + with patch.object(meta, '_connect') as never: + with self.assertRaises(meta.MetaError): + meta._public_connection('evil.example', 60) + never.assert_not_called() + + def test_send_uses_the_checked_connection_and_closes_it(self): + calls = [] + + class Conn: + def request(self, method, target, body=None, headers=None): + calls.append((method, target, body, headers)) + + def getresponse(self): + r = Body(b'{}') + r.status, r.headers = 200, {} + return r + + def close(self): + calls.append('closed') + with patch.object(meta, '_public_connection', return_value=Conn()) as connect: + status, _, body = REAL_SEND('https://graph.oculus.com/graphql?x=1', data=b'a=1') + with body: + self.assertEqual(body.read(), b'{}') + self.assertEqual(status, 200) + connect.assert_called_once_with('graph.oculus.com', meta.TIMEOUT) + self.assertEqual(calls[0][:3], ('POST', '/graphql?x=1', b'a=1')) + self.assertEqual(calls[-1], 'closed') + with self.assertRaisesRegex(meta.MetaError, 'plain HTTPS'): + REAL_SEND('http://graph.oculus.com/graphql') + + def test_redirects_off_meta_never_carry_the_token(self): + start = f'https://securecdn.oculus.com/binaries/download/?id=1&access_token={TOKEN}' + self.assertEqual(meta.next_hop(start, 'https://scontent.oculuscdn.com/v/1?sig=x', TOKEN), + 'https://scontent.oculuscdn.com/v/1?sig=x') + same = meta.next_hop(start, f'/binaries/download/?id=2&access_token={TOKEN}', TOKEN) + self.assertIn(TOKEN, same) # still Meta's download host + quoted = urllib.parse.quote(TOKEN, safe='') + lower_hex = ''.join('%%%02x' % ord(c) for c in TOKEN.lower()) + for target in (f'https://cdn.example/v/1?sig={TOKEN}', f'https://cdn.example/{TOKEN}/file', + f'https://cdn.example/v/1#{TOKEN}', f'https://cdn.example/v/1?sig={quoted}', + f'https://cdn.example/{lower_hex}', f'https://cdn.example/v?s={TOKEN.swapcase()}', + f'https://{TOKEN[:12].lower()}.cdn.example/v', f'https://cdn.example/v?x={TOKEN[:9]}', + 'https://cdn.example/v?ACCESS_TOKEN=other'): + with self.assertRaisesRegex(meta.MetaError, 'carrying your token'): + meta.next_hop(start, target, TOKEN) + self.assertEqual(self.fake.lookups, []) # refused before any lookup (a token in a host name) + with self.assertRaisesRegex(meta.MetaError, 'plain HTTPS'): + meta.next_hop(start, 'http://evil.example/steal', TOKEN) + + def test_download_redirect_with_the_token_is_refused_before_connecting(self): + meta.connect(TOKEN) + self.addCleanup(meta.disconnect) + token, generation = meta._session() + work = meta.cache_root() / 'refuse' + work.mkdir(parents=True, exist_ok=True) + self.addCleanup(lambda: __import__('shutil').rmtree(work, ignore_errors=True)) + self.fake.location = 'https://cdn.example/{token}/v/{id}' + with self.assertRaisesRegex(meta.MetaError, 'carrying your token'): + meta.fetch('9002', work / 'x.obb', meta.MAX_DATA_FILE, token, generation) + self.assertFalse([r for r in self.fake.requests if 'cdn.example' in r['url']]) + self.assertEqual(list(work.iterdir()), []) + + def test_download_redirect_sends_no_token_to_the_cdn(self): + meta.connect(TOKEN) + self.addCleanup(meta.disconnect) + token, generation = meta._session() + dest = Path(meta.cache_root()) / 'redirect-test' + dest.mkdir(parents=True, exist_ok=True) + self.addCleanup(lambda: [p.unlink() for p in dest.iterdir()] and dest.rmdir()) + got = meta.fetch('9002', dest / 'x.obb', meta.MAX_DATA_FILE, token, generation, expected=len(OBB)) + self.assertEqual(Path(got['path']).read_bytes(), OBB) + cdn = [r for r in self.fake.requests if 'oculuscdn' in r['url']] + self.assertEqual(len(cdn), 1) + self.assertNotIn(TOKEN, json.dumps(cdn[0])) + self.assertNotIn('access_token', cdn[0]['url'].lower()) + self.assertNotIn('Authorization', cdn[0]['headers']) + + def test_graphql_redirect_not_followed(self): + with patch.object(meta, '_send', return_value=(302, {'Location': 'https://evil.example/'}, Body(b''))): + with self.assertRaisesRegex(meta.MetaError, 'redirected'): + meta.graphql(TOKEN, meta.DOC_VIEWER) + + def test_network_errors_are_redacted(self): + error = OSError(f'connection to https://graph.oculus.com/?access_token={TOKEN} reset') + with patch.object(meta, '_send', side_effect=error): + with self.assertRaises(meta.MetaError) as e: + meta.graphql(TOKEN, meta.DOC_VIEWER) + self.assertNotIn(TOKEN, str(e.exception)) + self.assertIsNone(e.exception.__cause__) + self.assertTrue(e.exception.__suppress_context__) + + +class Library(unittest.TestCase): + def test_owned_quest_apps_with_download_facts(self): + fake = connected(self) + apps = {a['id']: a for a in meta.library()} + self.assertEqual(set(apps), {'111', '222'}) # no Rift app, nothing expired or inactive + game = apps['111'] + self.assertEqual((game['title'], game['package'], game['version'], game['version_code'], game['size']), + ('Quest Game', PKG, '1.0', 100, len(APK))) + self.assertEqual(game['binary_id'], '9001') + self.assertEqual(game['obb'], {'id': '9002', 'name': 'game-data.obb', 'size': len(OBB), 'sha256': None, + 'required': True}) + self.assertEqual([(f['id'], f['required']) for f in game['assets']], [('9003', True), ('9004', False)]) + other = apps['222'] # filled in from the binary query + self.assertEqual((other['package'], other['version_code'], other['size']), ('org.example.another', 7, 10)) + queries = len(fake.requests) + meta.library() + self.assertEqual(len(fake.requests), queries) # kept in memory + public = meta._public(game) + self.assertEqual(public['data_files'], 1) # the OBB; level1.pak isn't a standard OBB name + self.assertEqual(public['skipped_files'], ['level1.pak']) + self.assertNotIn(TOKEN, json.dumps(public)) + meta.disconnect() + with self.assertRaises(meta.MetaError): + meta.library() + + def test_binary_query_when_the_list_doesnt_say_what_data_there_is(self): + fake = connected(self) + binary = fake.entitlements['data']['viewer']['user']['active_entitlements']['nodes'][0]['item'][ + 'latest_supported_binary'] + fake.details['9001'] = {'data': {'node': dict(binary)}} + for key in ('obb_binary', 'asset_files'): + binary.pop(key) + game = next(a for a in meta.library(refresh=True) if a['id'] == '111') + self.assertEqual(game['obb']['id'], '9002') # from the binary query + asked = [json.loads(dict(urllib.parse.parse_qsl(r['data'].decode()))['variables']).get('binaryID') + for r in fake.requests if r['data'] and meta.DOC_BINARY in r['data'].decode()] + self.assertIn('9001', asked) + with patch.object(meta, 'MAX_DETAILS', 0): # beyond the cap: filled in when chosen + game = next(a for a in meta.library(refresh=True) if a['id'] == '111') + self.assertIsNone(game['obb']) + self.assertEqual(meta.owned('111')['obb']['id'], '9002') + + def test_cli(self): + FakeMeta(self) + self.addCleanup(meta.disconnect) + out = io.StringIO() + with patch.object(sys, 'argv', ['frame_meta.py', 'connect', '--token-stdin']), \ + patch.object(sys, 'stdin', io.StringIO(TOKEN + '\n')), patch('sys.stdout', out), \ + patch('sys.stderr', io.StringIO()): + meta.main() + self.assertTrue(json.loads(out.getvalue())['connected']) + out = io.StringIO() + with patch.object(sys, 'argv', ['frame_meta.py', 'list']), patch('sys.stdout', out), \ + patch.object(meta, 'with_verdicts', side_effect=lambda apps: apps): + meta.main() + listed = json.loads(out.getvalue()) + self.assertEqual({a['id'] for a in listed}, {'111', '222'}) + self.assertNotIn(TOKEN, out.getvalue()) + with patch.object(sys, 'argv', ['frame_meta.py', 'connect', TOKEN]): + with self.assertRaises(SystemExit) as e: + meta.main() + self.assertNotIn(TOKEN, str(e.exception.code)) # a token on the command line is refused, not echoed + + def test_only_owned_apps_download(self): + fake = connected(self) + with self.assertRaisesRegex(meta.MetaError, "isn't in your Meta library"): + meta.owned('999') + with self.assertRaisesRegex(meta.MetaError, 'invalid'): + meta.owned('../1') + with self.assertRaisesRegex(meta.MetaError, "isn't in your Meta library"): + meta.download('333') # a Rift app the account owns isn't a Quest app + self.assertEqual(fake.downloads(), []) + + def test_expired_token_while_listing(self): + fake = connected(self) + fake.token = OTHER_TOKEN # Meta signed it out + with self.assertRaises(meta.TokenRejected) as e: + meta.library(refresh=True) + self.assertIn('get a new one', str(e.exception)) + + +class Downloads(unittest.TestCase): + def test_apk_and_obb_into_the_cache(self): + fake = connected(self) + r = meta.download('111') + self.addCleanup(lambda: __import__('shutil').rmtree(r['folder'], ignore_errors=True)) + self.assertEqual(Path(r['apk']).read_bytes(), APK) + self.assertEqual([Path(p).name for p in r['obb']], [f'main.100.{PKG}.obb']) + self.assertEqual(Path(r['obb'][0]).read_bytes(), OBB) + self.assertEqual(r['skipped'], ['level1.pak']) + self.assertTrue(r['folder'].startswith(str(meta.cache_root()))) + ids = [dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(d['url']).query)).get('id') + for d in fake.downloads() if 'securecdn' in d['url']] + self.assertEqual(ids, ['9001', '9002']) # never the optional DLC, nor the non-OBB asset + + def test_declared_size_over_the_limit_refused_before_downloading(self): + fake = connected(self) + token, generation = meta._session() + work = meta.cache_root() / 'limits' + work.mkdir(parents=True, exist_ok=True) + self.addCleanup(lambda: __import__('shutil').rmtree(work, ignore_errors=True)) + before = len(fake.requests) + with self.assertRaisesRegex(meta.MetaError, 'more than Frame Control downloads'): + meta.fetch('9002', work / 'a.obb', 100, token, generation, expected=1000) + self.assertEqual(len(fake.requests), before) + fake.entitlements['data']['viewer']['user']['active_entitlements']['nodes'][0]['item'][ + 'latest_supported_binary']['obb_binary']['size'] = meta.MAX_TOTAL + 1 + meta.library(refresh=True) + with patch.object(meta.shutil, 'disk_usage', return_value=type('U', (), {'free': 10**15})()): + with self.assertRaisesRegex(meta.MetaError, 'more than Frame Control downloads'): + meta.download('111') + with patch.object(meta.shutil, 'disk_usage', return_value=type('U', (), {'free': 10})()): + with self.assertRaisesRegex(meta.MetaError, 'GB free on this computer'): + meta.download('111') + + def test_declared_and_actual_sizes(self): + fake = connected(self) + token, generation = meta._session() + work = meta.cache_root() / 'sizes' + work.mkdir(parents=True, exist_ok=True) + self.addCleanup(lambda: __import__('shutil').rmtree(work, ignore_errors=True)) + with self.assertRaisesRegex(meta.MetaError, 'not the size'): + meta.fetch('9002', work / 'a.obb', 50, token, generation) # Content-Length over the limit + fake.cdn_headers = {'Content-Length': None} # no length given: the limit stops the stream + with self.assertRaisesRegex(meta.MetaError, 'larger than'): + meta.fetch('9002', work / 'a.obb', 50, token, generation) + fake.cdn_headers = {'Content-Length': '5'} + with self.assertRaisesRegex(meta.MetaError, 'not the size Meta said'): + meta.fetch('9002', work / 'b.obb', 10**6, token, generation, expected=len(OBB)) + fake.cdn_headers = {'Content-Length': None} + with self.assertRaisesRegex(meta.MetaError, 'incomplete'): + meta.fetch('9003', work / 'c.obb', 10**6, token, generation, expected=10) + with self.assertRaisesRegex(meta.MetaError, 'checksum'): + meta.fetch('9003', work / 'd.obb', 10**6, token, generation, expected=3, sha256='0' * 64) + self.assertEqual(list(work.iterdir()), []) # nothing left behind, not even .part files + + def test_disconnect_stops_a_download(self): + connected(self) + token, generation = meta._session() + work = meta.cache_root() / 'stop' + work.mkdir(parents=True, exist_ok=True) + self.addCleanup(lambda: __import__('shutil').rmtree(work, ignore_errors=True)) + meta.disconnect() + with self.assertRaisesRegex(meta.MetaError, 'disconnected'): + meta.fetch('9002', work / 'a.obb', 10**6, token, generation) + self.assertEqual(list(work.iterdir()), []) + + def test_one_budget_for_every_file_including_unknown_sizes(self): + # Sizes Meta doesn't give count as they arrive: three files of unknown size that each fit + # the per-file limit still can't add up past the install's budget. + fake = connected(self) + node = fake.entitlements['data']['viewer']['user']['active_entitlements']['nodes'][0]['item'][ + 'latest_supported_binary'] + node.pop('size') + node['obb_binary'].pop('size') + node['obb_binary']['file_name'] = f'main.100.{PKG}.obb' + node['asset_files']['nodes'][0].update(file_name=f'patch.100.{PKG}.obb') + node['asset_files']['nodes'][0].pop('size') + fake.files['9003'] = OBB + fake.cdn_headers = {'Content-Length': None} + meta.library(refresh=True) + budget = len(APK) + len(OBB) + len(OBB) // 2 # the second OBB overflows half way + with patch.object(meta, 'MAX_TOTAL', budget): + with self.assertRaisesRegex(meta.MetaError, 'past the'): + meta.download('111') + self.assertEqual([p for p in meta.cache_root().iterdir() if p.name.startswith(PKG)], []) # all removed + with patch.object(meta, 'MAX_TOTAL', len(APK) + 2 * len(OBB)): + r = meta.download('111') # exactly enough + self.addCleanup(lambda: __import__('shutil').rmtree(r['folder'], ignore_errors=True)) + self.assertEqual(len(r['obb']), 2) + + def test_not_an_apk(self): + connected(self, apk=b'sign in') + with self.assertRaisesRegex(meta.MetaError, 'not an APK'): + meta.install('111') + self.assertEqual(list(meta.cache_root().iterdir()) if meta.cache_root().is_dir() else [], []) + + +class Install(unittest.TestCase): + """The handoff to Import from my Quest's checks, install and two-step game data.""" + META = dict(test_quest.Import.META) + + def setUp(self): + self.order, self.running, self.installed = [], False, None + + def install(path, source=None, **kw): + self.order.append(('install', Path(path).name, source)) + self.assertTrue(android._install_lock._is_owned()) + self.assertEqual(Path(path).read_bytes()[:2], b'PK') + return dict(self.META) + + def add_data(pkg, checked, obb, folder, report, warnings=(), source=''): + self.order.append(('data', [Path(p).name for p in obb], [Path(p).read_bytes() for p in obb])) + self.running = False + return {'message': 'Added the game data.', 'obb': [{'name': Path(p).name} for p in obb], 'warnings': list(warnings), + 'app': dict(self.META), 'needs_game_data': False, 'closed': True, 'split': False} + + def launch(pkg): + self.order.append(('launch',)) + self.running = True + + for target, name, effect in ((android, 'install', install), (android, 'launch', launch), + (android, 'read_meta', lambda pkg: self.installed), + (quest, '_running', lambda pkg: self.running), + (quest, '_sleep', lambda s: None), + (quest, 'add_game_data', add_data)): + p = patch.object(target, name, side_effect=effect) + p.start() + self.addCleanup(p.stop) + + def cache_left(self): + root = meta.cache_root() + return [p for p in root.iterdir() if p.name.startswith(PKG)] if root.is_dir() else [] + + def test_install_then_add_game_data_on_the_second_install(self): + connected(self) + result = meta.install('111') + self.assertEqual(self.order, [('install', 'base.apk', 'Meta Horizon Store')]) + self.assertTrue(result['needs_game_data']) + self.assertIn('choose Install again', result['message']) + self.assertIn('level1.pak', result['message']) # not copied, and said so + self.assertEqual(result['source'], 'meta-store') + self.assertEqual(self.cache_left(), []) # nothing kept after the install + # Second Install with the game open: the OBB, under its standard name, then closed. + self.running, self.installed = True, dict(self.META) + result = meta.install('111') + self.assertEqual(self.order[-1], ('data', [f'main.100.{PKG}.obb'], [OBB])) + self.assertEqual(self.cache_left(), []) + + def test_install_and_test_adds_the_data_itself_then_tests(self): + connected(self) + with patch.object(meta, 'run_test', side_effect=lambda pkg, **kw: self.order.append(('test', pkg)) or + {'message': 'Test: Quest Game runs.', 'outcome': 'runs'}): + result = meta.install('111', test=True) + self.assertEqual([o[0] for o in self.order], ['install', 'launch', 'data', 'test']) + self.assertFalse(result['needs_game_data']) + self.assertEqual(result['test']['outcome'], 'runs') + self.assertIn('Test: Quest Game runs.', result['message']) + + def test_licence_check_installs_with_the_warning(self): + apk = test_quest.apk_bytes(files=dict(test_quest.OPENXR, **{'lib/arm64-v8a/libovrplatformloader.so': b''}), + version_code=100, version_name='1.0') + connected(self, apk=apk) + result = meta.install('111') + self.assertEqual(self.order[0][0], 'install') + self.assertTrue(result['platform_sdk']) + self.assertIn("Uses Meta's licence check", result['message']) + self.assertIn('may quit, hang, or run without online features', result['message']) + + def test_still_refused_32_bit_and_newer_android(self): + connected(self, apk=test_quest.apk_bytes(files={'lib/armeabi-v7a/libgame.so': b''}, version_code=100)) + with self.assertRaisesRegex(quest.QuestError, '32-bit'): + meta.install('111') + meta.disconnect() + connected(self) + real = android.apk_info + with patch.object(android, 'apk_info', side_effect=lambda p: dict(real(p), min_sdk=31)): + with self.assertRaisesRegex(android.FrameError, 'API 31'): + meta.install('111') + self.assertEqual(self.order, []) + self.assertEqual(self.cache_left(), []) + + def test_version_mismatch_and_one_at_a_time(self): + connected(self, apk=test_quest.apk_bytes(files=test_quest.OPENXR, version_code=99)) + with self.assertRaisesRegex(meta.MetaError, 'version code 100, but the APK it sent is 99'): + meta.install('111') + with meta._install_lock: + with self.assertRaisesRegex(meta.MetaError, 'Another My Meta library install'): + meta.install('111') + + +LOGS = { + 'runs': 'I FrameXrCompat: created instance\nI Unity: XR_SESSION_STATE_FOCUSED\n', + 'issues': 'I Unity: XR_SESSION_STATE_FOCUSED\nE Adreno: GL_INVALID_OPERATION in glDrawArrays\n', + 'licence': 'I Unity: ovr_Entitlement_GetIsViewerEntitled\nE Unity: Entitlement check failed, quitting\n', + 'crash': ('E AndroidRuntime: FATAL EXCEPTION: main\n' + 'E AndroidRuntime: java.lang.NullPointerException: ClipboardManager.addPrimaryClipChangedListener\n'), + 'native': 'F libc: Fatal signal 11 (SIGSEGV), code 1 in tid 4242 (UnityMain)\n', + 'anr': 'E ActivityManager: ANR in com.example.questgame\n', + 'extension': 'E OpenXR: xrCreateInstance: XR_ERROR_EXTENSION_NOT_PRESENT XR_FB_passthrough\n', + 'device': 'E Unity: Failed to launch SystemActivities\n', + 'gpu-crash': 'E zink: DEVICE LOST\nF libc: Fatal signal 6 (SIGABRT) in libgallium_dri.so\n', + 'java': ('E AndroidRuntime: FATAL EXCEPTION: main\n' + f'E AndroidRuntime: java.lang.IllegalStateException: bad session {TOKEN} for user 1234\n'), + 'licence-ok': ('I Unity: ovr_Entitlement_GetIsViewerEntitled called\n' + 'I Unity: IsUserEntitledToApplication: true\nI Unity: entitlement check passed\n'), +} +LOGS['extension-session'] = LOGS['extension'] + 'I Unity: XR_SESSION_STATE_FOCUSED\n' +LOGS['licence-ok-session'] = LOGS['licence-ok'] + LOGS['runs'] + + +class Classify(unittest.TestCase): + def test_outcomes_from_logs(self): + cases = [ + ('runs', True, 'runs', 'runs', '', ''), + ('issues', True, 'runs-with-issues', 'runs', 'Graphics errors', ''), + ('licence', False, 'quits', 'crashes', "Meta's licence check failed", ''), + # Open with a failed check but no sign of its VR scene: not enough to call it stuck. + ('licence', True, 'inconclusive', None, "isn't recorded", ''), + ('crash', False, 'crashes', 'crashes', 'clipboard service', ''), + ('native', False, 'crashes', 'crashes', 'native crash signal', 'SIGSEGV'), + ('java', False, 'crashes', 'crashes', 'Java exception', 'java.lang.IllegalStateException'), + ('anr', True, 'hangs', 'crashes', 'stopped responding', ''), + ('extension', True, 'inconclusive', None, 'OpenXR feature', 'XR_FB_passthrough'), + ('extension-session', True, 'runs-with-issues', 'runs', 'OpenXR feature', 'XR_FB_passthrough'), + ('device', False, 'quits', 'crashes', 'Meta Quest', ''), + ('gpu-crash', False, 'crashes', 'crashes', 'Graphics errors', ''), + ('licence-ok', True, 'inconclusive', None, "can't tell whether it runs", ''), + ('licence-ok-session', True, 'runs', 'runs', '', ''), + ] + for log, alive, outcome, result, cause, detail in cases: + v = meta.classify(LOGS[log], alive) + self.assertEqual((v['outcome'], v['result']), (outcome, result), log) + self.assertIn(cause, v['cause'], log) + self.assertEqual(v['detail'], detail, log) + # Causes are fixed texts, never log text. + self.assertTrue(v['cause'] == '' or v['cause'].startswith(tuple(meta.CAUSES.values())), log) + self.assertNotIn('licence', meta.classify(LOGS['licence-ok-session'], True)['signals']['matched']) + quiet = meta.classify('I Unity: loading\n', True) + self.assertEqual((quiet['outcome'], quiet['result']), ('inconclusive', None)) + gone = meta.classify('', False, instance_up=False) + self.assertEqual(gone['outcome'], 'quits') + self.assertIn('stopped before its log could be read', gone['cause']) + never = meta.classify('', False, instance_up=False, started=False) + self.assertEqual((never['outcome'], never['result']), ('instance-failed', 'instance_failed')) + self.assertTrue(meta.classify(LOGS['runs'], True)['signals']['xr_session']) + self.assertEqual(meta.classify(LOGS['licence'], False)['signals']['matched'], ['licence']) + + +class Speed(unittest.TestCase): + """A hostile or broken log can't make classifying or redacting slow: 100 KB adversarial + lines, each step well under 200 ms.""" + LINES = { + 'no .so': 'a' * 100000, + 'dots': 'a.' * 50000, + 'dotted names': ('a.' * 40 + ' ') * 1235, + 'OC-': 'OC-' * 33334, + 'OC run': ' OC' + 'a-' * 50000, + 'zink': 'zink' * 25000, + 'entitle': 'entitle' * 14286, + 'entitlement calls': 'ovr_Entitlement' * 6667, + 'platform': 'libovrplatform.so' * 5883, + 'crash marker': '>>> ' + 'a' * 100000, + 'access_token': 'access_token=' * 7693, + 'percent': '%4f%43' * 16667, + 'words': ('libx.so XR_FB_y java.lang.ZException SIGSEGV ' * 2300)[:100000], + } + + def timed(self, fn, *args): + import time + start = time.perf_counter() + fn(*args) + return time.perf_counter() - start + + def test_adversarial_lines(self): + for name, line in self.LINES.items(): + self.assertTrue(99000 <= len(line) <= 101000, name) + for alive in (True, False): + self.assertLess(self.timed(meta.classify, line + '\nFATAL EXCEPTION\n', alive), 0.2, name) + self.assertLess(self.timed(meta.redact, line, None, TOKEN), 0.2, name) + for key in ('extension', 'library', 'crash-java', 'crash-native'): + self.assertLess(self.timed(meta._excerpt, key, line), 0.2, (name, key)) + + def test_names_are_still_found(self): + self.assertEqual(meta._excerpt('library', 'E linker: dlopen failed: library "libfoo.so" not found'), 'libfoo.so') + self.assertEqual(meta._excerpt('extension', 'XR_ERROR_EXTENSION_NOT_PRESENT: XR_FB_passthrough.'), + 'XR_FB_passthrough') + self.assertEqual(meta._excerpt('crash-java', 'AndroidRuntime: java.lang.IllegalStateException: x'), + 'java.lang.IllegalStateException') + self.assertEqual(meta._excerpt('crash-native', 'Fatal signal 11 (SIGSEGV)'), 'SIGSEGV') + self.assertEqual(meta.redact('error near ' + OTHER_TOKEN + ' here'), 'error near here') + + def test_every_accepted_token_is_redacted(self): + # Property: whatever check_token accepts, the generic pattern redacts in full, wherever + # it appears, without knowing the stored token. + import random + rng = random.Random(20261013) + allowed = ''.join(chr(c) for c in range(33, 127) + if re.fullmatch('[%s]' % meta.TOKEN_CHARS, chr(c))) + self.assertEqual(set(allowed), set('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=_-')) + accepted = 0 + for _ in range(3000): + prefix = rng.choice(('OC', 'FRL')) + body = ''.join(rng.choice(allowed) for _ in range(rng.randint(meta.TOKEN_MIN - len(prefix), 60))) + try: + token = meta.check_token(prefix + body) + except meta.MetaError: + continue + accepted += 1 + for left, right in (('', ''), ('error near ', ' here'), ('library "', '.so" not found'), + ('url?tok=', '&x=1'), ('(', ')'), ('', '_FLAG'), ('access_token=', '')): + self.assertGone(token, left, right) + # Random neighbours from all of printable ASCII, on either side. + for _ in range(5): + self.assertGone(token, ''.join(rng.choice(PRINTABLE) for _ in range(rng.randint(1, 3))), + ''.join(rng.choice(PRINTABLE) for _ in range(rng.randint(1, 3)))) + self.assertEqual(meta._excerpt('library', f'dlopen failed: library "{token}.so" not found'), '') + self.assertGreater(accepted, 2000) + + def assertGone(self, token, left, right): + """redact(left + token + right) is some start of left, one , and some end of + right: every character of the token is gone, whatever surrounds it.""" + out = meta.redact(left + token + right) + at = out.find(meta.REDACTED) + self.assertGreaterEqual(at, 0, (left, token, right)) + before, after = out[:at], out[at + len(meta.REDACTED):] + self.assertTrue(left.startswith(before) and right.endswith(after), (left, token, right, out)) + + def test_round6_gaps(self): + # 1. All upper case is refused, so an "OC..._FLAG" constant can't be an accepted token. + for bad in ('OCABCDEFGHIJKLMNOPQR', 'OCABCDEFGHIJ_KLMNOPQRS', 'FRL0123456789ABCDEFGHIJ'): + with self.assertRaisesRegex(meta.MetaError, "doesn't look like a Meta token"): + meta.check_token(bad) + self.assertEqual(meta.redact('OCABCDEFGHIJKLMNOPQR_FLAG'), 'OCABCDEFGHIJKLMNOPQR_FLAG') # a name, kept + self.assertEqual(meta.redact('OCAbcdefGhijkLmnopQr_FLAG'), meta.REDACTED) + # 2. Found after any character, word characters included. + for left in ('https://example.invalid/_', 'x-', '=', '/', 'lib', '9', 'Z'): + self.assertEqual(meta.redact(left + 'OCAbcdefGhijkLmnopQr'), left + meta.REDACTED, left) + # 3. Whole-token redaction comes first, so no prefix survives a parameter match. + meta.check_token('OCaccess_token=123456') + self.assertEqual(meta.redact('OCaccess_token=123456'), meta.REDACTED) + self.assertEqual(meta.redact('x access_token=abc&y=1'), f'x access_token={meta.REDACTED}&y=1') + # Anything with other characters is refused at connect, with a clear message. + for bad in ('OCAbcdef.GhijkLmnopQrstUvwx', 'OCAbcdefGhijk~LmnopQrstUvwx', 'FRLAbcdef!GhijkLmnopQrstU', + 'OCAbcdefGhijk#LmnopQrstUvwx', 'OCABCDEFGHIJ_KLMNOPQRS_TUV'): + with self.assertRaisesRegex(meta.MetaError, "doesn't look like a Meta token"): + meta.check_token(bad) + + def test_dotted_value_regression(self): + # Accepted before (dots allowed) but not redacted, so it showed as a library name. + line = 'E linker: dlopen failed: library "OCAbcdef.GhijkLmnopQrstUvwx.so" not found' + with self.assertRaises(meta.MetaError): + meta.check_token('OCAbcdef.GhijkLmnopQrstUvwx') + # It can't be a connected token now; as a stored token it would be redacted by _clean too. + self.assertNotIn('OCAbcdef', meta.redact(line, token='OCAbcdef.GhijkLmnopQrstUvwx')) + + def test_digit_free_token_shapes_are_redacted(self): + # check_token accepts tokens without digits, so the generic pattern must not need one. + shape = 'OCAbcdefGhijkLmnopQrstUvwx' + meta.check_token(shape) + meta.check_token('FRLAbcdefGhijkLmnopQrs') + meta.check_token('OCAbcdefGhijkLmnopQr') # 20 characters: the shortest accepted + with self.assertRaisesRegex(meta.MetaError, 'incomplete'): + meta.check_token('OCAbcdefGhijkLmnopQ') # 19: refused, so never needs redacting + for text in (shape, 'FRLAbcdefGhijkLmnopQrs', 'OC-' + 'x' * 20, 'OCabcdefghijklmnopqr', 'OCAbcdefGhijkLmnopQr'): + out = meta.redact(f'error near {text} here') + self.assertEqual(out, 'error near here', text) + line = f'E linker: dlopen failed: library "{shape}.so" not found' + self.assertEqual(meta._excerpt('library', line), '') + v = meta.classify(line + '\n', False) + self.assertEqual((v['cause_key'], v['detail']), ('library', '')) + self.assertNotIn(shape[:10], json.dumps(v)) + # Ordinary identifiers stay: upper-case constants, short words, package names, too-short runs. + for text in ('OCULUS_PLATFORM_SDK_VERSION_NAME', 'OCULUS_EXT_HAND_TRACKING_2', 'OCR', 'OCULUS', + 'com.oculus.systemactivities', 'OCAbcdefGhijkLmnopQ', 'libOculusPlatform.so'): + self.assertEqual(meta.redact(f'error near {text} here'), f'error near {text} here', text) + self.assertEqual(meta._excerpt('library', 'dlopen failed: library "libOculusPlatformLoader.so" not found'), + 'libOculusPlatformLoader.so') + long_line = 'x' * (meta.LINE_SCAN + 10) + ' Entitlement check failed' + self.assertNotIn('licence', meta.classify(long_line, False)['signals']['matched']) # only the start is scanned + + +class RunTest(unittest.TestCase): + META = dict(test_quest.Import.META) + + def setUp(self): + self.reports, self.alive, self.log, self.running = [], True, LOGS['runs'], False + ctr = f"lepton-steamlaunch-{self.META['instance']}" + + def ssh(cmd, input=None, timeout=120): + if 'pidof' in cmd: + return '4242\n' if self.alive else '' + if 'logcat' in cmd: + return self.log + raise AssertionError(cmd) + + def launch(pkg): + self.running = True + for target, name, effect in ((android, '_meta_or_fail', lambda pkg: dict(self.META)), + (android, 'launch', launch), (android, 'ssh', ssh), + (android, 'running_instances', lambda strict=False: {ctr: '5555'} if self.running else {}), + (quest, '_running', lambda pkg: self.running), + (quest, '_close', lambda pkg: (setattr(self, 'running', False), (True, None))[1]), + (quest, '_sleep', lambda s: None)): + p = patch.object(target, name, side_effect=effect) + p.start() + self.addCleanup(p.stop) + import frame_catalog + p = patch.object(frame_catalog, 'add_report', side_effect=lambda *a, **kw: self.reports.append((a, kw)) or + {'id': 'r1'}) + p.start() + self.addCleanup(p.stop) + + def test_runs_and_report_carries_results_only(self): + connected(self) + self.log = LOGS['runs'] + f'I Game: debug url https://graph.oculus.com/?access_token={TOKEN}\n' + v = meta.run_test(PKG, wait=20) + self.assertEqual(v['outcome'], 'runs') + self.assertEqual(v['signals']['seconds'], 20) + (args, kwargs), = self.reports + self.assertEqual(args, (PKG, '1.0')) + self.assertEqual(kwargs['source'], 'meta-store') + self.assertEqual(kwargs['result'], 'runs') + self.assertEqual(kwargs['via'], 'probe') + self.assertIn('version code 100', kwargs['notes']) + self.assertEqual(set(kwargs), {'result', 'notes', 'via', 'label', 'source'}) + payload = json.dumps([args, kwargs]) + self.assertNotIn(TOKEN, payload) + self.assertNotIn(str(meta.cache_root()), payload) + self.assertNotIn('.apk', payload) + self.assertNotIn('.obb', payload) + + def test_quit_with_licence_check_is_reported_as_it_happened(self): + connected(self) + self.alive, self.log = False, LOGS['licence'] + v = meta.run_test(PKG, wait=30) + self.assertEqual((v['outcome'], v['result']), ('quits', 'crashes')) + self.assertIn("Meta's licence check failed", v['message']) + self.assertEqual(self.reports[0][1]['result'], 'crashes') + self.assertIn('Meta library test: quits', self.reports[0][1]['notes']) + + def test_instance_never_starts(self): + connected(self) + with patch.object(android, 'launch'): # nothing comes up + v = meta.run_test(PKG, wait=5) + self.assertEqual(v['outcome'], 'instance-failed') + self.assertEqual(self.reports[0][1]['result'], 'instance_failed') + + def test_hang_is_closed_and_no_report_when_asked(self): + connected(self) + self.log = LOGS['anr'] + v = meta.run_test(PKG, wait=5, report_result=False) + self.assertEqual(v['outcome'], 'hangs') + self.assertFalse(self.running) + self.assertEqual(self.reports, []) + + def test_crash_text_never_reaches_reports_or_messages(self): + connected(self) + self.alive, self.log = False, LOGS['java'] + v = meta.run_test(PKG, wait=10) + self.assertEqual((v['outcome'], v['cause_key']), ('crashes', 'crash-java')) + self.assertIn('(java.lang.IllegalStateException)', v['message']) + (args, kwargs), = self.reports + everything = json.dumps([v, args, kwargs]) + for fragment in (TOKEN, TOKEN[:8], 'bad session', 'user 1234'): + self.assertNotIn(fragment, everything) + self.assertNotIn('IllegalStateException', kwargs['notes']) # reports: fixed texts only + self.assertIn(meta.CAUSES['crash-java'].rstrip('.'), kwargs['notes']) + + def test_long_token_in_a_library_name_is_never_shown(self): + connected(self) + long_token = TOKEN + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8S9t0U1v2W3x4Y5z6' + meta.disconnect() + fake = FakeMeta(self, token=long_token) + meta.connect(long_token) + self.alive = False + for line in (f'E linker: dlopen failed: library "{long_token}.so" not found', + f'E linker: dlopen failed: library "lib{long_token[-40:]}.so" not found', + 'E linker: dlopen failed: library "libZq9Xw3Rt7Yu1Io5Pa8Sd2Fg6.so" not found'): + self.log = line + '\n' + self.reports.clear() + v = meta.run_test(PKG, wait=5) + self.assertEqual(v['cause_key'], 'library') + self.assertEqual(v['detail'], '', line) + everything = json.dumps([v, self.reports]) + for fragment in (long_token[-30:], long_token[-60:-30], long_token[:8], 'Zq9Xw3Rt7Yu1Io5Pa8Sd2Fg6'): + self.assertNotIn(fragment, everything) + self.log = 'E linker: dlopen failed: library "libfoo_engine.so" not found\n' + self.assertEqual(meta.run_test(PKG, wait=5)['detail'], 'libfoo_engine.so') # real names still shown + self.assertEqual(fake.token, long_token) + + def test_log_read_failure_never_shows_the_ssh_output(self): + # A failed ssh command with empty stderr: frame_android.ssh raises its stdout, which + # here is the game's log, carrying a token. Neither the test, the CLI nor the job says it. + import subprocess + import server + connected(self) + leaked = f'I Game: url https://graph.oculus.com/?x=1&tok={TOKEN[5:]} {OTHER_TOKEN[3:]}\n' * 3 + + def run_ssh(cmd, **kw): + remote = cmd[-1] + if 'logcat' in remote: + return subprocess.CompletedProcess(cmd, 1, stdout=leaked, stderr='') + return subprocess.CompletedProcess(cmd, 0, stdout='4242\n' if 'pidof' in remote else '', stderr='') + with patch.object(android, 'ssh', REAL_SSH), patch.object(android.frame_host, 'run_ssh', side_effect=run_ssh): + with self.assertRaises(meta.MetaError) as e: + meta.run_test(PKG, wait=5) + self.assertEqual(str(e.exception), meta.LOG_UNREADABLE) + self.assertIsNone(e.exception.__cause__) + self.assertTrue(e.exception.__suppress_context__) + # The standalone Test route (a background job). + with patch.object(server, 'ensure_master'), patch.object(server, 'start_job', + return_value={'job': 'j'}) as job: + server.meta({'action': 'test', 'package': PKG}) + with self.assertRaises(meta.MetaError) as e: + job.call_args.args[1]() + self.assertEqual(str(e.exception), meta.LOG_UNREADABLE) + # The CLI. + with patch.object(sys, 'argv', ['frame_meta.py', 'test', PKG, '--wait', '5']): + with self.assertRaises(SystemExit) as cli: + meta.main() + self.assertEqual(cli.exception.code, 'error: ' + meta.LOG_UNREADABLE) + for fragment in (TOKEN[5:20], OTHER_TOKEN[3:15]): + self.assertNotIn(fragment, str(e.exception) + str(cli.exception.code)) + self.assertEqual(self.reports, []) + # Anything else that goes wrong in a test job is a fixed text too. + with patch.object(server, 'ensure_master'), patch.object(server, 'start_job', return_value={'job': 'j'}) as job: + server.meta({'action': 'test', 'package': PKG}) + with patch.object(meta, 'run_test', side_effect=KeyError(TOKEN)): + with self.assertRaises(meta.MetaError) as e: + job.call_args.args[1]() + self.assertEqual(str(e.exception), 'The test stopped unexpectedly (KeyError); try again.') + + def test_inconclusive_is_not_reported_or_closed(self): + connected(self) + self.log = 'I Unity: ovr_Entitlement_GetIsViewerEntitled called\nI Unity: loading\n' + v = meta.run_test(PKG, wait=10) + self.assertEqual((v['outcome'], v['result']), ('inconclusive', None)) + self.assertEqual(self.reports, []) + self.assertIn('skipped', v['report']) + self.assertTrue(self.running) # left open: nothing says it is stuck + + def test_must_close_a_running_game_before_testing(self): + connected(self) + self.running = True + launched = [] + with patch.object(quest, '_close', return_value=(False, 'it is still running')), \ + patch.object(android, 'launch', side_effect=launched.append): + with self.assertRaisesRegex(meta.MetaError, "couldn't close it, so it wasn't tested.*Close it from Steam"): + meta.run_test(PKG, wait=5) + self.assertEqual(launched, []) + self.assertEqual(self.reports, []) + + def test_failed_hang_cleanup_is_said(self): + connected(self) + self.log = LOGS['anr'] + with patch.object(quest, '_close', side_effect=[(False, 'Steam is not answering')]): + v = meta.run_test(PKG, wait=5) + self.assertEqual(v['outcome'], 'hangs') + self.assertFalse(v['closed']) + self.assertIn('the game may still be running; close it from Steam', v['message']) + self.assertEqual(self.reports[0][1]['result'], 'crashes') + + def test_sharing_follows_the_privacy_setting(self): + sent = [] + with patch.object(frame_telemetry, 'enabled', return_value=True), \ + patch.object(frame_telemetry, 'capture', side_effect=lambda e, p, level: sent.append(p) or True): + frame_telemetry.compat_report({'id': 'x', 'package': PKG, 'result': 'runs', 'source': 'meta-store', + 'notes': f'access_token={TOKEN}'}) + self.assertEqual(sent[0]['source'], 'meta-store') + with patch.object(frame_telemetry, 'enabled', return_value=False), \ + patch.object(frame_telemetry, 'capture') as capture: + self.assertFalse(frame_telemetry.compat_report({'id': 'y', 'package': PKG, 'result': 'runs'})) + capture.assert_not_called() + + +class Server(unittest.TestCase): + @classmethod + def setUpClass(cls): + import server + cls.server = server + + def test_requests_and_answers_never_carry_the_token(self): + s = self.server + FakeMeta(self) + self.addCleanup(meta.disconnect) + for body in ({'action': 'connect', 'token': TOKEN}, {'action': 'connect', 'acknowledged': True}, + {'action': 'install', 'app': 'abc'}, {'action': 'install', 'app': '111', 'test': 'yes'}, + {'action': 'test', 'package': '../x'}, {'action': 'nope'}): + with self.assertRaises(s.Failure) as e: + s.meta(body) + self.assertEqual(e.exception.status, 400, body) + result = s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True}) + self.assertTrue(result['connected']) + status = s.meta_status('library=1') + self.assertEqual({a['id'] for a in status['apps']}, {'111', '222'}) + self.assertIn('sharing', status) + self.assertNotIn(TOKEN, json.dumps([result, status])) + with patch.object(s, 'ensure_master'), patch.object(s, 'queue_job', return_value={'job': 'j'}) as job: + self.assertEqual(s.meta({'action': 'install', 'app': '111', 'test': True}), {'job': 'j'}) + work = job.call_args.args[1] + with patch.object(meta, 'install', side_effect=KeyError(f'access_token={TOKEN}')): + with self.assertRaises(meta.MetaError) as e: + work(lambda *a: None) + self.assertNotIn(TOKEN, str(e.exception)) + with self.assertRaises(s.Failure) as e: + s.meta({'action': 'connect', 'token': OTHER_TOKEN, 'acknowledged': True}) + self.assertNotIn(OTHER_TOKEN, str(e.exception)) + self.assertEqual(e.exception.status, 400) + self.assertFalse(s.meta({'action': 'disconnect'})['connected']) + + def test_sign_in_attempts_through_the_server(self): + s = self.server + FakeMeta(self) + self.addCleanup(meta.disconnect) + for body in ({'action': 'cancel-signin'}, {'action': 'cancel-signin', 'attempt': 'no'}, + {'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 5}, + {'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': '../../x'}): + with self.assertRaises(s.Failure) as e: + s.meta(body) + self.assertEqual(e.exception.status, 400, body) + self.assertTrue(s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, + 'attempt': 'server-attempt-0001'})['connected']) + result = s.meta({'action': 'cancel-signin', 'attempt': 'server-attempt-0001'}) + self.assertFalse(result['connected']) + self.assertNotIn(TOKEN, json.dumps(result)) + # A cancel caught while Meta answers is a 409, without the token. + with patch.object(meta, 'whoami', side_effect=lambda t: meta.cancel_signin('server-attempt-0002') or + {'id': '1', 'name': 'player_one'}): + with self.assertRaises(s.Failure) as e: + s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 'server-attempt-0002'}) + self.assertEqual(e.exception.status, 409) + self.assertNotIn(TOKEN, str(e.exception)) + self.assertFalse(meta.status()['connected']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_mods.py b/tests/test_mods.py new file mode 100644 index 0000000..fc76fec --- /dev/null +++ b/tests/test_mods.py @@ -0,0 +1,337 @@ +"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk. + +Run: python3 -m unittest discover -s tests +""" +import hashlib +import io +import itertools +import json +import stat +import subprocess +import sys +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_mods # noqa: E402 +import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice) + +APPID = 1067310 + + +def zip_bytes(files, links=()): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as z: + for name, data in files.items(): + z.writestr(name, data) + for name in links: + info = zipfile.ZipInfo(name) + info.external_attr = (stat.S_IFLNK | 0o777) << 16 + z.writestr(info, "/etc/passwd") + return buf.getvalue() + + +@unittest.skipIf(sys.platform == "win32", "the helper runs on the Frame (Linux): /proc, flock and POSIX prefix paths") +class FakeLibrary(unittest.TestCase): + """A Steam library with Gravitas's real layout, and pinned downloads served from memory.""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + home = Path(tmp.name) + self.steam = home / ".local/share/Steam" + apps = self.steam / "steamapps" + (apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True) + (apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100) + (apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ") + self.prefix = apps / f"compatdata/{APPID}/pfx" + for sub in ("Roaming", "Local"): + (self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True) + (apps / f"appmanifest_{APPID}.acf").write_text( + f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n') + for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"), + ("RECEIPTS", home / ".local/share/frame-control/mods")): + p = mock.patch.object(frame_mods, name, value) + p.start() + self.addCleanup(p.stop) + self.running = None + p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running) + p.start() + self.addCleanup(p.stop) + self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ", + "openvr_api.dll": b"MZ"})} + self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"}) + self.pin(self.downloads) + + def pin(self, downloads): + """Serve these bytes and pin their real hashes in place of the published ones.""" + for name in ("UEVR", "PYTHON"): + pinned = getattr(frame_mods, name) + p = mock.patch.object(frame_mods, name, + dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest())) + p.start() + self.addCleanup(p.stop) + p = mock.patch.object(frame_mods.urllib.request, "urlopen", + side_effect=lambda url, timeout: io.BytesIO(downloads[url])) + self.urlopen = p.start() + self.addCleanup(p.stop) + + def managed(self): + return self.prefix / "drive_c/frame-control" + + +class Status(FakeLibrary): + def test_unreal_game_is_eligible_and_not_installed(self): + st = frame_mods.status(APPID) + self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]), + (True, "Drop-Win64-Shipping.exe", False, False, True)) + + def test_game_without_unreal_binary_is_refused(self): + (self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink() + self.assertFalse(frame_mods.status(APPID)["eligible"]) + with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"): + frame_mods.install(APPID) + self.urlopen.assert_not_called() + + def test_uninstalled_game(self): + with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"): + frame_mods.status(620) + + +class InstallUninstall(FakeLibrary): + def test_install_then_uninstall_leaves_prefix_as_found(self): + before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*")) + res = frame_mods.install(APPID) + self.assertTrue(res["installed"]) + self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file()) + self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file()) + self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text()) + self.assertFalse(list(self.managed().glob(".staging-*"))) + # What UEVR writes on its first injection. + roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping" + roaming.mkdir(parents=True) + (roaming / "log.txt").write_text("x") + (self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True) + frame_mods.uninstall(APPID) + after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*")) + self.assertEqual(after, before) + self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them") + self.assertIsNone(frame_mods.read_receipt(APPID)) + + def test_existing_uevr_settings_survive_uninstall(self): + profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping" + profile.mkdir(parents=True) + (profile / "config.txt").write_text("mine") + frame_mods.install(APPID) + frame_mods.uninstall(APPID) + self.assertEqual((profile / "config.txt").read_text(), "mine") + + def test_second_install_is_a_no_op(self): + frame_mods.install(APPID) + calls = self.urlopen.call_count + self.assertIn("already installed", frame_mods.install(APPID)["message"]) + self.assertEqual(self.urlopen.call_count, calls) + + def test_refuses_while_game_runs(self): + self.running = 4242 + with self.assertRaisesRegex(frame_mods.Fail, "running"): + frame_mods.install(APPID) + self.running = None + frame_mods.install(APPID) + self.running = 4242 + with self.assertRaisesRegex(frame_mods.Fail, "running"): + frame_mods.uninstall(APPID) + self.assertTrue((self.managed() / "uevr-1.05").is_dir()) + + def test_needs_a_prefix(self): + import shutil + shutil.rmtree(self.prefix.parent) + with self.assertRaisesRegex(frame_mods.Fail, "play it once"): + frame_mods.install(APPID) + + def test_receipt_cannot_point_outside_the_prefix(self): + frame_mods.install(APPID) + r = frame_mods.read_receipt(APPID) + victim = self.steam / "steamapps/common/Gravitas" + r["remove"].append(str(self.prefix / "../../../common/Gravitas")) + frame_mods.receipt_path(APPID).write_text(json.dumps(r)) + with self.assertRaisesRegex(frame_mods.Fail, "didn't create"): + frame_mods.uninstall(APPID) + self.assertTrue(victim.is_dir()) + self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad") + + + def test_receipt_cannot_delete_other_things_in_the_prefix(self): + frame_mods.install(APPID) + r = frame_mods.read_receipt(APPID) + users = self.prefix / "drive_c/users" + r["remove"].append(str(users)) + frame_mods.receipt_path(APPID).write_text(json.dumps(r)) + with self.assertRaisesRegex(frame_mods.Fail, "didn't create"): + frame_mods.uninstall(APPID) + self.assertTrue(users.is_dir()) + + def test_failed_removal_keeps_the_receipt(self): + frame_mods.install(APPID) + with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed + with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"): + frame_mods.uninstall(APPID) + self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again") + frame_mods.uninstall(APPID) + self.assertFalse(self.managed().exists()) + + def test_receipt_cannot_name_another_prefix(self): + frame_mods.install(APPID) + r = frame_mods.read_receipt(APPID) + r["prefix"] = "/" + frame_mods.receipt_path(APPID).write_text(json.dumps(r)) + with self.assertRaisesRegex(frame_mods.Fail, "isn't app"): + frame_mods.uninstall(APPID) + self.assertTrue((self.managed() / "uevr-1.05").is_dir()) + + +class Downloads(FakeLibrary): + def test_hash_mismatch_deletes_and_installs_nothing(self): + self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"}) + with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"): + frame_mods.install(APPID) + self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists()) + self.assertFalse(self.managed().exists()) + self.assertIsNone(frame_mods.read_receipt(APPID)) + + def test_archive_escaping_its_folder_is_refused(self): + for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}), + zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])): + downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad}) + self.pin(downloads) + with self.subTest(), self.assertRaises(frame_mods.Fail): + frame_mods.install(APPID) + self.assertFalse((self.prefix / "drive_c/evil.dll").exists()) + self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else []) + import shutil + shutil.rmtree(frame_mods.CACHE, ignore_errors=True) + + def test_oversized_archive_is_refused(self): + with mock.patch.object(frame_mods, "MAX_UNPACKED", 3): + with self.assertRaisesRegex(frame_mods.Fail, "more than expected"): + frame_mods.install(APPID) + + + def test_oversized_download_is_stopped(self): + with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10): + with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"): + frame_mods.install(APPID) + self.assertFalse(list(frame_mods.CACHE.glob("*"))) + + +class Actions(FakeLibrary): + def test_one_change_at_a_time(self): + import fcntl + lock = Path(tempfile.mkdtemp()) / "mods.lock" + with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held: + fcntl.flock(held, fcntl.LOCK_EX) + with self.assertRaisesRegex(frame_mods.Fail, "another mod action"): + frame_mods.main(["install", str(APPID)]) + self.assertFalse(frame_mods.status(APPID)["installed"]) + self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits") + + def test_zero_frames_is_an_answer(self): + stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}]) + with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)): + self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0) + with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')): + self.assertIsNone(frame_mods.frame_submits(APPID)) + + +class Start(FakeLibrary): + def environ(self, text): + """The game's /proc//environ; every other file reads normally.""" + real = frame_mods.Path.read_text + return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k: + text if str(path).startswith("/proc/") else real(path, *a, **k)) + + def test_needs_install_first(self): + with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"): + frame_mods.start(APPID) + + def run_start(self, reply, loaded=(False, True, True, True)): + frame_mods.install(APPID) + self.running = 4242 + env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0" + with self.environ(env), \ + mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \ + mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \ + mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \ + mock.patch.object(frame_mods, "frame_submits", return_value=7), \ + mock.patch.object(frame_mods.time, "sleep"): + return frame_mods.start(APPID), run + + def test_injects_with_the_games_wine_settings(self): + res, run = self.run_start('noise\n{"pid": 320}\n') + self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames")) + cmd, env = run.call_args.args[0], run.call_args.kwargs["env"] + self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"]) + self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05", + "C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"]) + self.assertEqual(env["WINEPREFIX"], "/x") + self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process") + self.assertIn("HOME", env, "fontconfig needs a HOME for its cache") + + def test_reports_the_injection_error(self): + with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"): + self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n') + + def test_success_is_checked_in_the_game(self): + with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"): + with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)): + self.run_start('{"pid": 320}\n', loaded=[False] * 40) + + def test_inject_script_is_valid_python(self): + compile(frame_mods.INJECT_PY, "frame_inject.py", "exec") + + +class Helper(unittest.TestCase): + @unittest.skipIf(sys.platform == "win32", "uninstall takes the flock lock, which only exists on the Frame (Linux)") + def test_unexpected_errors_are_still_json(self): + # A receipt from another version, without "exe": a KeyError, not a traceback. + script = (ROOT / "ui" / "frame_mods.py").read_text().replace( + "def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):") + out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script, + capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()}) + self.assertEqual(out.returncode, 1, out.stderr) + self.assertIn("KeyError", json.loads(out.stdout)["error"]) + + def test_bad_usage_prints_json_error(self): + for args in ([], ["install"], ["install", "12x"], ["remove", "620"]): + out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args], + capture_output=True, text=True, timeout=30) + self.assertEqual(out.returncode, 1, args) + self.assertIn("error", json.loads(out.stdout), args) + + +class ModRoutes(test_server.ServerGuards): + """Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH.""" + + def test_mod_input_validation(self): + for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""}, + {"action": "delete", "appid": "1067310"}, {"appid": "1067310"}): + status, payload = self.post("/api/mods", body) + self.assertEqual(status, 400, f"{body} -> {payload}") + + def test_needs_custom_header(self): + self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403) + + def test_runs_the_mods_helper_with_its_timeout(self): + import server + with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run: + self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1}) + run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py") + + +for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]: + setattr(ModRoutes, name, None) diff --git a/tests/test_page_scripts.py b/tests/test_page_scripts.py new file mode 100644 index 0000000..8ce35bd --- /dev/null +++ b/tests/test_page_scripts.py @@ -0,0 +1,141 @@ +"""The page's inline scripts share one global scope, so a second top-level function or +variable with a name already used replaces the first everywhere, silently.""" +import html.parser +import json +import pathlib +import shutil +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] + +# Compiles the scripts as one strict-mode block. There, functions are block scoped like let +# and const, so V8 itself rejects a name declared twice in the shared scope (however it's +# indented or declared: function, class, let, const, destructuring), while helpers with the +# same name inside different functions stay legal. var is the exception (declaring one twice +# is allowed), so the page doesn't use var at all: Node's own copy of the acorn parser finds +# real var declarations, not the word in comments, strings or CSS var(). +DUPLICATES = r''' +const vm = require('vm'); +const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8')); +try { new vm.Script('"use strict"; {\n' + scripts.join('\n;\n') + '\n}'); console.log('ok'); } +catch (e) { console.log(e.message); } +''' +VARS = r''' +const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8')); +let acorn, walk; +try { + acorn = require('internal/deps/acorn/acorn/dist/acorn'); + walk = require('internal/deps/acorn/acorn-walk/dist/walk'); +} catch { console.log('no parser'); process.exit(0); } +for (const code of scripts) { + let found = null; + try { + walk.simple(acorn.parse(code, { ecmaVersion: 'latest' }), { + VariableDeclaration(n) { if (n.kind === 'var' && !found) found = code.slice(n.start, n.end).slice(0, 40); }, + }); + } catch (e) { console.log(e.message); process.exit(0); } + if (found) { console.log('declares with var: ' + found); process.exit(0); } +} +console.log('ok'); +''' + + +def node(script, scripts, *flags): + r = subprocess.run(['node', *flags, '-e', script], input=json.dumps(scripts), capture_output=True, text=True) + return r.stdout.strip() or r.stderr.strip() + + +def check(scripts): + """'ok', or V8's message for the first name declared twice.""" + return node(DUPLICATES, scripts) + + +def check_vars(scripts): + """'ok', or the first var declaration. Skips where Node doesn't include acorn.""" + out = node(VARS, scripts, '--expose-internals') + if out == 'no parser': + raise unittest.SkipTest("this Node doesn't include acorn") + return out + + +class ClassicScripts(html.parser.HTMLParser): + """The page's inline classic scripts: no src, and no type other than a JavaScript one (a + module has its own scope, and JSON data isn't code). The types a browser runs as script: + https://mimesniff.spec.whatwg.org/#javascript-mime-type""" + JS = {'', 'application/ecmascript', 'application/javascript', 'application/x-ecmascript', + 'application/x-javascript', 'text/ecmascript', 'text/javascript', 'text/javascript1.0', + 'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4', + 'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript'} + + def __init__(self): + super().__init__() + self.scripts, self.current = [], None + + def handle_starttag(self, tag, attrs): + if tag == 'script': + a = dict(attrs) + inline = 'src' not in a and (a.get('type') or '').strip().lower() in self.JS + self.current = [] if inline else None + + def handle_data(self, data): + if self.current is not None: + self.current.append(data) + + def handle_endtag(self, tag): + if tag == 'script' and self.current is not None: + self.scripts.append(''.join(self.current)) + if tag == 'script': + self.current = None + + +def classic_scripts(page): + p = ClassicScripts() + p.feed(page) + return p.scripts + + +@unittest.skipUnless(shutil.which('node'), 'Node parses the page scripts') +class PageScripts(unittest.TestCase): + def test_no_top_level_name_is_declared_twice(self): + scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8')) + self.assertGreaterEqual(len(scripts), 2) + self.assertEqual(check(scripts), 'ok') + + def test_the_page_declares_nothing_with_var(self): + scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8')) + self.assertEqual(check_vars(scripts), 'ok') + + def test_the_check_finds_what_it_should(self): + twice = { + 'indented function': ['function loadPanels() {}', ' async function loadPanels() {}'], + 'class': ['class Panel {}', 'class Panel {}'], + 'destructured': ['const { a, b } = {};', 'let [b] = [];'], + 'later declarator': ['let x = 1;', 'const y = 2, x = 3;'], + 'function and const': ['function f() {}', 'const f = 1;'], + } + for what, scripts in twice.items(): + with self.subTest(what): + self.assertIn('already been declared', check(scripts)) + helpers = ['function a() { function help() {} }', 'function b() { const help = 1; }'] + self.assertEqual(check(helpers), 'ok') + + def test_the_var_check_finds_what_it_should(self): + # var: legal to declare twice, so not allowed at all; however it's written. + for code in ['var x = 1;', 'var/*c*/x = 1;', 'var {x} = {x: 1};', 'function f() { var y; }']: + with self.subTest(code): + self.assertIn('declares with var', check_vars([code])) + # The word var elsewhere is fine. + text = ['// var x\nconst a = "var y", b = `var ${a}`, c = "color: var(--blue)", d = {}.var;'] + self.assertEqual(check_vars(text), 'ok') + + def test_only_inline_classic_scripts_are_checked(self): + page = ('' + '' + '' + '') + self.assertEqual(classic_scripts(page), ['let a;', 'let c;', 'let d;', 'let e;']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_page_startup.py b/tests/test_page_startup.py new file mode 100644 index 0000000..a482e7f --- /dev/null +++ b/tests/test_page_startup.py @@ -0,0 +1,58 @@ +"""The page calls showPage() while its first script is still running, before names declared +later (in that script or the second one) exist. Touching one of them there throws, and the +rest of the page's setup never runs: opening Frame Control at #games did exactly that.""" +import json +import pathlib +import re +import shutil +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab', + 'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame'] + +RUN = r''' +const vm = require('vm'); +const {code, hashes} = JSON.parse(require('fs').readFileSync(0, 'utf8')); +const failures = []; +for (const hash of hashes) { + const context = vm.createContext({ + location: {hash}, document: {querySelectorAll: () => [], title: ''}, live: false, + }); + try { vm.runInContext(code, context); } + catch (e) { failures.push(`${hash}: ${e.message}`); } +} +console.log(JSON.stringify(failures)); +''' + + +@unittest.skipUnless(shutil.which('node'), 'Node runs the page code') +class PageStartup(unittest.TestCase): + def test_opening_any_page_or_section_at_startup_runs(self): + page = (ROOT / 'ui/index.html').read_text(encoding='utf-8') + first, second = re.findall(r'', page, re.S)[:2] + # The routing tables and the workspace helpers showPage uses, up to showPage itself. + start = first.index('function showPage(') + tables = first[first.index('const PAGES ='):start] + show = first[start:first.index('\n}\n', start) + 3] + # Everything declared after the startup call is still uninitialised when it runs. + call = re.search(r'^showPage\(\);', first, re.M).end() + later = re.findall(r'^(?:const|let)\s+(\w+)', first[call:] + second, re.M) + later = [n for n in dict.fromkeys(later) if n not in STUBS and n not in ('PAGES', 'SECTION_PAGE', 'page')] + self.assertIn('link', later) # the name that broke #games + stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n' + for n in STUBS if n != '$') + node = '{ classList: { toggle() {} }, scrollIntoView() {}, append() {}, prepend() {}, setAttribute() {}, removeAttribute() {} }' + code = (f'const $ = id => id === "nowhere" ? null : {node};\nconst gm = {{src: "owned"}};\n' + stubs + tables + show + + 'showPage();\n' + ''.join(f'let {n};\n' for n in later)) + hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#control', '#nowhere'] + hashes += ['#' + k for k in re.findall(r'(\w+): ?"', tables[:tables.index('const workspace')])] + r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}), + capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(json.loads(r.stdout), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_quest.py b/tests/test_quest.py new file mode 100644 index 0000000..ff24bf3 --- /dev/null +++ b/tests/test_quest.py @@ -0,0 +1,1144 @@ +"""Import from my Quest (ui/frame_quest.py) against a fake adb (tests/fakeadb.py), and +split APK merging in the shared install path (frame_android.install(splits=...)).""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import io +import json +import os +from pathlib import Path +from types import SimpleNamespace +import struct +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile +import zlib + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android # noqa: E402 +import frame_apk # noqa: E402 +import frame_apk_sign # noqa: E402 +import frame_apk_vr # noqa: E402 +import frame_quest as quest # noqa: E402 +from apk_fixtures import pool # noqa: E402 + +FAKEADB = str(Path(__file__).resolve().parent / 'fakeadb.py') +PKG = 'com.example.questgame' +QUEST = {'serial': '1WMHH000ABCDEF', 'state': 'device', 'extra': 'usb:1-1 product:eureka model:Quest_3 transport_id:2', + 'props': {'ro.product.manufacturer': 'Oculus', 'ro.product.model': 'Quest 3'}} +TUNNEL = {'serial': '127.0.0.1:40123', 'state': 'device', 'extra': 'product:lepton model:Lepton transport_id:1', + 'props': {'ro.product.manufacturer': 'Valve', 'ro.product.model': 'Lepton'}} +PHONE = {'serial': 'R58M000PHONE', 'state': 'device', 'extra': 'usb:1-2 model:SM_G990 transport_id:3', + 'props': {'ro.product.manufacturer': 'samsung', 'ro.product.model': 'SM-G990'}} + + +def axml(package, split=None, split_required=False, resmap=False, feature=False, children=(), version_code=None, + version_name=None): + """A minimal binary AndroidManifest.xml: children. + + resmap: name isSplitRequired by its android resource id (as real builds do), not only by name.""" + strings = ['isSplitRequired', 'package', 'split', 'manifest', 'application', 'label', package, + split or '', 'Quest Game', 'isFeatureSplit', 'versionCode', 'versionName', version_name or '', + *children] + none = 0xffffffff + + def start(tag, attrs): + body = struct.pack('') + self.refused(apk_bytes(split='config.arm64_v8a', children=('uses-library',), files=OPENXR), '') + self.refused(apk_bytes(split='feature_vr', feature=True, files=OPENXR), 'feature split') + self.refused(apk_bytes(split='config.arm64_v8a', files={'kotlin/x.kotlin_builtins': b''}), 'contains kotlin/') + self.refused(apk_bytes(split='config.arm64_v8a', files={'lib/arm64-v8a/sub/libx.so': b''}), 'contains lib/') + + def test_resource_tables(self): + from apk_fixtures import resources + self.refused(apk_bytes(split='config.en', files={'resources.arsc': resources({(1, 'en', 0): {0: 1}})}), + 'density or language') + base, empty = self.files(apk_bytes(split_required=True), + apk_bytes(split='config.arm64_v8a', files=dict(OPENXR, **{'resources.arsc': resources({})}))) + self.assertEqual(set(android.split_entries(base, [empty])), set(OPENXR)) + + def test_zip_bomb_refused_by_headers_and_by_reads(self): + bomb = {'assets/bomb.bin': b'\0' * (8 * 1024**2)} + split = apk_bytes(split='install_time', files=bomb, deflate=True) + self.assertLess(len(split), 64 * 1024) # tiny on disk + base, part = self.files(apk_bytes(split_required=True), split) + with patch.object(android, 'MAX_SPLIT_MEMBER', 1024**2): + with self.assertRaisesRegex(android.FrameError, 'more than Frame Control merges'): + android.split_entries(base, [part]) + with patch.object(android, 'MAX_SPLIT_TOTAL', 4 * 1024**2): + with self.assertRaisesRegex(android.FrameError, 'more than 0 GB|more than Frame Control merges'): + android.split_entries(base, [part]) + # Headers that understate the size: the read stops at what they promise, and the + # truncated member fails its checksum instead of filling memory. + base, forged = self.files(apk_bytes(split_required=True), lie_about_size(split, 'assets/bomb.bin', 1000)) + self.assertEqual(android.split_size([forged]), 1000) + with patch.object(android, 'repack') as repack: + with self.assertRaises(android.FrameError): + android.merge_splits(base, [forged], os.path.join(os.path.dirname(base), 'out.apk')) + repack.assert_not_called() + + +class SplitForgeries(unittest.TestCase): + """Members whose headers understate their size, with a checksum that matches the prefix.""" + + def merge(self, split): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + base, part = os.path.join(tmp.name, 'base.apk'), os.path.join(tmp.name, 'split.apk') + with open(base, 'wb') as f: + f.write(apk_bytes(split_required=True)) + with open(part, 'wb') as f: + f.write(split) + with patch.object(android, 'repack') as repack: + with self.assertRaisesRegex(android.FrameError, 'does not match its header'): + android.merge_splits(base, [part], os.path.join(tmp.name, 'out.apk')) + repack.assert_not_called() + + def test_stored_and_deflated_truncation_refused(self): + content = bytes(range(256)) * 64 # 16 KB + prefix = zlib.crc32(content[:1000]) + for deflate in (False, True): + split = apk_bytes(split='install_time', deflate=deflate, files={'assets/level.pak': content}) + forged = lie_about_size(split, 'assets/level.pak', 1000, crc=prefix) + # Python's own reader is fooled: it stops at the declared size and the prefix checksum matches. + with zipfile.ZipFile(io.BytesIO(forged)) as z: + self.assertEqual(z.read('assets/level.pak'), content[:1000]) + self.merge(forged) + + def test_honest_members_still_merge(self): + content = bytes(range(256)) * 64 + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + for deflate in (False, True): + part = os.path.join(tmp.name, f'split{deflate}.apk') + with open(part, 'wb') as f: + f.write(apk_bytes(split='install_time', deflate=deflate, files={'assets/level.pak': content})) + self.assertEqual(android.read_member(part, 'assets/level.pak'), content) + + def test_bag_only_resource_table_is_not_empty(self): + from apk_fixtures import resources + simple = resources({(1, '', 0): {0: 1}}) + entry = struct.pack(' (status, headers, body).""" + + def __init__(self, route): + super().__init__() + self.route, self.seen = route, [] + + def https_open(self, req): + auth = req.get_header('Authorization') + self.seen.append((req.full_url, auth)) + status, headers, body = self.route(req.full_url, auth) + message = http.client.HTTPMessage() + for key, value in headers.items(): + message[key] = value + response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, status) + response.msg = 'fake' + return response + + +class Base(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)), + ('cache_dir', lambda *p: self.root.joinpath('cache', *p))]: + mock = patch.object(fdroid.frame_host, name, value) + mock.start() + self.addCleanup(mock.stop) + self.web = FakeHTTPS(self.route) + mock = patch('urllib.request.build_opener', lambda *handlers: REAL_BUILD_OPENER(*handlers, self.web)) + mock.start() + self.addCleanup(mock.stop) + mock = patch.dict(os.environ, {github.ENV: ''}) + mock.start() + self.addCleanup(mock.stop) + for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing, github._rejected): + state.clear() + self.addCleanup(state.clear) + self.stderr = io.StringIO() + mock = patch('sys.stderr', self.stderr) + mock.start() + self.addCleanup(mock.stop) + + def route(self, url, auth): + raise AssertionError('unexpected request ' + url) + + def assertNoSecrets(self, *texts): + for text in texts: + for secret in SECRETS + (USER + ':',): + self.assertNotIn(secret, text) + + def disk(self): + """Everything saved under the data and cache folders except the credentials file.""" + out = [] + for path in self.root.rglob('*'): + if path.is_file() and path.name != 'source-credentials.json': + out.append(path.read_bytes().decode('utf-8', 'replace')) + return '\n'.join(out) + + def credentials_file(self): + return self.root / 'data' / 'source-credentials.json' + + +class Repository(Base): + """A repository at REPO that wants USER and PASSWORD.""" + def setUp(self): + super().setUp() + self.login = basic(USER, PASSWORD) + self.redirect = None # file name -> absolute URL it redirects to + self.forbidden = 401 + + def serve(self, name): + return (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes() + + def route(self, url, auth): + for mirror in ('https://mirror.example/fdroid/repo/', 'https://repo.example/elsewhere/'): + if url.startswith(mirror): # serves anyone; a login must never get here unless same-origin + return 200, {}, self.serve(url[len(mirror):]) + self.assertTrue(url.startswith(REPO), url) + if auth != self.login: + return self.forbidden, {'WWW-Authenticate': 'Basic realm="repo"'}, b'no' + name = url[len(REPO):] + if self.redirect and name in self.redirect: + return 302, {'Location': self.redirect[name]}, b'' + return 200, {}, self.serve(name) + + def add(self, **kw): + return fdroid.add_repo('https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo', PIN, **kw) + + +class RepositoryLogin(Repository): + def test_add_search_download_with_login_in_the_url(self): + source = self.add() + self.assertEqual(source['url'], REPO) + self.assertEqual(source['fingerprint'], PIN) + saved = fdroid.user_repos()[0] + self.assertEqual(saved['url'], REPO) + self.assertNotIn('login', saved) + self.assertEqual(len(fdroid.search(source, 'example offline')), 1) + downloaded = fdroid.download(source, 'org.example.app', 1) + self.assertTrue(downloaded['verified']) + self.assertTrue(self.web.seen) + self.assertTrue(all(auth == self.login for url, auth in self.web.seen)) + self.assertTrue(all('@' not in url for url, auth in self.web.seen)) # never in the URL + self.assertTrue(next(s for s in fdroid.sources() if s['id'] == source['id'])['login']) + self.assertNoSecrets(json.dumps(source), json.dumps(fdroid.sources()), self.disk(), self.stderr.getvalue()) + # The settings file holds no part of the login; the credentials file holds it, privately. + settings = (self.root / 'data' / 'apk-repos.json').read_text() + self.assertNotIn(USER, settings) + self.assertTrue(self.credentials_file().is_file()) + if os.name != 'nt': + self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600) + + def test_login_given_apart_and_saved_login_reused_on_refresh(self): + source = fdroid.add_repo(REPO, PIN, username=USER, password=PASSWORD) + self.assertEqual(source['url'], REPO) + self.web.seen.clear() + fdroid._load(fdroid.user_repos()[0], force=True) # as a background refresh would + self.assertTrue(self.web.seen) + self.assertTrue(all(auth == self.login for url, auth in self.web.seen)) + fdroid.add_repo(REPO) # re-adding without a login keeps the saved one + self.assertTrue(_credentials.has(source['id'])) + + def test_wrong_or_missing_login_saves_nothing(self): + for kw, message in [({'username': USER, 'password': 'wrong'}, 'check the username and password'), + ({}, 'needs a username and password')]: + with self.subTest(kw=kw): + with self.assertRaisesRegex(SourceAuthError, message) as caught: + fdroid.add_repo(REPO, PIN, **kw) + self.assertNoSecrets(str(caught.exception), repr(caught.exception)) + self.assertNotIn('wrong', str(caught.exception)) + self.assertEqual(fdroid.user_repos(), []) + self.assertFalse(self.credentials_file().exists()) + self.assertFalse((self.root / 'data' / 'apk-repo-state.json').exists()) + self.assertEqual(_web._limited, {}) # an auth failure isn't a rate limit + + def test_403_with_a_login_is_a_login_error_not_a_rate_limit(self): + self.forbidden = 403 + with self.assertRaisesRegex(SourceAuthError, 'check the username and password'): + fdroid.add_repo(REPO, PIN, username=USER, password='wrong') + self.assertEqual(_web._limited, {}) + self.assertEqual(fdroid.user_repos(), []) + + def test_signature_checks_still_apply(self): + with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'): + fdroid.add_repo(REPO, '0' * 64, username=USER, password=PASSWORD) + self.assertEqual(fdroid.user_repos(), []) + self.assertFalse(self.credentials_file().exists()) + + def test_cross_origin_redirect_never_gets_the_login(self): + self.redirect = {'entry.jar': 'https://mirror.example/fdroid/repo/entry.jar', + 'index-v2.json': 'https://repo.example/elsewhere/index-v2.json'} + source = self.add() + self.assertEqual(source['fingerprint'], PIN) # the mirror's copy still had to verify + mirror = [auth for url, auth in self.web.seen if url.startswith('https://mirror.example/')] + same_origin = [auth for url, auth in self.web.seen if url.startswith('https://repo.example/elsewhere/')] + self.assertEqual(mirror, [None]) + self.assertEqual(same_origin, [None]) # same origin but outside the repository: no login either + + def test_traversal_names_are_refused_and_never_get_the_login(self): + for name in ('%252e%252e/%252e%252e/evil/collect.apk', '..;/..;/evil/collect.apk', 'a%2f..%2f..%2fevil.apk', + 'a%5c..%5cevil.apk', '%2e%2e/evil.apk', 'x;y.apk', '100%25.apk'): + with self.subTest(name=name), self.assertRaisesRegex(SourceError, 'unsafe|outside'): + fdroid._child(REPO, name) + self.assertEqual(fdroid._child(REPO, '/app/en-US/phoneScreenshots/#0 a.png'), + REPO + 'app/en-US/phoneScreenshots/%230%20a.png') + # Even if such a URL reached the downloader directly, the login stays home. + login = _credentials.basic(REPO, USER, PASSWORD) + for url in (REPO + '%252e%252e/%252e%252e/evil/collect.apk', REPO + '..;/..;/evil/collect.apk'): + with self.subTest(url=url), tempfile.TemporaryDirectory() as tmp: + self.web.seen.clear() + with contextlib.suppress(SourceError): # the repository answers 401 without the login + fdroid._fetch(url, Path(tmp) / 'out', 1 << 20, auth=login) + self.assertEqual(self.web.seen, [(url, None)]) + + def test_same_origin_redirect_outside_the_repository_gets_no_login(self): + self.redirect = {'example1.apk': 'https://repo.example/elsewhere/example1.apk'} + source = self.add() + self.web.seen.clear() + self.assertTrue(fdroid.download(source, 'org.example.app', 1)['verified']) + self.assertEqual(self.web.seen, [(REPO + 'example1.apk', self.login), + ('https://repo.example/elsewhere/example1.apk', None)]) + + def test_other_ports_and_http_never_get_the_login(self): + login = _credentials.basic(REPO, USER, PASSWORD) + for url in ('https://repo.example:8443/fdroid/repo/x', 'http://repo.example/fdroid/repo/x', + 'https://other.example/fdroid/repo/x', 'https://repo.example.evil/x'): + self.assertFalse(login.matches(url), url) + self.assertTrue(login.matches('https://REPO.example:443/anything')) + self.assertNoSecrets(repr(login)) + handler = _credentials.Handler(login) + req = urllib.request.Request('https://other.example/x', headers={'Authorization': 'Basic stale'}) + self.assertIsNone(handler.https_request(req).get_header('Authorization')) + + def test_http_and_bad_logins_are_refused_before_any_request(self): + for url, kw in [('http://alice:pw@repo.example/fdroid/repo', {}), + ('http://repo.example/fdroid/repo', {'username': USER, 'password': PASSWORD}), + (REPO, {'username': 'a:b', 'password': PASSWORD}), + (REPO, {'username': USER, 'password': 'x\r\nX-Evil: 1'}), + (REPO, {'password': PASSWORD}), + ('https://alice:pw@repo.example/fdroid/repo', {'username': 'bob'})]: + with self.subTest(url=url, kw=kw), self.assertRaises(SourceError) as caught: + fdroid.add_repo(url, PIN, **kw) + self.assertNotIn('pw', str(caught.exception).replace('password', '')) + self.assertNoSecrets(str(caught.exception)) + self.assertEqual(self.web.seen, []) + + def test_remove_deletes_the_login(self): + source = self.add() + other = _credentials.bearer('https://api.github.com/', TOKEN) + _credentials.put('github', other) + fdroid.remove_repo(source['id']) + self.assertFalse(_credentials.has(source['id'])) + self.assertNotIn(PASSWORD, self.credentials_file().read_text()) + _credentials.remove('github') + self.assertFalse(self.credentials_file().exists()) # nothing left: the file goes + + def test_cli_reads_the_password_from_stdin(self): + out = io.StringIO() + argv = ['fdroid.py', 'add', REPO, '--fingerprint', PIN, '--username', USER, '--password-stdin'] + with patch.object(sys, 'argv', argv), patch.object(sys, 'stdin', io.StringIO(PASSWORD + '\n')), \ + contextlib.redirect_stdout(out): + fdroid.main() + self.assertEqual(json.loads(out.getvalue())['url'], REPO) + self.assertNoSecrets(out.getvalue()) + self.assertTrue(_credentials.has(fdroid.user_repos()[0]['id'])) + + def test_artwork_from_the_repository_gets_the_login_and_only_there(self): + source = self.add() + self.assertEqual(_credentials.for_url(REPO + 'icons/a.png').header(), self.login) + self.assertTrue(_credentials.has(source['id'])) + self.assertIsNone(_credentials.for_url('https://repo.example/other/a.png')) # outside the repository + self.assertIsNone(_credentials.for_url('https://cdn.example/fdroid/repo/a.png')) + + +class GitHubToken(Base): + def setUp(self): + super().setUp() + self.accept = {TOKEN} + self.releases = json.dumps([]).encode() + self.anonymous_limited = False + + def route(self, url, auth): + if url.startswith('https://api.github.com/'): + if auth is not None and auth not in {'Bearer ' + t for t in self.accept}: + return 401, {}, b'{"message": "Bad credentials"}' + if auth is None and self.anonymous_limited: + return 403, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(time.time()) + 1800)}, b'{}' + if url.endswith('/redirected'): + return 302, {'Location': 'https://github.com/landed'}, b'' + return 200, {}, self.releases + if url == 'https://github.com/landed': + return 200, {}, b'{"ok": true}' + raise AssertionError('unexpected request ' + url) + + def test_saved_token_goes_only_to_the_api(self): + github.set_token(TOKEN) + self.assertEqual(github._api('/repos/a/b/releases'), []) + self.assertEqual(self.web.seen, [('https://api.github.com/repos/a/b/releases', 'Bearer ' + TOKEN)]) + auth = github._credential(github._token()[0]) + self.assertEqual(_web.read('https://api.github.com/redirected', ('api.github.com', 'github.com'), auth=auth), + b'{"ok": true}') + self.assertEqual(self.web.seen[-2:], [('https://api.github.com/redirected', 'Bearer ' + TOKEN), + ('https://github.com/landed', None)]) # redirect: no token + status = github.sources()[0]['token'] + self.assertEqual(status, {'set': True, 'from': 'saved', 'rejected': False}) + self.assertNoSecrets(json.dumps(github.sources()), self.disk(), self.stderr.getvalue()) + if os.name != 'nt': + self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600) + + def test_rejected_token_retries_once_without_it(self): + github.set_token(TOKEN) + self.accept = set() + self.assertEqual(github._api('/repos/a/b/releases'), []) + self.assertEqual([auth for _, auth in self.web.seen], ['Bearer ' + TOKEN, None]) + self.assertTrue(github.token_status()['rejected']) + self.assertIn('rejected', self.stderr.getvalue()) + self.assertNoSecrets(self.stderr.getvalue()) + self.web.seen.clear() + github._api('/repos/a/b/other') + self.assertEqual([auth for _, auth in self.web.seen], [None]) # not tried again this run + github.set_token(TOKEN) # saving it again gives it a fresh chance + self.assertFalse(github.token_status()['rejected']) + + def test_environment_variable_wins(self): + github.set_token(TOKEN) + env = 'ghp_fromTheEnvironment' + self.accept = {env} + with patch.dict(os.environ, {github.ENV: env}): + github._api('/repos/a/b/releases') + self.assertEqual(self.web.seen[-1][1], 'Bearer ' + env) + self.assertEqual(github.token_status(), {'set': True, 'from': 'env', 'rejected': False}) + self.assertEqual(github.token_status()['from'], 'saved') + + def test_clear_removes_it_from_disk(self): + github.set_token(TOKEN) + github.clear_token() + self.assertFalse(self.credentials_file().exists()) + self.assertEqual(github.token_status(), {'set': False, 'from': None, 'rejected': False}) + github._api('/repos/a/b/releases') + self.assertEqual(self.web.seen[-1][1], None) + + def test_saving_a_token_escapes_the_anonymous_rate_limit(self): + self.anonymous_limited = True + with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'): + github._api('/repos/a/b/releases') + self.assertEqual(self.web.seen[-1][1], None) + github.set_token(TOKEN) + self.assertEqual(github._api('/repos/a/b/other'), []) # tried, with the token's own quota + self.assertEqual(self.web.seen[-1], ('https://api.github.com/repos/a/b/other', 'Bearer ' + TOKEN)) + github.clear_token() + count = len(self.web.seen) + with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'): + github._api('/repos/a/b/third') + self.assertEqual(len(self.web.seen), count) # anonymous is still backing off: no request + + def test_bad_tokens_are_refused(self): + for token in ('', ' ', 'two words', 'x\nAuthorization: y'): + with self.subTest(token=token), self.assertRaises(SourceError): + github.set_token(token) + self.assertFalse(self.credentials_file().exists()) + + +PNG = b'\x89PNG\r\n\x1a\nfixture' +PRIVATE = 'https://repo.example/private/repo/' +TRAVERSALS = [PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', PRIVATE + '%2E%2E/x.png', PRIVATE + '../../evil/x.png', + PRIVATE + './icons/a.png', PRIVATE + '..%2f..%2fevil/x.png', PRIVATE + 'a%5c..%5c..%5cevil.png', + PRIVATE + 'a;b/../../evil.png', PRIVATE + '%252e%252e/%252e%252e/evil.png', + 'https://repo.example/private/repository/x.png', 'https://repo.example/private/repo%2fx.png'] + + +class ArtworkScope(Base): + """The artwork proxy: actual outgoing headers, through _images.get's own HTTP code.""" + + def setUp(self): + super().setUp() + self.sent, self.redirects = [], {} + _credentials.put('fdroid-user-private', _credentials.basic(PRIVATE, USER, PASSWORD)) + test = self + + class Response: + def __init__(self, status, location=''): + self.status, self.location, self.body = status, location, PNG if status == 200 else b'' + + def getheader(self, name, default=''): + return self.location if name == 'Location' else default + + def read1(self, n): + chunk, self.body = self.body[:n], self.body[n:] + return chunk + + class Connection: + def __init__(self, host, port, timeout=None): + self.host, self.port = host, port + + def request(self, method, path, headers): + url = 'https://%s%s' % (self.host, path) + test.sent.append((url, headers.get('Authorization'))) + self.url = url + + def getresponse(self): + if self.url in test.redirects: + return Response(302, test.redirects[self.url]) + return Response(200) + + def close(self): + pass + + # A public address, no real socket or TLS, and Connection in place of http.client's. + for target, name, value in [(_images, '_resolve', lambda host, port, timeout: [(2, 1, 6, '', ('93.184.216.34', port))]), + (_images.socket, 'create_connection', lambda *a, **k: Mock()), + (_images.ssl, 'create_default_context', lambda: Mock()), + (_images.http.client, 'HTTPConnection', Connection)]: + mock = patch.object(target, name, value) + mock.start() + self.addCleanup(mock.stop) + + def test_artwork_in_the_repository_gets_the_login(self): + self.assertEqual(_images.get(PRIVATE + 'icons/a%20b.png'), PNG) + self.assertEqual(self.sent, [(PRIVATE + 'icons/a%20b.png', basic(USER, PASSWORD))]) + + def test_paths_a_server_could_normalise_outside_the_repository_get_nothing(self): + for url in TRAVERSALS: + with self.subTest(url=url): + self.sent.clear() + self.assertEqual(_images.get(url), PNG) + self.assertEqual(self.sent, [(url, None)]) + self.assertIsNone(_credentials.for_url(url)) + + def test_redirect_to_a_traversal_gets_nothing(self): + start = PRIVATE + 'icons/a.png' + self.redirects[start] = PRIVATE + '%2e%2e/%2e%2e/evil/collect.png' + self.assertEqual(_images.get(start), PNG) + self.assertEqual(self.sent, [(start, basic(USER, PASSWORD)), (self.redirects[start], None)]) + + +class ScopedHandler(Base): + """Handler(scoped=True), for callers (artwork-like) that must stay inside a scope, redirects included.""" + + def route(self, url, auth): + if url == PRIVATE + 'icons/a.png': + return 302, {'Location': PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'}, b'' + return 200, {}, PNG + + def test_redirect_into_a_traversal_is_stripped(self): + login = _credentials.basic(PRIVATE, USER, PASSWORD) + opener = urllib.request.build_opener(_credentials.Handler(login, scoped=True)) + with opener.open(PRIVATE + 'icons/a.png') as r: + self.assertEqual(r.read(), PNG) + self.assertEqual(self.web.seen, [(PRIVATE + 'icons/a.png', basic(USER, PASSWORD)), + (PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', None)]) + for url in TRAVERSALS: + self.assertFalse(_credentials.in_scope(login, url), url) + self.assertTrue(_credentials.in_scope(login, PRIVATE + 'a/b%20c.png?x=1')) + self.assertTrue(_credentials.in_scope(login, PRIVATE.rstrip('/'))) + + +CHURN = ''' +import sys +sys.path.insert(0, sys.argv[1]) +from apk_sources import _credentials +for i in range(int(sys.argv[3])): + key = sys.argv[2] + str(i) + _credentials.put(key, _credentials.basic('https://h.example/', 'u', 'p')) + if i % 2: + _credentials.remove(key) +''' + + +class TwoProcesses(Base): + def test_interleaved_saves_and_removals_are_not_lost(self): + ui = str(Path(__file__).resolve().parents[1] / 'ui') + env = dict(os.environ, FRAME_CONTROL_DATA_DIR=str(self.root / 'data')) + count = 60 + procs = [subprocess.Popen([sys.executable, '-c', CHURN, ui, prefix, str(count)], env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) for prefix in ('a', 'b')] + for p in procs: + out, err = p.communicate(timeout=120) + self.assertEqual(p.returncode, 0, err.decode()) + saved = set(json.loads(self.credentials_file().read_text())) + self.assertEqual(saved, {p + str(i) for p in 'ab' for i in range(0, count, 2)}) + if os.name != 'nt': + self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600) + + +@unittest.skipUnless(shutil.which('node'), 'needs node') +class PageLogin(unittest.TestCase): + def test_login_leaves_the_address_field_before_sending(self): + """tests/page/source_login.mjs runs the real page functions from index.html.""" + r = subprocess.run(['node', str(Path(__file__).parent / 'page' / 'source_login.mjs')], + capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn('ok', r.stdout) + + +class Endpoints(Repository): + """The server API: logins in, never out, in responses or the server log.""" + + def setUp(self): + super().setUp() + mock = patch.object(search, 'modules', return_value=([fdroid, github], [])) + mock.start() + self.addCleanup(mock.stop) + self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler) + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + self.addCleanup(self.httpd.server_close) + self.addCleanup(self.httpd.shutdown) + + def request(self, method, path, body=None): + c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port) + c.request(method, path, json.dumps(body) if body is not None else None, + {'X-Frame-UI': '1', 'Content-Type': 'application/json'}) + r = c.getresponse() + raw = r.read().decode() + c.close() + self.replies.append(raw) + return r.status, json.loads(raw) + + def job(self, reply): + for _ in range(500): + job = self.request('GET', '/api/job?id=' + reply['job'])[1] + if job['done']: + return job + time.sleep(.01) + self.fail('job did not finish') + + def test_add_with_login_and_token_settings(self): + self.replies = [] + bad = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN, + 'username': USER, 'password': 'wrong'})[1]) + self.assertIn('check the username and password', bad['error']) + self.assertEqual(fdroid.user_repos(), []) + code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN, + 'username': USER, 'password': PASSWORD}) + self.assertEqual(code, 200) + job = self.job(reply) + self.assertIsNone(job['error']) + listed = self.request('GET', '/api/sources')[1]['sources'] + repo = next(s for s in listed if s['id'] == job['result']['source']['id']) + self.assertEqual((repo['url'], repo['login']), (REPO, True)) + self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'github', + 'token': TOKEN})[0], 200) + github_row = next(s for s in self.request('GET', '/api/sources')[1]['sources'] if s['id'] == 'github') + self.assertEqual(github_row['token'], {'set': True, 'from': 'saved', 'rejected': False}) + self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'fdroid', + 'token': TOKEN})[0], 400) + self.assertEqual(self.request('POST', '/api/sources', {'action': 'clear-token', 'source': 'github'})[0], 200) + self.assertFalse(_credentials.has('github')) + self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': repo['id']})[0], 200) + self.assertFalse(self.credentials_file().exists()) + self.assertNoSecrets(*self.replies, self.stderr.getvalue(), self.disk()) + + def test_login_in_the_url_is_accepted(self): + self.replies = [] + url = 'https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo' + job = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': url, 'fingerprint': PIN})[1]) + self.assertIsNone(job['error']) + self.assertEqual(fdroid.user_repos()[0]['url'], REPO) + self.assertNoSecrets(*self.replies, self.stderr.getvalue()) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_store_categories.py b/tests/test_store_categories.py index 789a392..e6cd0e5 100644 --- a/tests/test_store_categories.py +++ b/tests/test_store_categories.py @@ -326,17 +326,16 @@ class ArtworkLookups(unittest.TestCase): self.assertEqual(lookup.call_count, 2) def test_waiting_for_a_slot_counts_against_the_budget(self): - import threading, time - gate = threading.Event() - for _ in range(4): # every slot busy for the whole test - _images._resolvers.acquire() - threading.Timer(0.06, lambda: [_images._resolvers.release() for _ in range(4)]).start() - with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(2) and []): - start = time.monotonic() + # Sixty milliseconds pass while a slot is busy. A still-running lookup + # gets only the remaining forty, regardless of the test host's load. + with patch.object(_images, '_resolvers') as slots, \ + patch.object(_images.time, 'monotonic', side_effect=[100, 100.06]), \ + patch.object(_images.threading, 'Thread') as worker: + slots.acquire.return_value = True with self.assertRaises(SourceError): _images._resolve('slow.example', 443, 0.1) - self.assertLess(time.monotonic() - start, 0.16) - gate.set() + slots.acquire.assert_called_once_with(timeout=0.1) + self.assertAlmostEqual(worker.return_value.join.call_args.args[0], 0.04) def test_many_at_once_wait_for_a_slot(self): import threading, time diff --git a/tests/workspace_fixture.cjs b/tests/workspace_fixture.cjs index bb1dd3a..d8577a0 100644 --- a/tests/workspace_fixture.cjs +++ b/tests/workspace_fixture.cjs @@ -9,7 +9,7 @@ function workspaceFixture() { if (!elements.has(id)) elements.set(id,{id,hidden:false,disabled:false,open:false,innerHTML:'',textContent:'',value:'',dataset:{},classList:classes(), setAttribute(k,v){this[k]=v;},removeAttribute(k){delete this[k];},querySelectorAll(){return [];}, append(node){node.parent=this;},prepend(node){node.parent=this;},scrollIntoView(){calls.push(['section',id]);}, - showModal(){this.open=true;},close(){this.open=false;}}); + showModal(){this.open=true;},close(){this.open=false;},addEventListener(k,fn){listeners[id+'-'+k]=fn;}}); return elements.get(id); } const pages=[...html.matchAll(/data-page="([^"]+)"/g)].map((m,i)=>Object.assign(el('page-'+i),{dataset:{page:m[1]}})); @@ -23,8 +23,8 @@ function workspaceFixture() { const win={scrollY:0,scrollTo(a,b){this.scrollY=typeof a==='object'?a.top:b;calls.push(['scroll',this.scrollY]);},addEventListener(k,fn){listeners[k]=fn;}}; let hash='#apps';const location={get hash(){return hash;},set hash(value){hash=value&&!String(value).startsWith('#')?'#'+value:String(value);}}; const context={URL,document:{querySelectorAll:s=>s==='.page'?pages:s==='#nav a'?links:controls[s]||[],title:'',body:{addEventListener(k,fn){listeners['body-'+k]=fn;}}}, - window:win,location,requestAnimationFrame:fn=>fn(),$:el,live:false,toggleLive(on){this.live=on;calls.push(['live',on]);}, - loadAndroid(){calls.push(['android']);}, + window:win,location,scroller:()=>({get scrollTop(){return win.scrollY;},scrollTo:o=>win.scrollTo(o)}),scrollToY:y=>win.scrollTo({top:y}),requestAnimationFrame:fn=>fn(),$:el,live:false,toggleLive(on){this.live=on;calls.push(['live',on]);}, + loadAndroid(){calls.push(['android']);},closeDevPop(){},metaLib:{connected:false},renderMetaApps(){}, loadMacView(){calls.push(['macview']);},loadPanelSwitcher(){calls.push(['panels']);},loadDisplays(){calls.push(['displays']);}, gm:{src:'owned',dl:null,byId:new Map(),shown:0,shownBySource:{owned:0,store:0}},setGameSource(src){context.gm.src=src;},state:null,stateRead:{valid:false},online:null,devGen:0,androidApps:[],jobsRunning:0,viewGen:0, esc:s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}; diff --git a/ui/apk_sources/__init__.py b/ui/apk_sources/__init__.py index f7e6b05..be9648c 100644 --- a/ui/apk_sources/__init__.py +++ b/ui/apk_sources/__init__.py @@ -45,6 +45,10 @@ class SourceError(Exception): """User-readable failure from a source (network, format, verification).""" +class SourceAuthError(SourceError): + """The server refused our login (HTTP 401, or 403 when a login was sent).""" + + class SourceLimited(SourceError): """The source's host asked us to slow down; retry_after is in seconds.""" diff --git a/ui/apk_sources/_credentials.py b/ui/apk_sources/_credentials.py new file mode 100644 index 0000000..55b9889 --- /dev/null +++ b/ui/apk_sources/_credentials.py @@ -0,0 +1,270 @@ +"""Logins for sources behind one: HTTP basic auth and access tokens. + +Saved apart from the source settings, in frame_host.data_dir('source-credentials.json') +(mode 0600), keyed by a caller-chosen name: an F-Droid source id, 'github'. Each +credential belongs to one exact origin (https, host, port) and is only ever sent +to a request for that origin: Handler adds it to each request, redirects +included, and removes any Authorization header from every other request. + +Scoped use (for_url, and Handler(..., scoped=True), which F-Droid repositories and +artwork use) is stricter: the URL must also be under the credential's scope by whole +path segments, with no dot segments, encoded slashes or ';' a server could normalise +into a path outside it. Origin-only matching suits a host-wide token (GitHub's API). + +Secrets leave this module only as an Authorization header value. Credential's +repr hides them, and nothing here puts them in an exception message. + +Each change is a read-modify-write under an inter-process lock (the CLI and the +app may both save), so one process can't undo another's save or removal. +""" +import base64 +import contextlib +import errno +import json +import os +import tempfile +import threading +import urllib.parse +import urllib.request + +import frame_host + +_lock = threading.RLock() +MAX = 4096 # longest username, password or token accepted + + +def _path(): + return frame_host.data_dir('source-credentials.json') + + +@contextlib.contextmanager +def _changing(): + """This thread, then this process, holds the credentials file for a read-modify-write.""" + path = _path().with_name('source-credentials.lock') + with _lock: + path.parent.mkdir(parents=True, exist_ok=True) + with open(str(path), 'a+b') as f: + if os.name == 'nt': + import msvcrt + while True: + try: + f.seek(0) + msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1) + break + except OSError as e: # LK_LOCK gives up after ~10 s of contention; keep waiting + if e.errno not in (errno.EACCES, errno.EDEADLK): + raise + try: + yield + finally: + f.seek(0) + msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + +def path_segments(path): + """A URL path's decoded segments, or None if a server might read it as another path: + '.' or '..' (plain or percent-encoded), encoded '/' or '\\', '%' left after decoding + (double encoding), or ';' path parameters.""" + if ';' in path or '\\' in path: + return None + parts = [urllib.parse.unquote(part) for part in path.split('/')] + if any(part in ('.', '..') or '/' in part or '\\' in part or '%' in part or ';' in part for part in parts): + return None + return parts + + +def in_scope(credential, url): + """True if url is on credential's origin and under its scope by whole, plain path segments.""" + if credential is None or not credential.matches(url): + return False + try: + path, scope = urllib.parse.urlsplit(url).path, urllib.parse.urlsplit(credential.scope).path + except ValueError: + return False + parts, base = path_segments(path), path_segments(scope) + if parts is None or base is None: + return False + if base and base[-1] == '': + base = base[:-1] # 'https://h/repo/' covers '/repo/x', not '/repository' + return parts[:len(base)] == base + + +def origin(url): + """'https://host:port' for an https URL; ValueError for anything else.""" + p = urllib.parse.urlsplit(url) + port = p.port # raises ValueError on a bad port + if p.scheme.lower() != 'https' or not p.hostname: + raise ValueError('credentials are only sent over HTTPS') + host = p.hostname.lower() + return 'https://%s:%d' % ('[%s]' % host if ':' in host else host, port or 443) + + +def _check(value, what, allow_empty=False): + if not isinstance(value, str) or (not value and not allow_empty) or len(value) > MAX: + raise ValueError('invalid ' + what) + if any(ord(c) < 32 or ord(c) == 127 for c in value): + raise ValueError(what + ' must not contain control characters') + return value + + +class Credential: + """A username and password (basic) or a token (bearer) for one origin. + + scope is the URL prefix it belongs under: in_scope checks it (by path segments), + matches() checks only the origin.""" + __slots__ = ('origin', 'scope', 'username', '_secret') + + def __init__(self, scope, secret, username=None): + self.origin = origin(scope) + self.scope = scope + if username is not None: + _check(username, 'username') + if ':' in username: + raise ValueError('username must not contain ":"') + _check(secret, 'password', allow_empty=True) + else: + _check(secret, 'token') + if any(c.isspace() for c in secret): + raise ValueError('token must not contain spaces') + self.username, self._secret = username, secret + + @property + def kind(self): + return 'bearer' if self.username is None else 'basic' + + def header(self): + if self.username is None: + return 'Bearer ' + self._secret + return 'Basic ' + base64.b64encode((self.username + ':' + self._secret).encode('utf-8')).decode('ascii') + + def matches(self, url): + """True only for a URL on exactly this credential's origin.""" + try: + return origin(url) == self.origin + except ValueError: + return False + + def same(self, other): + return isinstance(other, Credential) and (self.origin, self.username, self._secret) == ( + other.origin, other.username, other._secret) + + def __repr__(self): + return '' % (self.kind, self.origin) + + def _json(self): + return {'scope': self.scope, 'username': self.username, 'secret': self._secret} + + +def basic(scope, username, password): + """Basic auth for scope's origin, e.g. basic('https://example.org/fdroid/repo/', 'me', 'pw').""" + return Credential(scope, password, username) + + +def bearer(scope, token): + """An access token for scope's origin, sent as 'Authorization: Bearer '.""" + return Credential(scope, token) + + +def _read(): + try: + with open(str(_path()), encoding='utf-8') as f: + data = json.load(f) + if not isinstance(data, dict): + raise ValueError + return data + except FileNotFoundError: + return {} + except (OSError, ValueError): + return {} # unreadable: as if none were saved; the next save rewrites it + + +def _save(data): + path = _path() + if not data: + try: + os.remove(str(path)) + except FileNotFoundError: + pass + return + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.credentials-', suffix='.part') # created 0600 + try: + with os.fdopen(fd, 'w', encoding='utf-8') as f: + json.dump(data, f, separators=(',', ':')) + os.chmod(tmp, 0o600) + os.replace(tmp, str(path)) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + + +def get(key): + """The saved Credential for key, or None.""" + with _lock: + item = _read().get(key) + try: + return Credential(item['scope'], item['secret'], item.get('username')) + except (TypeError, KeyError, ValueError): + return None + + +def has(key): + return get(key) is not None + + +def put(key, credential): + if not isinstance(credential, Credential): + raise TypeError('put() needs a Credential') + with _changing(): + data = _read() + data[key] = credential._json() + _save(data) + + +def remove(key): + """Delete key's credential from disk; True if there was one.""" + with _changing(): + data = _read() + if key not in data: + return False + del data[key] + _save(data) + return True + + +def for_url(url): + """A saved credential url is in the scope of (see in_scope), or None. Call it again for + each redirect hop: the answer depends only on the URL being requested.""" + with _lock: + keys = list(_read()) + for key in keys: + credential = get(key) + if in_scope(credential, url): + return credential + return None + + +class Handler(urllib.request.BaseHandler): + """Adds the credential to requests for its origin (redirects included) and strips + Authorization from every other request, so a redirect elsewhere never gets it. + scoped=True also requires each request, redirects included, to be in_scope.""" + handler_order = 900 # after the default processors + + def __init__(self, credential, scoped=False): + self.credential, self.scoped = credential, scoped + + def _request(self, req): + req.remove_header('Authorization') + allowed = in_scope if self.scoped else lambda c, url: c is not None and c.matches(url) + if allowed(self.credential, req.full_url): + req.add_unredirected_header('Authorization', self.credential.header()) + return req + + http_request = https_request = _request diff --git a/ui/apk_sources/_images.py b/ui/apk_sources/_images.py index 3d5ae4e..149c3bc 100644 --- a/ui/apk_sources/_images.py +++ b/ui/apk_sources/_images.py @@ -9,7 +9,7 @@ import threading import time from urllib.parse import urljoin, urlsplit -from apk_sources import SourceError +from apk_sources import SourceError, _credentials _lock = threading.Lock() _urls = OrderedDict() # token -> URL, oldest first @@ -168,7 +168,11 @@ def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE): path = p.path or '/' if p.query: path += '?' + p.query - conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})}) + sent = {'User-Agent': 'FrameControl/0.3.1', **(headers or {})} + login = _credentials.for_url(url) # a repository behind a login: its own artwork only, per hop + if login: + sent['Authorization'] = login.header() + conn.request('GET', path, headers=sent) live[0].settimeout(min(10, left())) response = conn.getresponse() if response.status in (301, 302, 303, 307, 308) and redirects: diff --git a/ui/apk_sources/_web.py b/ui/apk_sources/_web.py index 1952e7d..3aa528f 100644 --- a/ui/apk_sources/_web.py +++ b/ui/apk_sources/_web.py @@ -3,7 +3,7 @@ import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.pars from email.utils import parsedate_to_datetime import frame_host -from . import SourceError, SourceLimited +from . import SourceAuthError, SourceError, SourceLimited, _credentials UA = 'FrameControl/0.1' APK_CAP = 2 * 1024 ** 3 # cached APKs across all sources, least recently used go first @@ -38,7 +38,7 @@ def release(path): else: _in_use.pop(path, None) BACKOFF = 600 # seconds to leave a host alone after 403/429 without Retry-After -_limited = {} # host -> time.time() before which we don't contact it +_limited = {} # host (+ login identity) -> time.time() before which we don't ask it with that login _limited_lock = threading.Lock() @@ -46,8 +46,16 @@ def _host(url): return (urllib.parse.urlsplit(url).hostname or '').lower() -def throttle(url, headers=None): - """Remember that url's host asked us to back off; return the delay in seconds.""" +def _who(url, auth=None): + """Backoff key: the host, per login. A token has its own quota, so anonymous + rate limiting mustn't hold back requests made with one (or the reverse).""" + if auth is None: + return _host(url) + return _host(url) + ' ' + hashlib.sha256(auth.header().encode()).hexdigest()[:16] + + +def throttle(url, headers=None, auth=None): + """Remember that url's host asked us (with this login) to back off; return the delay in seconds.""" headers = headers or {} now, delay = time.time(), None value = (headers.get('Retry-After') or '').strip() @@ -62,14 +70,15 @@ def throttle(url, headers=None): if delay is None and headers.get('X-RateLimit-Remaining') == '0' and reset.isdigit(): delay = int(reset) - now # GitHub delay = min(max(delay if delay is not None else BACKOFF, 1), 6 * 3600) + key = _who(url, auth) with _limited_lock: - _limited[_host(url)] = max(_limited.get(_host(url), 0), now + delay) + _limited[key] = max(_limited.get(key, 0), now + delay) return delay -def wait_time(url): +def wait_time(url, auth=None): with _limited_lock: - return max(0, _limited.get(_host(url), 0) - time.time()) + return max(0, _limited.get(_who(url, auth), 0) - time.time()) def limited_error(name, seconds, hint=''): @@ -136,6 +145,8 @@ def checked_url(url, hosts): class Redirect(urllib.request.HTTPRedirectHandler): + """Only to the allowed hosts, and never carrying an Authorization header along: + a credential passed to open_url() is added again, by its Handler, only on its origin.""" def __init__(self, hosts): self.hosts = hosts @@ -147,13 +158,15 @@ class Redirect(urllib.request.HTTPRedirectHandler): return result -def open_url(url, hosts, headers=None): +def open_url(url, hosts, headers=None, auth=None): + """auth: an _credentials.Credential, sent only to requests on its own origin.""" checked_url(url, hosts) - return urllib.request.build_opener(Redirect(hosts)).open( + handlers = [Redirect(hosts)] + ([_credentials.Handler(auth)] if auth else []) + return urllib.request.build_opener(*handlers).open( urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60) -def read(url, hosts, headers=None, ttl=3600, name=None, hint=''): +def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None): path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data') name = name or _host(url) or 'The source' @@ -165,13 +178,13 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''): if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl: with open(path, 'rb') as f: return f.read() - wait = wait_time(url) + wait = wait_time(url, auth) if wait: data = cached() if data is None: raise limited_error(name, wait, hint) return data - with open_url(url, hosts, headers) as r: + with open_url(url, hosts, headers, auth) as r: data = r.read(8 * 1024 * 1024 + 1) if len(data) > 8 * 1024 * 1024: raise SourceError('Source index is too large') @@ -185,8 +198,10 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''): os.remove(tmp) return data except urllib.error.HTTPError as e: + if e.code == 401: # not 403: GitHub also uses that for rate limits, token or not + raise SourceAuthError('%s refused the login (HTTP 401)' % name) from e if e.code in (403, 429): - delay = throttle(url, e.headers) + delay = throttle(url, e.headers, auth) data = cached() if data is None: raise limited_error(name, delay, hint) from e diff --git a/ui/apk_sources/fdroid.py b/ui/apk_sources/fdroid.py index 1c73e2c..a9b17bc 100644 --- a/ui/apk_sources/fdroid.py +++ b/ui/apk_sources/fdroid.py @@ -20,7 +20,7 @@ import zipfile if __package__ in (None, ''): sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from apk_sources import SourceError, SourceLimited, _web +from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _web import frame_host from frame_apk_sign import _der_parts, _cert_key, der from frame_catalog import _IndexReader, _reduce_index, _sha256 @@ -71,6 +71,39 @@ def _url(url, fingerprint=None): return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin +def _split_login(url, username=None, password=None): + """(url without user:pass, credential or None). The address may carry the login, as + F-Droid's client accepts (https://user:pass@host/fdroid/repo), or it may be given apart.""" + try: + p = urllib.parse.urlsplit(url) + p.port + except ValueError as e: + raise SourceError('invalid repository URL') from e + if '@' in p.netloc: + if p.username is None: + raise SourceError('invalid repository URL') + linked = urllib.parse.unquote(p.username), None if p.password is None else urllib.parse.unquote(p.password) + if username not in (None, '', linked[0]) or (linked[1] is not None and password not in (None, '', linked[1])): + raise SourceError('the address and the username/password fields disagree') + username = linked[0] + password = linked[1] if linked[1] is not None else password + url = urllib.parse.urlunsplit((p.scheme, p.netloc.rpartition('@')[2], p.path, p.query, p.fragment)) + if username in (None, ''): + if password: + raise SourceError('a password needs a username') + return url, None + clean, _ = _url(url) + try: + return url, _credentials.basic(clean, username, password or '') + except ValueError as e: + raise SourceError('cannot use that login: ' + str(e)) from None + + +def _login(source): + """The saved login for a user repository, or None.""" + return None if source.get('builtin') else _credentials.get(source['id']) + + def _child(base, name): name = str(name).lstrip('/') decoded = urllib.parse.unquote(name) @@ -81,6 +114,9 @@ def _child(base, name): url = urllib.parse.urljoin(base, urllib.parse.quote(decoded, safe='/')) # names may contain '#' or spaces if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment: raise SourceError('repository file is outside its repository') + # Nothing a server could normalise elsewhere: double-encoded dots, ';' parameters, encoded slashes. + if _credentials.path_segments(urllib.parse.urlsplit(url).path) is None: + raise SourceError('unsafe repository file name') return url @@ -91,14 +127,17 @@ class _HTTPSRedirect(urllib.request.HTTPRedirectHandler): return super().redirect_request(req, fp, code, msg, headers, newurl) -def _fetch(url, path, maximum): +def _fetch(url, path, maximum, auth=None): + """auth: the repository's login, sent only inside the repository (its origin and directory, + by whole plain path segments), on every request including redirects.""" host = urllib.parse.urlsplit(url).hostname - wait = _web.wait_time(url) + wait = _web.wait_time(url, auth) if wait: raise _web.limited_error(host, wait) request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'}) + handlers = [_HTTPSRedirect()] + ([_credentials.Handler(auth, scoped=True)] if auth else []) try: - with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f: + with urllib.request.build_opener(*handlers).open(request, timeout=60) as r, open(path, 'wb') as f: total = 0 while True: chunk = r.read(1 << 20) @@ -109,8 +148,11 @@ def _fetch(url, path, maximum): raise SourceError('repository file exceeds size limit') f.write(chunk) except urllib.error.HTTPError as e: + if e.code == 401 or (e.code == 403 and auth): + raise SourceAuthError('The repository refused the login (HTTP %d); check the username and password' + % e.code if auth else 'This repository needs a username and password') from None if e.code in (403, 429): - raise _web.limited_error(host, _web.throttle(url, e.headers)) from e + raise _web.limited_error(host, _web.throttle(url, e.headers, auth)) from e raise @@ -342,7 +384,7 @@ def sources(): return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True, # The archive only holds superseded versions; it clutters search unless asked for. enabled=settings['enabled'].get(i, i != 'fdroid-archive'), trust='community') - for i, n, u, p in builtins] + settings['repos'] + for i, n, u, p in builtins] + [dict(s, login=_credentials.has(s['id'])) for s in settings['repos']] def _text(value): @@ -516,10 +558,12 @@ def _source_lock(source_id): return _load_locks.setdefault(source_id, threading.Lock()) -def _load(source, force=False): +def _load(source, force=False, auth=None): + """auth: a login to use instead of the saved one (while adding, before anything is saved).""" if not re.fullmatch(r'[a-z0-9-]+', source['id']): raise SourceError('invalid source id') _url(source['url'], source.get('fingerprint')) + auth = auth or _login(source) cache = frame_host.cache_dir('apk-sources', source['id'] + '.json') found = None if force else _cached(source, cache) if found: @@ -537,7 +581,7 @@ def _load(source, force=False): jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json' v2 = True try: - _fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024) + _fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024, auth=auth) except urllib.error.HTTPError as e: if e.code not in (404, 410): raise @@ -545,7 +589,7 @@ def _load(source, force=False): raise SourceError('repository no longer serves its signed v2 index; ' 'refusing to fall back to the older v1 index') from e v2 = False - _fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024) + _fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024, auth=auth) content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint')) timestamp = _v1(content, raw) _check_timestamp(source, timestamp) @@ -554,7 +598,7 @@ def _load(source, force=False): signed = json.loads(content) timestamp, entry = signed.get('timestamp'), signed['index'] _check_timestamp(source, timestamp) - _fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024) + _fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024, auth=auth) if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']): raise SourceError('index SHA-256 or size mismatch') apps = _reduce(raw, source) @@ -574,7 +618,10 @@ def _load(source, force=False): raise SourceError('cannot load repository: ' + str(e)) from e -def add_repo(url, fingerprint=None, name=None): +def add_repo(url, fingerprint=None, name=None, username=None, password=None): + """Check and save a repository. A login (in the URL or apart) is kept out of the + saved URL, in _credentials, and saved only once the repository's index verified.""" + url, login = _split_login(url, username, password) url, pin = _url(url, fingerprint) with _LOCK: existing = next((s for s in _read()['repos'] if s['url'] == url), None) @@ -585,15 +632,26 @@ def add_repo(url, fingerprint=None, name=None): source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND, name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname, url=url, builtin=False, enabled=True, trust='user', fingerprint=pin) - _, source['fingerprint'] = _load(source, force=True) # network work outside the settings lock + # Network work outside the settings lock; without a new login, a saved one (re-adding) is used. + _, source['fingerprint'] = _load(source, force=True, auth=login) source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None with _LOCK: settings = _read() current = next((s for s in settings['repos'] if s['id'] == source['id']), None) if current and current['fingerprint'] != source['fingerprint']: raise SourceError('repository already has a different pinned fingerprint; remove it first') - settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source] - _write(_storage(), settings) + previous = _credentials.get(source['id']) + if login: + _credentials.put(source['id'], login) + try: + settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source] + _write(_storage(), settings) + except BaseException: + if login and previous: # not saved after all: leave the login as it was + _credentials.put(source['id'], previous) + elif login: + _credentials.remove(source['id']) + raise return source @@ -604,6 +662,7 @@ def remove_repo(source_id): raise SourceError('unknown user repository') settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id] _write(_storage(), settings) + _credentials.remove(source_id) with _state_file_lock(), _LOCK: # lock order: state file, then _LOCK states = _states() if states.pop(source_id, None) is not None: # re-adding is a deliberate new trust decision @@ -688,7 +747,7 @@ def download(source, entry_id, version_code=None): fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part') os.close(fd) try: - _fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3) + _fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3, auth=_login(source)) if _sha256(tmp) != sha: raise SourceError('APK SHA-256 mismatch; download discarded') os.replace(tmp, path) @@ -709,6 +768,8 @@ def main(): add.add_argument('url') add.add_argument('--fingerprint') add.add_argument('--name') + add.add_argument('--username', help='for a repository behind a login (HTTP basic auth)') + add.add_argument('--password-stdin', action='store_true', help='read the password from the first line of stdin') sub.add_parser('list') remove = sub.add_parser('remove') remove.add_argument('source') @@ -727,7 +788,12 @@ def main(): def _cli(parser, args): try: if args.command == 'add': - result = add_repo(args.url, args.fingerprint, args.name) + password = None + if args.password_stdin: + if not args.username: + raise SourceError('--password-stdin needs --username') + password = sys.stdin.readline().rstrip('\r\n') + result = add_repo(args.url, args.fingerprint, args.name, args.username, password) elif args.command == 'list': result = sources() elif args.command == 'remove': diff --git a/ui/apk_sources/github.py b/ui/apk_sources/github.py index e636f2a..a12ed57 100644 --- a/ui/apk_sources/github.py +++ b/ui/apk_sources/github.py @@ -1,17 +1,77 @@ """Curated publisher releases; optional topic discovery is page-only.""" -import fnmatch, json, os, re, urllib.parse +import fnmatch, hashlib, json, os, re, sys, threading, urllib.parse -from . import SourceError, _web +from . import SourceAuthError, SourceError, _credentials, _web KIND = 'github' API = 'https://api.github.com' +ENV = 'FRAME_GITHUB_TOKEN' # wins over a token saved in source settings +CREDENTIAL = 'github' # _credentials key; only ever sent to api.github.com +_rejected = set() # SHA-256 of tokens GitHub answered 401 to (this run only) +_rejected_lock = threading.Lock() TOPICS = ('oculus-quest', 'openxr', 'quest') HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com') def sources(): return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com', - 'builtin': True, 'enabled': True, 'trust': 'community'}] + 'builtin': True, 'enabled': True, 'trust': 'community', 'token': token_status()}] + + +def _token(): + """(token, where it came from) or (None, None). The environment variable wins.""" + env = os.environ.get(ENV) + if env: + return env, 'env' + saved = _credentials.get(CREDENTIAL) + if saved and saved.kind == 'bearer': + return saved, 'saved' + return None, None + + +def _credential(token): + if isinstance(token, _credentials.Credential): + return token + try: + return _credentials.bearer(API + '/', token) + except ValueError as e: + raise SourceError('%s is not a usable GitHub token: %s' % (ENV, e)) from None + + +def _digest(credential): + return hashlib.sha256(credential.header().encode()).hexdigest() + + +def token_status(): + """Whether a token is set, where from, and whether GitHub rejected it; never the token.""" + token, where = _token() + rejected = False + if token is not None: + try: + with _rejected_lock: + rejected = _digest(_credential(token)) in _rejected + except SourceError: + rejected = True + return {'set': token is not None, 'from': where, 'rejected': rejected} + + +def set_token(token): + """Save a personal access token for api.github.com (FRAME_GITHUB_TOKEN still wins).""" + if not isinstance(token, str) or not token.strip(): + raise SourceError('Paste a GitHub token') + try: + credential = _credentials.bearer(API + '/', token.strip()) + except ValueError as e: + raise SourceError('That is not a usable GitHub token: ' + str(e)) from None + _credentials.put(CREDENTIAL, credential) + with _rejected_lock: + _rejected.discard(_digest(credential)) # a fresh chance + return {'message': 'GitHub token saved' + (' (%s is set and is used instead)' % ENV if os.environ.get(ENV) else '')} + + +def clear_token(): + _credentials.remove(CREDENTIAL) + return {'message': 'GitHub token removed'} def _curated(): @@ -20,13 +80,33 @@ def _curated(): def _api(path): + """GET from the GitHub API, with the user's token if there is one. If GitHub rejects + the token (401), retry once without it, like Obtainium, and remember the rejection.""" headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'} - token = os.environ.get('FRAME_GITHUB_TOKEN') - if token: - headers['Authorization'] = 'Bearer ' + token + token, _ = _token() + auth = _credential(token) if token is not None else None + with _rejected_lock: + if auth and _digest(auth) in _rejected: + auth = None # already refused this run: don't spend a request on it again + hint = ' (add a GitHub token in the store\'s source settings, or set %s, to raise the limit)' % ENV + if token is not None and not auth: + hint = ' (GitHub rejected your token; replace it in the store\'s source settings)' + + def get(auth): + return _web.read(API + path, ('api.github.com',), headers, name='GitHub', + hint='' if auth else hint, auth=auth) try: - return json.loads(_web.read(API + path, ('api.github.com',), headers, name='GitHub', - hint='' if token else ' (set FRAME_GITHUB_TOKEN to raise the limit)')) + try: + data = get(auth) + except SourceAuthError: + if not auth: + raise + with _rejected_lock: + _rejected.add(_digest(auth)) + print('GitHub rejected the access token (HTTP 401); continuing without it', file=sys.stderr) + hint = ' (GitHub rejected your token; replace it in the store\'s source settings)' + data = get(None) + return json.loads(data) except (ValueError, TypeError) as e: raise SourceError('Invalid GitHub response') from e diff --git a/ui/apk_sources/search.py b/ui/apk_sources/search.py index f96b579..650d2ce 100644 --- a/ui/apk_sources/search.py +++ b/ui/apk_sources/search.py @@ -114,6 +114,14 @@ def manage_repo(action, **kwargs): return getattr(module, action)(**kwargs) +def set_token(source_id, token): + """Save (token) or remove (None) a source's access token; status is in sources().""" + module, _ = resolve(source_id) + if not hasattr(module, 'set_token'): + raise SourceError('This source does not use an access token') + return module.set_token(token) if token is not None else module.clear_token() + + def normalise(name): return ' '.join(re.findall(r'\w+', unicodedata.normalize('NFKC', name or '').casefold())) diff --git a/ui/frame_android.py b/ui/frame_android.py index 0738c04..74b5262 100644 --- a/ui/frame_android.py +++ b/ui/frame_android.py @@ -7,7 +7,7 @@ in the Steam library and gets its own SteamVR panel. Nothing goes through Lepton Development, which wipes its apps on exit. See docs/apks.md. Python stdlib only. CLI: python3 ui/frame_android.py - install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG + install APK [SPLIT.apk ...] [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG """ @@ -18,7 +18,7 @@ import frame_artwork import frame_host import tempfile import zipfile -from frame_apk_vr import add_launcher_category +from frame_apk_vr import add_launcher_category, drop_split_required from frame_apk_sign import repack ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -142,7 +142,9 @@ def check_installable(info): "(or arm64, or universal) and install that instead") -_install_lock = threading.Lock() # installs are rare; one at a time avoids every race +# Installs are rare; one at a time avoids every race. Reentrant so a caller (a Quest import's +# final checks and data copy) can hold it around install_obb and install, which take it too. +_install_lock = threading.RLock() # The slowest transfer a copy is allowed before it times out, in bytes a second (about 4 Mbit/s, # a slow or busy Wi-Fi link). A fixed 10 minutes failed any APK over about 300 MB at that rate. COPY_MIN_RATE = 512 * 1024 @@ -242,7 +244,303 @@ def _finish_update_recovery(d): install_hooks = [] -def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None): +# Split APK members that belong to the split itself, not the app: never merged. +SPLIT_OWN = re.compile(r'^(AndroidManifest\.xml|stamp-cert-sha256|META-INF/.*)$') +# What a mergeable split may add: native libraries and assets, nothing else. +SPLIT_ADDS = re.compile(r'^(lib/[^/]+/[^/]+\.so|assets/.*[^/])$') +# Manifest elements a config or install-time asset split carries. Anything else (an +# activity, service, uses-library...) belongs to a feature split, which needs its manifest. +SPLIT_TAGS = {'manifest', 'application', 'uses-sdk', 'module', 'delivery', 'install-time', 'fusing'} +# Every merged byte is held in memory while the APK is repacked and signed (as for every +# patched APK), so splits are bounded by what's actually inflated, not only the headers. +MAX_SPLIT_MEMBER = 512 * 1024**2 +MAX_SPLIT_TOTAL = 1024**3 + + +def _split_manifest(path, name): + """(package, split name) from a split APK's manifest, refusing splits that declare + anything the merged app would lose with that manifest.""" + with zipfile.ZipFile(path) as z: + elements = frame_apk.manifest_elements(frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST)) + attrs = next((a for tag, a in elements if tag == 'manifest'), {}) + split = (attrs.get('split') or (None, None, None))[2] + feature = attrs.get('isFeatureSplit') + if feature and feature[1]: + raise FrameError(f'{name} ({split or "split"}) is a feature split with its own code and manifest; ' + 'Frame Control can only merge splits that add native libraries or assets') + extra = sorted({tag for tag, _ in elements if tag not in SPLIT_TAGS}) + if extra: + raise FrameError(f'{name} ({split or "split"}) declares <{">, <".join(extra)}> in its manifest; ' + 'Frame Control can only merge splits that add native libraries or assets') + return (attrs.get('package') or (None, None, None))[2], split + + +def split_entries(base, splits): + """What merging splits into base adds: {entry name: (split path, entry name, size)}. + + Lepton runs one APK per instance, so a split app is merged into one APK. Only splits + that add native libraries (the arm64 ABI split) or install-time assets can be: a split + with code, components or resources (screen density, language) needs its own manifest + or resource table, which Frame Control doesn't merge, so those are refused.""" + try: + with zipfile.ZipFile(base) as z: + taken = set(z.namelist()) + package = apk_info(base)['package'] + plan, total = {}, 0 + for path in splits: + name = os.path.basename(path) + split_pkg, split_name = _split_manifest(path, name) + label = f'{name} ({split_name or "split"})' + if split_pkg != package: + raise FrameError(f'{name} is not part of {package} (it says {split_pkg or "nothing"})') + with zipfile.ZipFile(path) as z: + for member in z.infolist(): + entry = member.filename + if entry.endswith('/') or SPLIT_OWN.match(entry): + continue + if entry == 'resources.arsc': + # A config split for native code may carry an empty table; any resource needs merging. + if not resources_empty(frame_apk._read(z, entry, frame_apk.MAX_ARSC)): + raise FrameError(f'{label} holds screen-density or language resources; Frame Control ' + 'can only merge splits that add native libraries or assets') + continue + if entry.startswith('res/'): + raise FrameError(f'{label} holds screen-density or language resources ({entry}); Frame ' + 'Control can only merge splits that add native libraries or assets') + if re.match(r'^classes\d*\.dex$', entry): + raise FrameError(f'{label} contains code ({entry}); Frame Control can only merge splits ' + 'that add native libraries or assets') + if not SPLIT_ADDS.match(entry): + raise FrameError(f'{label} contains {entry}; Frame Control can only merge splits that ' + 'add native libraries or assets') + if entry in taken: + raise FrameError(f'{name} and another part of the app both contain {entry}') + if member.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + raise FrameError(f'{entry} in {name} uses a compression Android does not') + if member.file_size > MAX_SPLIT_MEMBER: + raise FrameError(f'{entry} in {name} is {member.file_size / 1024**2:.0f} MB, more than ' + f'Frame Control merges ({MAX_SPLIT_MEMBER // 1024**2} MB)') + total += member.file_size + if total > MAX_SPLIT_TOTAL: + raise FrameError(f'the split APKs add more than {MAX_SPLIT_TOTAL // 1024**3} GB, more than ' + 'Frame Control merges') + taken.add(entry) + plan[entry] = (path, entry, member.file_size) + return plan + except (OSError, zipfile.BadZipFile, struct.error, IndexError, frame_apk.ApkError) as e: + raise FrameError(f'could not read the split APKs: {e}') from e + + +def resources_empty(data): + """True only for a resource table that provably defines nothing: every type chunk's + entries absent. Bags (styles, plurals) count, and anything not understood counts too.""" + def covered(start, end): + chunks, at = [], start + for chunk in frame_apk._chunks(data, start, end): + if chunk[2] != at: + return None + chunks.append(chunk) + at = chunk[2] + chunk[3] + return chunks if at == end else None + + try: + if len(data) < 12 or struct.unpack_from(' len(body): + raise FrameError('truncated ZIP64 extra field') + values.append(struct.unpack_from(' MAX_SPLIT_MEMBER or info.compress_size > MAX_SPLIT_MEMBER: + raise FrameError(f'{name} is larger than Frame Control merges ({MAX_SPLIT_MEMBER // 1024**2} MB)') + following = next((o for o in starts if o > info.header_offset), None) + with open(path, 'rb') as raw: + raw.seek(info.header_offset) + header = raw.read(30) + if len(header) != 30 or header[:4] != b'PK\3\4': + raise FrameError(f'{name}: invalid local ZIP header') + flags, method, crc, csize, usize, nl, el = struct.unpack_from('<2xHH4xIIIHH', header, 4) + local_name, extra = raw.read(nl), raw.read(el) + if local_name != info.orig_filename.encode('utf-8' if flags & 0x800 else 'cp437', 'surrogateescape'): + raise FrameError(f'{name}: the local header names {local_name.decode("utf-8", "replace")!r}') + if method != info.compress_type or (flags & 0x08) != (info.flag_bits & 0x08): + raise FrameError(f'{name} does not match its header (local and central entries differ)') + start = info.header_offset + 30 + nl + el + compressed = raw.read(info.compress_size) + if len(compressed) != info.compress_size: + raise FrameError(f'{name} is truncated') + if flags & 0x08: + # Sizes and checksum follow the data: (signature,) crc, then sizes of 4 bytes each, + # or 8 for ZIP64. Streaming writers use the ZIP64 form even for small files when + # sizes weren't known up front, so the width isn't implied by the final sizes: take + # the layout that matches the central directory and fits before the next entry, + # preferring one that ends exactly there, then the width the local header implies. + zip64 = 0xffffffff in (csize, usize) or _has_zip64_extra(extra) + tail = raw.read(24) + if len(tail) < 12: + raise FrameError(f'{name}: truncated data descriptor') + end = start + info.compress_size + fits = [] + for signed in (True, False): + if signed and tail[:4] != b'PK\7\x08': + continue + for width in (20, 12): + body = tail[4:4 + width] if signed else tail[:width] + if len(body) < width: + continue + values = struct.unpack_from(' following: + raise FrameError(f'{name} overlaps the next ZIP entry') + if info.compress_type == zipfile.ZIP_STORED: + if info.compress_size != size: + raise FrameError(f'{name} does not match its header (stored {info.compress_size} bytes, says {size})') + data = compressed + else: + inflate = zlib.decompressobj(-15) + try: + data = inflate.decompress(compressed, size + 1) + except zlib.error as e: + raise FrameError(f'{name} is corrupt: {e}') from e + if len(data) != size or not inflate.eof or inflate.unconsumed_tail or inflate.unused_data: + raise FrameError(f'{name} does not match its header (its compressed data is not exactly ' + f'{size} bytes with nothing after it)') + if zlib.crc32(data) != info.CRC: + raise FrameError(f'{name} does not match its checksum') + return data + + +def merge_splits(base, splits, dst): + """One installable APK from a base APK and its splits (see split_entries), re-signed + with Frame Control's key, without the base's isSplitRequired flag.""" + plan = split_entries(base, splits) + add, total = {}, 0 + try: + for entry, (path, member, size) in plan.items(): + data = read_member(path, member) + total += len(data) + if total > MAX_SPLIT_TOTAL: + raise FrameError(f'the split APKs add more than {MAX_SPLIT_TOTAL // 1024**3} GB') + add[entry] = data + with zipfile.ZipFile(base) as z: + original = frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST) + manifest = drop_split_required(original) + repack(base, dst, replace={'AndroidManifest.xml': manifest} if manifest != original else None, add=add) + except (OSError, ValueError, IndexError, struct.error, zipfile.BadZipFile, frame_apk.ApkError) as e: + raise FrameError(f'could not merge the split APKs: {e}') from e + return {'merged': sorted(os.path.basename(p) for p in splits), 'entries': len(plan)} + + +def split_size(splits): + """Bytes a merge adds, from the split headers (merge_splits enforces the real sizes).""" + total = 0 + for path in splits: + with zipfile.ZipFile(path) as z: + total += sum(m.file_size for m in z.infolist() if SPLIT_ADDS.match(m.filename)) + return total + + +def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None, + splits=None): + """splits: the rest of a split APK (base first, as apk_path); merged into one APK first.""" + if splits: + start = time.time() + with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp: + merged = os.path.join(tmp, 'merged.apk') + try: + merge_splits(apk_path, list(splits), merged) + except Exception as e: # reported like any other failed install + _after_install(None, None, e, start) + raise + meta = install(merged, flatscreen, name, source or os.path.basename(apk_path), icon_png, xr_compat, artwork) + return meta start, info = time.time(), None try: info = apk_info(apk_path) @@ -332,6 +630,7 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None): 'source': source or os.path.basename(apk_path)}) presentation['warnings'] = art_warnings + presentation.get('warnings', []) meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'], + 'version_code': info.get('version_code'), 'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut), 'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []), 'launchable': info.get('launchable', False), 'patched': info.get('patched', []), @@ -464,6 +763,34 @@ except (OSError, ValueError, TypeError, AttributeError): """ +def _apk_parser_script(): + """Frame-side Python preamble that loads this copy's stdlib APK parser (frame_apk).""" + script = 'import sys, types, json, base64\n' + for module in ('frame_apk', 'frame_apk_vr'): + with open(os.path.join(ROOT, 'ui', module + '.py')) as f: + source = f.read() + script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n' + return script + + +def installed_version_code(pkg): + """The version code of pkg's installed app.apk, read on the Frame (only its manifest + crosses the link), or None if there is no readable APK. Connection failures raise.""" + if not PKG_RE.match(pkg or ''): + raise FrameError(f'bad package name {pkg!r}') + script = _apk_parser_script() + f"""try: + code = sys.modules['frame_apk'].apk_info({(APPS_DIR + '/' + pkg + '/app.apk')!r}).get('version_code') +except Exception: + code = None +print(json.dumps(code if isinstance(code, int) else None)) +""" + try: + code = json.loads(ssh('python3 -', input=script, timeout=120).strip().splitlines()[-1]) + except (ValueError, IndexError): + return None + return code if isinstance(code, int) and not isinstance(code, bool) else None + + def art_missing(m): """True when a Frame Control install has no complete Steam artwork on record.""" return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS) @@ -512,13 +839,7 @@ def refresh_art(pkg=None, artwork=None, fill_only=False): if orphan and orphan != m['shortcut']: shortcut_tool('remove', str(orphan)) # Parse the APK on the Frame, transferring only its icon/label, not the APK. - modules = {} - for module in ('frame_apk', 'frame_apk_vr'): - with open(os.path.join(ROOT, 'ui', module + '.py')) as f: - modules[module] = f.read() - script = 'import sys, types, json, base64\n' - for module, source in modules.items(): - script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n' + script = _apk_parser_script() script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n" script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n" script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n" @@ -587,10 +908,22 @@ def _clean_meta(m): return m -def running_instances(): - """Lepton container name -> adb port, for the instances that are running now.""" - out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true') - return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2) +def running_instances(strict=False): + """Lepton container name -> adb port, for the instances that are running now. + + strict: a failed `podman ps` raises FrameError instead of reading as "nothing running", + and containers without an adb port label count too (port None); for callers that must + not mistake an error for a stopped instance.""" + if not strict: + out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true') + return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2) + out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}"') + found = {} + for line in out.splitlines(): + cols = line.split() + if cols: + found[cols[0]] = cols[1] if len(cols) > 1 and cols[1] != ', for a base APK whose + splits were merged into it: Android refuses a lone base that still says it needs them.""" + chunks = list(frame_apk._chunks(axml_bytes, 8, len(axml_bytes))) + pool = next((c for c in chunks if c[0] == 1), None) + if pool is None: + raise frame_apk.ApkError('AndroidManifest.xml has no string pool') + strings = frame_apk._string_pool(axml_bytes, pool[2]) + resmap = () + for kind, header_size, offset, chunk_size in chunks: + if kind == 0x180: + resmap = struct.unpack_from('<%dI' % ((chunk_size - header_size) // 4), axml_bytes, offset + header_size) + result, changed = bytearray(axml_bytes[:8]), False + for kind, hs, off, size in chunks: + chunk = bytearray(axml_bytes[off:off + size]) + if kind == 0x0102 and not changed: + name, astart, asize, count = struct.unpack_from('<4xIHHH', chunk, hs) + if name < len(strings) and strings[name] == 'application': + for i in range(count): + a = hs + astart + i * asize + attr = struct.unpack_from(' i + 1 else index) + changed = True + break + result += chunk + if not changed: + return axml_bytes + struct.pack_into('= 32: raise DeviceError("That's enough addresses for one headset") - d["addresses"].append(a) + d["addresses"].insert(0 if first else len(d["addresses"]), a) self.save() return copy.deepcopy(a) diff --git a/ui/frame_link.py b/ui/frame_link.py index 4443c6d..d38d9f2 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -356,6 +356,7 @@ class Link: self.version = 0 self.stopped = False self.kicks = [] # reasons someone asked for a (re)connect + self.test_gen = {} # device id -> its newest test of the addresses (see test()) self.busy = False # the loop is handling kicks self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [], "probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0, @@ -1306,9 +1307,13 @@ class Link: started = now() rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None, "rtt_ms": None, "ssh": None} for a in device["addresses"]] + with self.cond: + gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1 def put(**fields): with self.cond: + if gen != self.test_gen[device_id]: + return # a newer test has started: its results are the ones to show self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields) self.version += 1 self.cond.notify_all() @@ -1617,7 +1622,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0): raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}") msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "") elif action == "address-add": - a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "") + a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "", + first=body.get("first") is True) if is_active and link.state["phase"] == "failed": link.kick("retry") msg = f"Added {a['host']}" diff --git a/ui/frame_meta.py b/ui/frame_meta.py new file mode 100644 index 0000000..9739bfa --- /dev/null +++ b/ui/frame_meta.py @@ -0,0 +1,1442 @@ +"""My Meta library (Experimental): install Quest games you own from Meta, with your own token. + +Opt-in and off until you connect. You paste your own Meta access token (docs/meta-library.md +says how to get it); Frame Control checks it with a harmless "who am I" query, then keeps it +in a private file (frame_host.data_dir('meta-account.json'), owner-only). It lists the Quest +(Android) apps your account owns, downloads one you choose (its APK, plus its OBB game data +when it has some) from Meta's download server into a per-user cache, and installs it on the +Frame through the same checks and install as Import from my Quest (frame_quest.py). Install +and test then runs the game and reads its log to say whether it runs, and records the +result through the existing compatibility reports (frame_catalog.add_report), which share +it only if you chose to share compatibility results. + +This uses Meta's undocumented internal API (graph.oculus.com, securecdn.oculus.com), as the +open-source tools QAVS, DownQuest and BSManager do; it may break whenever Meta changes it and +may be against Meta's terms. Frame Control's code is its own (MIT); only the protocol facts +(endpoints, query ids, field names) come from those tools' public source (docs/meta-library.md). + +Rules this module keeps: +- Only games the account owns: a download must be an app in the account's own library. +- No licence-check bypass of any kind: games are installed as Meta ships them. +- The token goes only to TOKEN_HOSTS over HTTPS, never into logs, errors, API answers, + the page or reports (redact()); a redirect elsewhere carrying it is refused. +- Game files stay in the local install cache and are deleted after each install. + +Python stdlib only. CLI: python3 ui/frame_meta.py + connect --token-stdin | status | disconnect | list [--refresh] | download APP_ID + install APP_ID [--test] [--allow-downgrade] | test PACKAGE [--wait SECONDS] [--no-report] +""" +import concurrent.futures +import hashlib +import http.client +import ipaddress +import json +import os +from pathlib import Path +import re +import secrets +import shutil +import socket +import ssl +import sys +import tempfile +import threading +import time +import urllib.parse +import zipfile + +import frame_android as android +import frame_host +import frame_quest as quest + +GRAPH_URL = 'https://graph.oculus.com/graphql' +DOWNLOAD_URL = 'https://securecdn.oculus.com/binaries/download/' +# The only hosts the token is ever sent to. Downloads may redirect elsewhere (a CDN): the +# token never goes there: a redirect carrying it in any form is refused (next_hop). +TOKEN_HOSTS = frozenset({'graph.oculus.com', 'securecdn.oculus.com'}) +# Stored queries ("doc_id") as the public tools use them (docs/meta-library.md). +DOC_VIEWER = '4149322231793299' # data.viewer.user {id, alias} +DOC_ENTITLEMENTS = '4850747515044496' # data.viewer.user.active_entitlements.nodes[].item +DOC_BINARY = '4734929166632773' # one binary: package, version, size, OBB and asset files +QUEST_PLATFORMS = frozenset({'ANDROID_6DOF'}) # Quest apps; Rift (PC) and Go (3DOF) are left out +# Entitlement states that mean the account owns the app. A real account's purchases all +# came back PERMANENT (2026-10-14); ACTIVE, which this was first written for, is kept too. +OWNED_STATES = frozenset({'ACTIVE', 'PERMANENT'}) +UA = 'FrameControl/1 (My Meta library)' +ACCOUNT_FILE = 'meta-account.json' + +MAX_RESPONSE = 32 * 1024**2 # one API answer +MAX_APK = 2 * 1024**3 # as for every other APK download (apk_sources/_web.py) +MAX_DATA_FILE = 8 * 1024**3 # one OBB +MAX_TOTAL = 16 * 1024**3 # everything one install downloads +MAX_APPS = 1000 # owned apps listed +MAX_DETAILS = 200 # binary queries per library refresh (the rest when installed) +DETAIL_WORKERS = 4 +MAX_REDIRECTS = 5 +LIBRARY_TTL = 600 +CACHE_TTL = 6 * 3600 # a download a crash left behind is swept after this +TIMEOUT = 30 +TEST_WAIT = 30 # seconds a test watches the game after its instance is up +TEST_STEP = 10 # ...looking at it this often +START_TIMEOUT = 90 + +WARNING = ("Experimental. My Meta library uses Meta's undocumented internal API with your own Meta " + "access token. It may stop working whenever Meta changes things, and using it may be against " + "Meta's terms; your account is your responsibility. It only lists and downloads games your " + "account owns, never changes them, and never removes a licence check.") +NOT_CONNECTED = 'Connect your Meta account first (My Meta library, Experimental).' +REJECTED = ("Meta didn't accept this token. It may have expired or been signed out: get a new one " + "(docs/meta-library.md) and connect again.") +RAN_PLATFORM_SDK = ("It uses Meta's licence check; on the Frame that check can't reach Meta, so the " + "game may quit, hang, or run without online features. Frame Control won't remove the check.") + + +class MetaError(android.FrameError): + """A readable failure. Its text never carries the token (built with redact()).""" + + +class TokenRejected(MetaError): + pass + + +class SignInCancelled(MetaError): + """The desktop app's Sign in with Meta window gave up on this attempt (closed, timed + out, signed out), so a connect it had started is not saved.""" + + +# ---- Keeping the token out of everything --------------------------------------- + +REDACTED = '' +# What a Meta user token may be made of, used both to accept one (check_token) and to find +# token-shaped values to redact (_GENERIC), so anything accepted is always redacted. The +# public tools only check the OC/FRL prefix and refuse % | : (docs/meta-library.md); the +# tokens they describe are letters and digits, so this allows those plus the base64 and +# URL-safe base64 characters (+ / = - _) and nothing else. Widen it here, in one place, if a +# real token needs more. +TOKEN_CHARS = r'A-Za-z0-9+/=_\-' # a regex character-class body +TOKEN_MIN = 20 # characters, prefix included +# Any token-shaped value, whoever's it is, in this order: bare OC.../FRL... tokens of the +# shape check_token accepts first (so a token that happens to contain "access_token=" goes +# whole, prefix and all), then access_token=... / "access_token": "..." parameters and +# Authorization schemes. +# +# The token shape: the OC or FRL prefix check_token requires and its minimum length, over +# the characters tokens are made of (TOKEN_CHARS, plus % | : of encoded and app tokens in +# messages), digits or not. No word boundary or lookbehind: a token is found whatever comes +# before it (_OC..., /OC..., libOC...); the run then takes every token character after the +# prefix, so an accepted token is always inside one match. Upper-case prefix, so package +# names (com.oculus...) are left alone. One run per match, no lookahead: linear. The stored +# token itself is matched in any case by _clean. +_TOKEN_SHAPE = re.compile(r'(?:OC[%s%%|:]{%d,}|FRL[%s%%|:]{%d,})' + % (TOKEN_CHARS, TOKEN_MIN - 2, TOKEN_CHARS, TOKEN_MIN - 3)) +_GENERIC = [ + _TOKEN_SHAPE, + re.compile(r'(?i)(access[_%5f-]*token["\']?\s*(?:=|:|%3d)\s*["\']?)[^&\s"\'<>,;]+'), + re.compile(r'(?i)\b(bearer|oauth)\s+[^\s"\'<>,;]+'), +] +# Upper-case constants separated by underscores (OCULUS_PLATFORM_SDK_VERSION) are names in +# logs and are kept. check_token refuses any value without a lower-case letter, so a run +# holding an accepted token (which has one) can never look like this. +_CONSTANT = re.compile(r'[A-Z0-9]+(?:_[A-Z0-9]+)+') + + +def _clean_spans(message, token): + """Where in message the token is, or any start of it (8+ characters, as truncation leaves), in + any letter case, with any of its characters written literally, percent-encoded (%4B or + %4b) or percent-encoded twice (%254B, %254b). Mirrors _itch_account._clean on + feat/itch-purchases (plus the double encoding), so the two can be unified.""" + if not token: + return [] + # Each character: itself, or the (once or twice) percent-encoding of either of its letter + # cases; hex digits in any case (re.I). + tokens = ['(?:%s)' % '|'.join([re.escape(c)] + [f % ord(v) for v in sorted({c.lower(), c.upper()}) + for f in ('%%%02x', '%%25%02x')]) + for c in token] + start, rest = re.compile(''.join(tokens[:8]), re.I), [re.compile(t, re.I) for t in tokens[8:]] + spans, pos = [], 0 + while True: + found = start.search(message, pos) + if not found: + break + end = found.end() + for t in rest: # as much more of the token as follows + more = t.match(message, end) + if not more: + break + end = more.end() + spans.append((found.start(), end)) + # Look again from the next character, not the end: a partial match just before the + # real token can overlap its start. Overlapping spans merge in _replace. + pos = found.start() + 1 + return spans + + +def _clean(message, token): + """message without the token in any of the forms _clean_spans finds.""" + return _replace(message, _clean_spans(message, token)) + + +def _replace(text, spans): + """text with every span replaced by REDACTED; overlapping or touching spans merge first, + so no part of a value found by one rule survives because another rule found the rest.""" + out, pos = [], 0 + for a, b in sorted(spans): + if out and a <= pos: # overlaps or touches the previous span + pos = max(pos, b) + continue + out += [text[pos:a], REDACTED] + pos = b + return ''.join(out) + text[pos:] + + +def _generic_spans(text): + spans = [] + for pattern in _GENERIC: + for m in pattern.finditer(text): + if pattern is _TOKEN_SHAPE: + if not _CONSTANT.fullmatch(m.group(0)): + spans.append(m.span()) + else: # keep the parameter or scheme name, redact its value + spans.append((m.end(1), m.end()) if m.groups() else m.span()) + return spans + + +def carries_token(url, token): + """True if url holds the token (or 8+ characters of its start), however cased or encoded, + anywhere (host, path, query or fragment), or an access_token parameter of any value. + Mirrors _itch_account._carries_key on feat/itch-purchases.""" + for form in (url, urllib.parse.unquote(url), urllib.parse.unquote_plus(url)): + if _clean(form, token) != form: + return True + names = {name.lower() for name, _ in urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query, + keep_blank_values=True)} + return 'access_token' in names + + +def redact(text, limit=None, token=None): + """text without the stored token (and any other token-shaped value), then cut to limit + characters. Redacting before cutting means a cut can't leave part of a token behind.""" + text = '' if text is None else str(text) + # Every rule finds its spans in the original text and overlapping spans merge, so one + # rule removing part of a value can never stop another from recognising the rest. + spans = _generic_spans(text) + for t in {t for t in (token, _stored_token_quiet()) if t}: + spans += _clean_spans(text, t) + text = _replace(text, spans) + if limit is not None and len(text) > limit: + text = text[:max(0, limit - 1)] + '…' + return text + + +def check_token(token): + """The token, stripped, if it looks like a Meta user access token; else MetaError. The + rules match what the public tools check before sending one anywhere.""" + token = (token or '').strip() + if token.lower().startswith('bearer '): + token = token[7:].strip() + if not token: + raise MetaError('Paste your Meta access token first.') + if len(token) > 4096 or not re.fullmatch(r'[\x21-\x7e]+', token): + raise MetaError("That doesn't look like a Meta access token (it has spaces or unusual characters).") + if not (token.startswith('OC') or token.startswith('FRL')): + raise MetaError("That doesn't look like a Meta access token: it should start with OC or FRL.") + if '%' in token: + raise MetaError('That token is URL-encoded (it has % in it); copy it from the cookie or request ' + 'payload itself, not from an address.') + if '|' in token or ':' in token: + raise MetaError("That's an app token, not your account's token. See docs/meta-library.md for which one to copy.") + if re.fullmatch(r'OC\d{15}', token) or len(token) < TOKEN_MIN: + raise MetaError("That token is incomplete. Copy the whole value.") + if not re.fullmatch('[%s]+' % TOKEN_CHARS, token): + raise MetaError("That doesn't look like a Meta token: it has characters a Meta token doesn't (only " + 'letters, digits and + / = - _). Copy just the token value.') + if not re.search('[a-z]', token): # real tokens are mixed case; this is a constant or a name + raise MetaError("That doesn't look like a Meta token: it has no lower-case letters. Copy just the " + 'token value.') + return token + + +# ---- The account file ------------------------------------------------------------- + +_lock = threading.Lock() # the account file, the generation and the library cache +_state = {'generation': 0} +_library = {'generation': None, 'at': 0, 'apps': None} + + +def account_path(): + return Path(frame_host.data_dir(ACCOUNT_FILE)) + + +def _read_account(): + try: + with open(account_path(), encoding='utf-8') as f: + data = json.load(f) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) and isinstance(data.get('token'), str) else None + + +def _stored_token_quiet(): + account = _read_account() + return account['token'] if account else None + + +def _write_account(data): + """Write the account file owner-only (0600), atomically: a temporary file made by mkstemp + (already 0600) in the same folder, then renamed over the old one.""" + path = account_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.meta-account-', suffix='.tmp') + try: + try: + os.chmod(tmp, 0o600) + except OSError: + pass + with os.fdopen(fd, 'w', encoding='utf-8') as f: + json.dump(data, f) + os.replace(tmp, str(path)) + try: + os.chmod(str(path), 0o600) + except OSError: + pass + finally: + if os.path.exists(tmp): + os.remove(tmp) + + +def _session(): + """(token, generation) for the connected account, or MetaError.""" + with _lock: + account = _read_account() + if not account: + raise MetaError(NOT_CONNECTED) + return account['token'], _state['generation'] + + +def _current(generation): + with _lock: + return _state['generation'] == generation + + +def status(): + """For the page: whether an account is connected and its display name. Never the token.""" + account = _read_account() + user = (account or {}).get('user') or {} + return {'connected': bool(account), 'experimental': True, 'warning': WARNING, + 'user': {'name': str(user.get('name') or '')[:80]} if account else None, + 'connectedAt': (account or {}).get('connected')} + + +# Sign in with Meta (app/meta-signin.js) names each of its attempts. While a connect from an +# attempt is being checked it is listed in _attempts (only then, so this never grows); a +# cancel marks it, and it is then not saved. _saved_by is the attempt whose token is the +# saved account (None for a pasted token), set together with the save, so a cancel that +# arrives just after the save still removes that token, and never a later one. +SIGNIN_ATTEMPT = re.compile(r'[A-Za-z0-9-]{8,64}') +SIGNIN_CANCELLED = 'Sign in with Meta was cancelled, so that sign-in was not saved.' +_attempts = {} # in-flight attempt id -> {'cancelled': bool, 'running': connects under way} +_saved_by = [None] + + +def _check_attempt(attempt): + if attempt is not None and not (isinstance(attempt, str) and SIGNIN_ATTEMPT.fullmatch(attempt)): + raise MetaError('Invalid sign-in attempt id.') + return attempt + + +def cancel_signin(attempt): + """Never keep a token from this Sign in with Meta attempt: one still being checked isn't + saved, and one already saved is deleted. Unknown attempts change nothing.""" + if attempt is None: + raise MetaError('Invalid sign-in attempt id.') + _check_attempt(attempt) + with _lock: + # Both, independently: a connect that has just saved may still be listed until + # its cleanup runs, and its token must go all the same. + if attempt in _attempts: + _attempts[attempt]['cancelled'] = True + if _saved_by[0] == attempt: + _forget_locked() + return status() + + +def connect(token, attempt=None): + """Check the token with Meta ("who am I"), then keep it. A disconnect (or another + connect) that happens while it is being checked wins: this one is then not saved. So + does cancelling the Sign in with Meta attempt it came from (cancel_signin).""" + _check_attempt(attempt) + token = check_token(token) + with _lock: + generation = _state['generation'] + if attempt is not None: + entry = _attempts.setdefault(attempt, {'cancelled': False, 'running': 0}) + entry['running'] += 1 + try: + user = whoami(token) + with _lock: + if attempt is not None and _attempts[attempt]['cancelled']: + raise SignInCancelled(SIGNIN_CANCELLED) + if _state['generation'] != generation: + raise MetaError('The Meta account was disconnected (or another token connected) while this one ' + 'was being checked, so it was not saved.') + _write_account({'token': token, 'user': user, 'connected': time.strftime('%Y-%m-%dT%H:%M:%S')}) + _saved_by[0] = attempt + _state['generation'] += 1 + _library.update(generation=None, at=0, apps=None) + finally: + if attempt is not None: + with _lock: + entry = _attempts[attempt] + entry['running'] -= 1 + if not entry['running']: + del _attempts[attempt] + return status() + + +def _forget_locked(): + _state['generation'] += 1 + _saved_by[0] = None + _library.update(generation=None, at=0, apps=None) + try: + os.remove(str(account_path())) + except FileNotFoundError: + pass + + +def disconnect(): + """Forget the token: delete the file and the library in memory. Downloads under way stop.""" + with _lock: + _forget_locked() + return status() + + +# ---- HTTPS to Meta ---------------------------------------------------------------- + +# Connections: every address a host resolves to must be public (public_address), and the +# connection is made to exactly one of those checked addresses, with TLS verified for the +# host name, so a second DNS answer can't swap in another (no urllib, so no environment +# proxies either). This mirrors _web.public_address / resolve_public / _public_connection +# on feat/itch-purchases, so the two can be unified when both merge. + +# IPv6 is an allow-list: only global unicast (2000::/3), which rules out everything else at +# once (loopback, unspecified, mapped/compatible IPv4, NAT64 64:ff9b::, site-local fec0::, +# unique-local fc00::, link-local, multicast, reserved). Inside it, these special blocks are +# refused too: 6to4 and Teredo carry IPv4 that may be private; the rest aren't the internet. +_GLOBAL6 = ipaddress.ip_network('2000::/3') +_SPECIAL6 = tuple(ipaddress.ip_network(n) for n in ( + '2001::/32', # Teredo + '2001:2::/48', # benchmarking + '2001:10::/28', # ORCHID + '2001:20::/28', # ORCHIDv2 + '2001:db8::/32', # documentation + '2002::/16', # 6to4 + '3fff::/20')) # documentation (2024) +# IPv4 blocks that are never the public internet, listed in full rather than trusting +# is_global (Python 3.9 calls 192.0.0.8, .11 and .255 global). +_SPECIAL4 = tuple(ipaddress.ip_network(n) for n in ( + '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', + '192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15', + '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32')) + + +def public_address(ip): + """True for an address on the public internet: never loopback, private, link-local, + site-local, shared (CGNAT), reserved, unspecified or multicast, nor an IPv6 wrapper + around IPv4. Python 3.9's is_global alone lets several of these through.""" + try: + a = ipaddress.ip_address(ip.split('%', 1)[0]) + except ValueError: + return False + if a.version == 6 and (a not in _GLOBAL6 or any(a in n for n in _SPECIAL6)): + return False + if a.version == 4 and any(a in n for n in _SPECIAL4): + return False + return a.is_global and not (a.is_reserved or a.is_private or a.is_loopback or a.is_link_local + or a.is_multicast or a.is_unspecified) + + +def resolve_public(host): + """host's addresses for port 443, if every address it has is public; else MetaError.""" + try: + addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM) + except (OSError, UnicodeError): + raise MetaError(f"Couldn't find {host}; check the internet connection") from None + if not addresses or not all(public_address(a[4][0]) for a in addresses): + raise MetaError(f'{host} is on a private or local network address; not connecting') + found = [] + for a in addresses: + if a[4][:2] not in found: + found.append(a[4][:2]) + return found + + +CONNECT_BUDGET = 20 # seconds for connecting, all addresses and TLS handshakes together + + +def _public_connection(host, timeout, budget=CONNECT_BUDGET): + """A verified TLS connection to host:443, made to an address checked to be public. + + Each checked address is tried in turn, all within one deadline (budget, or timeout if + shorter) covering both connecting and the handshake. The one connected to is always one + that was checked, so a second lookup can't swap in another. timeout is for reads after.""" + deadline = time.monotonic() + min(budget, timeout) + error = None + for address in resolve_public(host): + if time.monotonic() >= deadline: + break + try: + return _connect(host, address, timeout, deadline) + except OSError as e: # unreachable, refused, or its TLS failed: try the next one + error = e + raise error or socket.timeout('Timed out connecting to ' + host) + + +def _connect(host, address, timeout, deadline): + """TLS to exactly address, verified as host, connected and handshaken before deadline + (a time.monotonic() value); reads then get timeout seconds each.""" + def remaining(): + left = deadline - time.monotonic() + if left <= 0: + raise socket.timeout('Timed out connecting to ' + host) + return left + sock = socket.create_connection(address, timeout=remaining()) + try: + sock.settimeout(remaining()) + sock = ssl.create_default_context().wrap_socket(sock, server_hostname=host, + do_handshake_on_connect=False) + sock.settimeout(remaining()) + sock.do_handshake() + sock.settimeout(timeout) + except BaseException: + sock.close() + raise + conn = http.client.HTTPConnection(host, 443, timeout=timeout) + conn.sock = sock + return conn + + +class _Answer: + """A response that closes its connection with it.""" + + def __init__(self, conn, response): + self.conn, self.response = conn, response + + def read(self, n=-1): + return self.response.read(n) + + def close(self): + try: + self.response.close() + finally: + self.conn.close() + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + +def _send(url, data=None, headers=None, timeout=TIMEOUT): + """One HTTPS request to a checked public address, redirects not followed: (status, + headers, readable body). The tests replace this with a fake Meta.""" + p = urllib.parse.urlsplit(check_url(url)) + conn = _public_connection(p.hostname, timeout) + try: + conn.request('POST' if data is not None else 'GET', (p.path or '/') + ('?' + p.query if p.query else ''), + body=data, headers=headers or {}) + r = conn.getresponse() + except BaseException: + conn.close() + raise + return r.status, r.headers, _Answer(conn, r) + + +def check_url(url): + """url if it is HTTPS on port 443 without credentials; else MetaError (never echoing the + URL, which can hold the token). No lookup: addresses are checked when connecting.""" + try: + p = urllib.parse.urlsplit(url) + port = p.port + except (ValueError, TypeError): + raise MetaError("Meta's server gave an invalid address") from None + if p.scheme != 'https' or p.username or p.password or port not in (None, 443) or not p.hostname: + raise MetaError("Meta's server sent the download somewhere that isn't plain HTTPS; not following it") + return url + + +def next_hop(current, location, token): + """Where a redirect from current to location goes, checked before any lookup or + connection: plain HTTPS, and off Meta's own hosts never carrying the token in any form, + anywhere in the address (host, path, query or fragment). Refused, not rewritten.""" + url = check_url(urllib.parse.urljoin(current, location or '')) + if (urllib.parse.urlsplit(url).hostname or '').lower() not in TOKEN_HOSTS and carries_token(url, token): + raise MetaError("Meta's server pointed the download at an address carrying your token; refused, so " + 'the token is not sent anywhere but Meta') + return url + + +def _follow(url, headers, token): + """GET url, following up to MAX_REDIRECTS redirects through next_hop. Returns + (status, headers, body) of the final answer.""" + check_url(url) + for _ in range(MAX_REDIRECTS + 1): + status, rheaders, body = _send(url, headers=headers) + if status in (301, 302, 303, 307, 308): + location = rheaders.get('Location') if rheaders else None + body.close() + if not location: + raise MetaError("Meta's server redirected the download without saying where") + url = next_hop(url, location, token) + continue + return status, rheaders, body + raise MetaError("Meta's server redirected the download too many times") + + +def graphql(token, doc_id, variables=None): + """One query to Meta's GraphQL API: the token travels in the POST body, never the URL. + Returns the answer's "data"; raises TokenRejected or MetaError with readable text.""" + body = urllib.parse.urlencode({'access_token': token, 'doc_id': doc_id, + 'variables': json.dumps(variables or {})}).encode() + try: + status, _, r = _send(GRAPH_URL, data=body, headers={ + 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', 'User-Agent': UA}) + with r: + raw = r.read(MAX_RESPONSE + 1) + except MetaError: + raise + except (http.client.HTTPException, OSError, ValueError) as e: + raise MetaError("Couldn't reach Meta's API: " + redact(getattr(e, 'reason', e), 200, token)) from None + if len(raw) > MAX_RESPONSE: + raise MetaError("Meta's API answer was too large") + if 300 <= status < 400: + raise MetaError(f"Meta's API redirected the request (HTTP {status}); Frame Control doesn't follow that") + try: + answer = json.loads(raw.decode('utf-8')) + except (UnicodeDecodeError, ValueError): + answer = None + error = answer.get('error') if isinstance(answer, dict) else None + if status in (401, 403) or (isinstance(error, dict) and (error.get('type') == 'OAuthException' + or error.get('code') in (102, 190))): + raise TokenRejected(REJECTED) + if status != 200 or not isinstance(answer, dict): + said = error.get('message') if isinstance(error, dict) else '' + raise MetaError(f"Meta's API said HTTP {status}" + (f': {redact(said, 200, token)}' if said else '')) + if isinstance(error, dict): + raise MetaError("Meta's API refused the request: " + redact(error.get('message'), 200, token)) + errors = answer.get('errors') + if errors and not answer.get('data'): + first = errors[0] if isinstance(errors, list) and errors else {} + raise MetaError("Meta's API refused the request: " + + redact(first.get('message') if isinstance(first, dict) else first, 200, token)) + data = answer.get('data') + if not isinstance(data, dict): + raise MetaError("Meta's API answered without data; it may have changed") + return data + + +def whoami(token): + """The account a token belongs to ({'id', 'name'}), from a harmless read-only query.""" + data = graphql(token, DOC_VIEWER) + user = ((data.get('viewer') or {}).get('user') if isinstance(data.get('viewer'), dict) else None) + if not isinstance(user, dict) or not user.get('id'): + raise TokenRejected(REJECTED) + return {'id': str(user['id'])[:40], 'name': str(user.get('alias') or user.get('display_name') or '')[:80]} + + +# ---- The library -------------------------------------------------------------------- + +def _int(value): + try: + n = int(str(value).strip()) + return n if n >= 0 else None + except (TypeError, ValueError): + return None + + +def _nodes(connection): + """The items of a GraphQL list, whether it is {nodes: [...]}, {edges: [{node}]} or a list.""" + if isinstance(connection, list): + items = connection + elif isinstance(connection, dict): + items = connection.get('nodes') or [e.get('node') for e in connection.get('edges') or [] if isinstance(e, dict)] + else: + items = [] + return [i for i in items if isinstance(i, dict)] + + +def _file(node, required=None): + """An OBB or asset file: {'id', 'name', 'size', 'required', 'sha256'} or None.""" + if not isinstance(node, dict) or not str(node.get('id') or '').isdigit(): + return None + digest = str(node.get('sha256') or node.get('sha256_hash') or '').lower() + return {'id': str(node['id']), 'name': str(node.get('file_name') or ''), + 'size': _int(node.get('size')), 'sha256': digest if re.fullmatch(r'[0-9a-f]{64}', digest) else None, + 'required': bool(node.get('is_required')) if required is None else required} + + +def parse_binary(binary): + """The download facts of one Android binary: package, version, size, OBB and asset files.""" + if not isinstance(binary, dict): + return {} + out = {} + if str(binary.get('id') or '').isdigit(): + out['binary_id'] = str(binary['id']) + if isinstance(binary.get('package_name'), str) and android.PKG_RE.match(binary['package_name']): + out['package'] = binary['package_name'] + if binary.get('version') not in (None, ''): + out['version'] = str(binary['version'])[:80] + if _int(binary.get('version_code')) is not None: + out['version_code'] = _int(binary['version_code']) + if _int(binary.get('size')) is not None: + out['size'] = _int(binary['size']) + digest = str(binary.get('sha256') or '').lower() + if re.fullmatch(r'[0-9a-f]{64}', digest): + out['sha256'] = digest + if 'obb_binary' in binary: + out['obb'] = _file(binary.get('obb_binary'), required=True) + if 'asset_files' in binary: + out['assets'] = [f for f in (_file(n) for n in _nodes(binary.get('asset_files'))) if f] + return out + + +def parse_entitlements(data, now=None): + """The Quest apps an entitlements answer says the account owns, newest facts first. + Expired or inactive entitlements and other platforms (Rift, Go) are left out.""" + now = now or time.time() + user = ((data.get('viewer') or {}).get('user') or {}) if isinstance(data, dict) else {} + apps, seen = [], set() + for node in _nodes(user.get('active_entitlements') if isinstance(user, dict) else None): + state = str(node.get('active_state') or 'ACTIVE').upper() + expires = _int(node.get('expiration_time')) + item = node.get('item') + if state not in OWNED_STATES or (expires and expires < now) or not isinstance(item, dict): + continue + app_id = str(item.get('id') or '') + platform = str(item.get('platform') or '').upper() + if not app_id.isdigit() or app_id in seen or (platform and platform not in QUEST_PLATFORMS): + continue + seen.add(app_id) + app = {'id': app_id, 'title': str(item.get('display_name') or item.get('name') or app_id)[:120], + 'package': None, 'version': None, 'version_code': None, 'size': None, 'binary_id': None} + app.update(parse_binary(item.get('latest_supported_binary'))) + # Detailed: the answer said what game data the binary has (its OBB and asset files). + app['detailed'] = 'obb' in app and 'assets' in app + app.setdefault('obb', None) + app.setdefault('assets', []) + apps.append(app) + if len(apps) >= MAX_APPS: + break + return sorted(apps, key=lambda a: a['title'].lower()) + + +def binary_details(token, binary_id): + """The full facts of one binary (package, version code, size, OBB, asset files).""" + data = graphql(token, DOC_BINARY, {'binaryID': str(binary_id)}) + node = data.get('node') + if not isinstance(node, dict): + node = next((v for v in data.values() if isinstance(v, dict) and str(v.get('id') or '') == str(binary_id)), {}) + return parse_binary(node) + + +def _complete(app): + return bool(app.get('package') and app.get('version_code') is not None and app.get('detailed')) + + +def _fill(token, app): + """Add one binary query's facts to app (in place).""" + try: + facts = binary_details(token, app['binary_id']) + except TokenRejected: + raise + except MetaError as e: + app['problem'] = str(e) + return app + app.update({k: v for k, v in facts.items() if v is not None}) + app['detailed'] = True + app.pop('problem', None) + return app + + +def library(refresh=False): + """The Quest apps the connected account owns, each with its download facts. Kept in + memory only (LIBRARY_TTL), and dropped by a disconnect. Apps the list answer doesn't + fully describe get one binary query each, at most MAX_DETAILS per refresh; the rest + are filled in when installed.""" + token, generation = _session() + with _lock: + if (not refresh and _library['generation'] == generation and _library['apps'] is not None + and time.time() - _library['at'] < LIBRARY_TTL): + return [dict(a) for a in _library['apps']] + apps = parse_entitlements(graphql(token, DOC_ENTITLEMENTS)) + missing = [a for a in apps if a.get('binary_id') and not _complete(a)][:MAX_DETAILS] + if missing: + with concurrent.futures.ThreadPoolExecutor(DETAIL_WORKERS) as pool: + for future in [pool.submit(_fill, token, a) for a in missing]: + future.result() + with _lock: + if _state['generation'] == generation: + _library.update(generation=generation, at=time.time(), apps=[dict(a) for a in apps]) + return apps + + +def owned(app_id, refresh=False): + """An app the connected account owns, by Meta app id; MetaError if it isn't in the library.""" + app_id = str(app_id or '') + if not re.fullmatch(r'[0-9]{1,25}', app_id): + raise MetaError('invalid Meta app id') + for attempt in (refresh, True): + app = next((a for a in library(refresh=attempt) if a['id'] == app_id), None) + if app or attempt: + break + if not app: + raise MetaError("That app isn't in your Meta library, so Frame Control won't download it.") + if app.get('binary_id') and not _complete(app): + token, _ = _session() + _fill(token, app) + if not app.get('binary_id') or not app.get('package'): + raise MetaError(f"Meta didn't say which file to download for {app['title']}" + + (f" ({app['problem']})" if app.get('problem') else '') + '.') + return app + + +def with_verdicts(apps): + """apps with the community verdict for each package from the compatibility database, + when there is one (apk-catalog/reports.py).""" + try: + import frame_catalog + by = frame_catalog.reports.by_package(frame_catalog.compat_db.load()) + except Exception: # the library still shows without verdicts + return apps + for app in apps: + found = frame_catalog.reports.verdict(by.get(app.get('package'))) if app.get('package') else None + app['verdict'] = {'verdict': found[0], 'lines': found[1]} if found else None + return apps + + +# ---- Downloading ------------------------------------------------------------------- + +_install_lock = threading.Lock() + + +def cache_root(): + return Path(frame_host.cache_dir('meta-library')) + + +def sweep_cache(now=None): + """Remove downloads a crash left behind (older than CACHE_TTL).""" + root, now = cache_root(), now or time.time() + if root.is_dir(): + for d in root.iterdir(): + try: + if now - d.stat().st_mtime > CACHE_TTL: + shutil.rmtree(d, ignore_errors=True) + except OSError: + pass + + +class Budget: + """The bytes one install (or CLI download) may still download, across all its files. + Counted from the bytes actually received, so files of unknown size count too.""" + + def __init__(self, total=None): + self.left = MAX_TOTAL if total is None else total + + def take(self, n, name): + if n > self.left: + raise MetaError(f'{name} would take this install past the {MAX_TOTAL / 1024**3:.0f} GB Frame Control ' + 'downloads for one game; download stopped and removed') + self.left -= n + + +def fetch(file_id, dest, limit, token, generation, expected=None, sha256=None, progress=None, budget=None): + """Download one file by id from Meta's download server into dest. Bounded by limit, by + the size Meta declared and by budget (the whole install's bytes); checked against its + SHA-256 when Meta gives one. Stops if the account is disconnected meanwhile. Nothing is + left behind on failure.""" + budget = budget if budget is not None else Budget() + if expected is not None and (expected > limit or expected > budget.left): + raise MetaError(f'{dest.name} is {expected / 1024**3:.1f} GB, more than Frame Control downloads ' + f'({min(limit, budget.left) / 1024**3:.1f} GB left for this game)') + url = DOWNLOAD_URL + '?' + urllib.parse.urlencode({'id': file_id, 'access_token': token}) + part = dest.with_name(dest.name + '.part') + try: + try: + status, headers, body = _follow(url, {'User-Agent': UA}, token) + except MetaError: + raise + except (http.client.HTTPException, OSError, ValueError) as e: + raise MetaError(f"Couldn't download {dest.name} from Meta: " + redact(getattr(e, 'reason', e), 200, token)) from None + with body: + if status in (401, 403): + raise TokenRejected(f"Meta refused to download {dest.name} (HTTP {status}). " + REJECTED) + if status != 200: + raise MetaError(f"Meta's download server said HTTP {status} for {dest.name}") + length = _int((headers or {}).get('Content-Length')) + if length is not None and (length > limit or (expected is not None and length != expected)): + raise MetaError(f'{dest.name} is not the size Meta said it is; download refused') + h, size = hashlib.sha256(), 0 + with open(part, 'wb') as f: + while True: + try: + chunk = body.read(1 << 20) + except (OSError, ValueError) as e: + raise MetaError(f'Downloading {dest.name} failed: ' + redact(e, 200, token)) from None + if not chunk: + break + size += len(chunk) + if size > limit or (expected is not None and size > expected): + raise MetaError(f'{dest.name} is larger than Meta said; download stopped') + if not _current(generation): + raise MetaError('The Meta account was disconnected; the download was stopped and removed.') + budget.take(len(chunk), dest.name) + h.update(chunk) + f.write(chunk) + if progress: + progress(size) + if expected is not None and size != expected: + raise MetaError(f'{dest.name} is incomplete ({size} of {expected} bytes); download removed') + if sha256 and h.hexdigest() != sha256: + raise MetaError(f"{dest.name} doesn't match the checksum Meta gave; download removed") + os.replace(part, dest) + return {'path': str(dest), 'size': size, 'sha256': h.hexdigest(), 'verified': bool(sha256)} + finally: + if part.exists(): + part.unlink() + + +def obb_name(app, file): + """The name an OBB gets on the Frame: Meta's file name when it is a standard + main|patch...obb name, else main...obb for the + app's own OBB binary. None for anything else (not copied).""" + pattern = quest.OBB_NAME.format(re.escape(app['package'])) + if re.fullmatch(pattern, file.get('name') or ''): + return file['name'] + if file is app.get('obb') and app.get('version_code') is not None: + return f"main.{app['version_code']}.{app['package']}.obb" + return None + + +def data_plan(app): + """(files to download as OBBs [(file, name)], names left out with why).""" + files = ([app['obb']] if app.get('obb') else []) + [f for f in app.get('assets') or [] if f.get('required')] + plan, skipped, names = [], [], set() + for f in files: + name = obb_name(app, f) + if not name or name in names: + skipped.append(f.get('name') or f['id']) + continue + names.add(name) + plan.append((f, name)) + return plan, skipped + + +def _check_space(work, need, title): + free = shutil.disk_usage(str(work)).free + if need > free: + raise MetaError(f'Installing {title} needs about {need / 1024**3:.1f} GB free on this computer ' + f'({free / 1024**3:.1f} GB available)') + + +def _bar(report, stage, start, span, total): + def progress(done): + if total: + report(stage, start + int(span * min(done, total) / total)) + return progress + + +def download_apk(app, work, token, generation, report, budget): + report(f"Downloading {app['title']} from Meta", 5) + got = fetch(app['binary_id'], work / 'base.apk', MAX_APK, token, generation, expected=app.get('size'), + sha256=app.get('sha256'), progress=_bar(report, f"Downloading {app['title']} from Meta", 5, 40, + app.get('size')), budget=budget) + try: + with zipfile.ZipFile(got['path']) as z: + if 'AndroidManifest.xml' not in z.namelist(): + raise MetaError(f"What Meta sent for {app['title']} is not an APK") + except zipfile.BadZipFile: + raise MetaError(f"What Meta sent for {app['title']} is not an APK") from None + return Path(got['path']) + + +def download_data(app, work, token, generation, report, budget): + """The app's OBB files, in work/obb: ([paths], [names not copied]). Every byte counts + against budget as it arrives; a file that overflows it is removed.""" + plan, skipped = data_plan(app) + folder = work / 'obb' + folder.mkdir(exist_ok=True) + paths, total = [], sum(f.get('size') or 0 for f, _ in plan) + if total > budget.left: + raise MetaError(f"{app['title']}'s game data is {total / 1024**3:.1f} GB, more than Frame Control " + f"downloads ({MAX_TOTAL // 1024**3} GB)") + for i, (f, name) in enumerate(plan): + stage = f"Downloading {app['title']}'s game data ({i + 1} of {len(plan)})" + report(stage, 50) + got = fetch(f['id'], folder / name, MAX_DATA_FILE, token, generation, expected=f.get('size'), + sha256=f.get('sha256'), progress=_bar(report, stage, 50, 15, f.get('size')), budget=budget) + paths.append(got['path']) + return paths, skipped + + +def download(app_id, progress=None): + """CLI: download an owned app's APK and OBB files into the local install cache (removed + after CACHE_TTL) and say where they are.""" + report = progress or (lambda stage, percent=None: None) + token, generation = _session() + app = owned(app_id) + sweep_cache() + work = cache_root() / f"{app['package']}-{secrets.token_hex(4)}" + work.mkdir(parents=True) + try: + _check_space(work, (app.get('size') or 0) + sum(f.get('size') or 0 for f, _ in data_plan(app)[0]), + app['title']) + budget = Budget() + apk = download_apk(app, work, token, generation, report, budget) + obb, skipped = download_data(app, work, token, generation, report, budget) + except BaseException: + shutil.rmtree(work, ignore_errors=True) + raise + return {'app': _public(app), 'apk': str(apk), 'obb': obb, 'skipped': skipped, 'folder': str(work)} + + +# ---- Installing ------------------------------------------------------------------- + +def _public(app): + """An app as the page and the CLI see it (download ids are fine: they need the token).""" + keys = ('id', 'title', 'package', 'version', 'version_code', 'size', 'problem', 'verdict') + out = {k: app.get(k) for k in keys if k in app} + plan, skipped = data_plan(app) if app.get('package') else ([], []) + out['data_size'] = sum(f.get('size') or 0 for f, _ in plan) or None + out['data_files'] = len(plan) + out['skipped_files'] = skipped + return out + + +def _wait_running(pkg, timeout=START_TIMEOUT): + for _ in range(timeout // 3): + if quest._running(pkg): + return True + quest._sleep(3) + return quest._running(pkg) + + +def install(app_id, test=False, progress=None, allow_downgrade=False, wait=TEST_WAIT): + """Download an owned app from Meta and install it on the Frame (as Import from my Quest + does), then, with test, run it and record whether it runs. + + Game data (OBB) can only go into a running Lepton instance. Without test that is a second + Install while the person has the game open (Frame Control then copies it in and closes the + game). With test, the person asked Frame Control to run the game, so it starts the game + itself to add the data, then starts it again for the test.""" + report = progress or (lambda stage, percent=None: None) + if not _install_lock.acquire(blocking=False): + raise MetaError('Another My Meta library install is running; wait for it to finish') + try: + token, generation = _session() + report('Reading your Meta library', 1) + app = owned(app_id) + pkg = app['package'] + sweep_cache() + work = cache_root() / f'{pkg}-{secrets.token_hex(4)}' + work.mkdir(parents=True) + try: + result = _install(app, work, token, generation, report, test, allow_downgrade) + finally: + shutil.rmtree(work, ignore_errors=True) # never kept: the files are on the Frame now + finally: + _install_lock.release() + if test: + report('Testing it on the Frame', 90) + outcome = run_test(pkg, wait=wait, app=app) + result['test'] = outcome + result['message'] += ' ' + outcome['message'] + report('Done', 100) + return result + + +def _install(app, work, token, generation, report, test, allow_downgrade): + pkg = app['package'] + plan, skipped = data_plan(app) + data_bytes = sum(f.get('size') or 0 for f, _ in plan) + apk_bytes = app.get('size') or 0 + # The APK, a merged or patched copy of it, and the OBB files. + _check_space(work, 2 * apk_bytes + data_bytes, app['title']) + budget = Budget() + apk = download_apk(app, work, token, generation, report, budget) + report('Checking it can run on the Frame', 46) + checked = quest.inspect(pkg, str(apk)) + if app.get('version_code') is not None and checked['version_code'] not in (None, app['version_code']): + raise MetaError(f"Meta said {app['title']} is version code {app['version_code']}, but the APK it sent " + f"is {checked['version_code']}; not installing it") + warnings = list(checked['warnings']) + if skipped: + warnings.append('Not copied (Frame Control only copies standard OBB files; other files are fetched ' + "through Meta's Platform SDK, which the Frame doesn't have): " + ', '.join(skipped)) + label = checked['label'] + if quest._running(pkg): + # The second Install: the game is open, so add its game data to exactly this install. + quest._check_target(pkg, android.read_meta(pkg), checked) + if not plan: + raise MetaError(f'{label} is already installed and open on the Frame, and has no game data to add. ' + 'Close it first to install it again.') + obb, _ = download_data(app, work, token, generation, report, budget) + result = quest.add_game_data(pkg, checked, obb, None, report, warnings, source="Meta's download server") + result.update(package=pkg, source='meta-store', version_code=checked['version_code']) + return result + report('Installing on the Frame', 60) + with android._install_lock: + if quest._running(pkg): + raise MetaError(f'{label} was started on the Frame during the install. Close it, then install again.') + quest._check_downgrade(android.read_meta(pkg), checked, allow_downgrade) + meta = android.install(str(apk), source='Meta Horizon Store') + try: + apk.unlink() # the Frame has it now + except OSError: + pass + message = f"Installed {meta['label']} from your Meta library. It's in the Steam library." + needs_data = bool(plan) + added = None + if plan and test: + obb, _ = download_data(app, work, token, generation, report, budget) + report(f'Starting {label} to add its game data', 68) + android.launch(pkg) + if not _wait_running(pkg): + raise MetaError(f"{label} was installed, but its Lepton instance didn't start within " + f'{START_TIMEOUT} s, so its game data could not be added. Start it from the Steam ' + 'library, then use Install again to add the data.') + added = quest.add_game_data(pkg, checked, obb, None, report, (), source="Meta's download server") + message += ' ' + added['message'] + needs_data = False + elif plan: + message += (f" To add its OBB game data, start {meta['label']} from the Steam library, leave it on its " + 'first screen, then choose Install again here: Frame Control downloads the data, copies it ' + 'in and closes the game.') + note = android.layer_note(meta) + return {'message': ' '.join([message, *warnings, *([note] if note else [])]), 'app': meta, + 'package': pkg, 'warnings': warnings, 'needs_game_data': needs_data, + 'obb': (added or {}).get('obb', []), 'source': 'meta-store', 'version_code': checked['version_code'], + 'platform_sdk': checked['platform_sdk']} + + +# ---- Testing whether it runs ---------------------------------------------------------- +# +# Signatures for common reasons a Quest game fails on the Frame (from the compatibility +# research in docs/meta-library.md), written for Frame Control. Each is (key, pattern, +# kind): 'blocker' explains a quit or a stall, 'issue' a game that runs with something +# missing. Causes are fixed texts (CAUSES), never log text: reports carry only those. A +# short excerpt (an extension, library, exception or signal name, matched by a strict +# pattern) may be shown to the person, redacted before it is shortened, never reported. + +SIGNATURES = [ + # Explicit failure evidence only: a game that merely calls the check (and passes) isn't one. + ('licence', re.compile(r'(?i)entitle\w{0,40}\s{0,8}(?:check\s{0,8})?(?:has\s{1,8})?' + r'(?:failed|failure|denied|rejected|error)|' + r'\bnot entitled\b|failed to (?:verify|check|get) (?:the )?entitlement|' + r'(?:ovr_Entitlement\w{0,60}|IsUserEntitledToApplication)(?:\(\))?\s{0,8}' + r'(?:failed|returned (?:an )?error|error|=\s{0,8}false|:\s{0,8}false)'), 'blocker'), + ('platform', re.compile(r'(?i)ovr_Platform(?:Initialize)?\w{0,60}\s{0,8}(?:failed|error)|' + r'libovrplatform\w{0,40}\.so.{0,200}?(?:not found|failed)|' + r'Failed to initialize (?:the )?(?:Oculus|Meta) Platform|' + r'Oculus Platform.{0,200}?(?:failed|not initialized)'), + 'blocker'), + ('device', re.compile(r'(?i)Failed to launch SystemActivities|IsRunningOnSantaCruz|' + r'(?:device|headset) (?:is )?not supported|unsupported (?:device|headset)|not an? (?:Oculus|Meta) (?:Quest|device)'), + 'blocker'), + ('vrapi', re.compile(r'(?i)vrapi_\w{1,60} was called before|libvrapi\.so.{0,200}?(?:not found|failed)|' + r'vrapi_Initialize\w{0,40}\s{0,8}(?:failed|error)'), + 'blocker'), + ('extension', re.compile(r'XR_ERROR_EXTENSION_NOT_PRESENT'), 'blocker'), + ('xr-version', re.compile(r'XR_ERROR_API_VERSION_UNSUPPORTED'), 'blocker'), + ('runtime', re.compile(r'XR_ERROR_RUNTIME_(?:UNAVAILABLE|FAILURE)|XR_ERROR_INITIALIZATION_FAILED|' + r'(?i:xrCreateInstance\w{0,40}\s{0,8}(?:failed|returned -))'), 'blocker'), + ('gpu', re.compile(r'(?i)VK_ERROR_DEVICE_LOST|DEVICE[ _]LOST|vkCreate\w{1,60} failed|VK_ERROR_\w+|' + r'libgallium_dri\.so|zink.{0,200}?(?:error|fail)|GL_INVALID_\w+|GL_OUT_OF_MEMORY|' + r'shader (?:compil|link)\w{0,20} (?:failed|error)|failed to (?:compile|link) shader'), 'issue'), + ('clipboard', re.compile(r'ClipboardManager'), 'blocker'), + ('android', re.compile(r'NoSuchMethodError|NoClassDefFoundError'), 'issue'), + ('library', re.compile(r'UnsatisfiedLinkError|dlopen failed'), 'blocker'), +] +CAUSES = { + 'licence': "Meta's licence check failed (it needs Meta's services, which the Frame doesn't have). " + "Frame Control won't remove the check.", + 'platform': "Meta's Platform SDK couldn't start (no Meta services on the Frame); online features, and the " + 'licence check if the game enforces it, won\'t work.', + 'device': 'The game checks that it is on a Meta Quest and stops on the Frame.', + 'vrapi': "The game uses Meta's legacy VrApi, which the Frame doesn't have.", + 'extension': 'The game asked for an OpenXR feature the Frame lacks.', + 'xr-version': "The game asked for an OpenXR version the Frame's runtime refuses.", + 'runtime': "The game couldn't start an OpenXR session with the Frame's runtime.", + 'gpu': "Graphics errors (the Frame uses the Mesa Vulkan and GLES drivers, which are stricter than the " + "Quest's); it may draw wrongly or crash.", + 'clipboard': "The game needs Android's clipboard service, which Lepton doesn't have.", + 'android': 'The game called an Android feature Lepton (Android 11) lacks.', + 'library': "The game needs a library the Frame doesn't have.", + 'not-started': f"Its Lepton instance didn't start within {START_TIMEOUT} s.", + 'crash-java': 'It crashed (a Java exception).', + 'crash-native': 'It crashed (a native crash signal).', + 'crash': 'It crashed.', + 'quit': 'It closed by itself without a recognisable error.', + 'quit-no-log': 'It closed by itself, and its instance stopped before its log could be read.', + 'not-responding': 'It stopped responding (Android reported it not responding).', + 'no-session': "It stayed open, but its log didn't show its VR scene starting, so Frame Control can't tell " + "whether it runs. This result isn't recorded.", +} +CRASH = re.compile(r'FATAL EXCEPTION|Fatal signal \d+|\bSIG(?:SEGV|ABRT|BUS|ILL)\b|>>> [\w.]{1,200} <<<|Abort message:') +HANG = re.compile(r'ANR in [\w.]+|Application Not Responding|Input dispatching timed out') +# Signs the game got far enough to show its VR scene. +SESSION = re.compile(r'XR_SESSION_STATE_(?:SYNCHRONIZED|VISIBLE|FOCUSED)|xrEndFrame|xrBeginSession|' + r'session state (?:changed )?(?:to )?(?:SYNCHRONIZED|VISIBLE|FOCUSED)', re.I) +XR_STARTED = re.compile(r'FrameXrCompat.{0,200}?created instance|xrCreateInstance', re.I) +LINE_SCAN = 4096 # characters of each log line the signatures look at (Android's own line limit) +LOG_UNREADABLE = ("Frame Control couldn't read the game's log from the Frame, so the test has no result. " + 'Check the Frame is connected, then test again.') +# The compatibility database's result for each outcome; None: not recorded at all. +RESULTS = {'runs': 'runs', 'runs-with-issues': 'runs', 'quits': 'crashes', 'crashes': 'crashes', + 'hangs': 'crashes', 'instance-failed': 'instance_failed', 'inconclusive': None} +OUTCOME_WORDS = {'runs': 'runs', 'runs-with-issues': 'runs, with issues', 'quits': 'quit', + 'crashes': 'crashed', 'hangs': 'seems stuck', 'instance-failed': "didn't start", + 'inconclusive': 'gave no clear result'} + + +def _excerpt(key, line): + """A short name from the log line behind a cause, for the person only: an OpenXR + extension, a library, an exception class or a signal. The whole line is redacted first, + then the name is taken whole (never a cut-off end of something longer), and dropped + unless it looks like a real identifier.""" + if key not in _NAMES or not line: + return '' + # The whole line is redacted, then split once into words; only words short enough to + # be names are tested, each with one fullmatch: linear in the line, however long. + for word in re.split(r'[^\w.+$-]+', redact(line)): + word = word.strip('.') + if 0 < len(word) <= 64 and _NAMES[key].fullmatch(word) and _plausible(word): + return word + return '' + + +_NAMES = {'extension': re.compile(r'XR_(?!ERROR_)[A-Z0-9]+_[A-Za-z0-9_]+'), + 'library': re.compile(r'[\w.+-]+\.so'), + 'crash-java': re.compile(r'[\w$]+(?:\.[\w$]+)*(?:Exception|Error)'), + 'crash-native': re.compile(r'SIG[A-Z]{2,6}')} + + +def _plausible(name): + """A name short and plain enough to be a library, extension, class or signal name, not + a credential: at most 64 characters, no run of 32+ letters and digits, and no run of 16+ + that mixes upper case, lower case and digits (as tokens and base64 do).""" + if not name or len(name) > 64 or REDACTED in name: + return False + for run in re.findall(r'[A-Za-z0-9]+', name): + if len(run) >= 32 or (len(run) >= 16 and re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) + and re.search(r'[0-9]', run)): + return False + return True + + +def classify(log, alive, instance_up=True, seconds=None, started=True): + """An outcome (runs, runs-with-issues, quits, crashes, hangs, instance-failed, or + inconclusive when the game stayed open without showing its VR scene), the key of a fixed + plain-English cause, and the signals behind it, from the game's Android log, whether its + Lepton instance started at all, and whether its process and instance were up at the end.""" + # Signatures look at the start of each line only (LINE_SCAN), and every pattern is + # bounded, so a hostile log can't make this slow. The full line is kept for the excerpt, + # which redacts all of it before taking a name. + full = (log or '').splitlines() + lines = [l[:LINE_SCAN] for l in full] + log = '\n'.join(lines) + by_scan = dict(zip(lines, full)) + found = [] # (key, kind, line) + for key, pattern, kind in SIGNATURES: + line = next((l for l in lines if pattern.search(l)), None) + if line is not None: + found.append((key, kind, line)) + crash = next((l for l in lines if CRASH.search(l)), None) + hang = bool(HANG.search(log)) + session = bool(SESSION.search(log)) + signals = {'alive': bool(alive), 'instance_up': bool(instance_up), 'crash': bool(crash), 'not_responding': hang, + 'xr_session': session, 'xr_started': session or bool(XR_STARTED.search(log)), + 'matched': [key for key, _, _ in found]} + blockers = [(k, l) for k, kind, l in found if kind == 'blocker'] + issues = [(k, l) for k, kind, l in found if kind == 'issue'] + first = (blockers + issues or [(None, None)])[0] + if not started: + outcome, (key, line) = 'instance-failed', ('not-started', None) + elif not alive: + outcome = 'crashes' if crash else 'quits' + if first[0]: + key, line = first + elif crash: + java = next((l for l in lines if 'AndroidRuntime' in l and ('Exception' in l or 'Error' in l)), None) + native = next((l for l in lines if re.search(r'Fatal signal|\bSIG[A-Z]{2,6}\b', l)), None) + key, line = ('crash-java', java) if java else ('crash-native', native) if native else ('crash', None) + else: + key, line = ('quit' if log.strip() else 'quit-no-log'), None + elif hang: + outcome, (key, line) = 'hangs', (blockers[0] if blockers else ('not-responding', None)) + elif not session: + # Open, but no sign of its VR scene: not enough to say it runs, nor that it hangs. + outcome, (key, line) = 'inconclusive', (blockers[0] if blockers else ('no-session', None)) + elif first[0]: + outcome, (key, line) = 'runs-with-issues', first + else: + outcome, key, line = 'runs', None, None + cause = CAUSES[key] if key else '' + if outcome == 'inconclusive' and key != 'no-session': + cause += ' ' + CAUSES['no-session'] + if seconds is not None: + signals['seconds'] = seconds + return {'outcome': outcome, 'result': RESULTS[outcome], 'cause_key': key, 'cause': cause, + 'detail': _excerpt(key, by_scan.get(line, line)), 'signals': signals} + + +def collect_log(meta): + """The game's Lepton instance's Android log (main, system and crash buffers), bounded.""" + ctr = f"lepton-steamlaunch-{int(meta['instance'])}" + return android.ssh(f"podman exec {ctr} /system/bin/sh -c 'logcat -d -b main,system,crash -t 4000' " + "2>/dev/null | tail -c 400000 || true", timeout=60) + + +def run_test(pkg, wait=TEST_WAIT, app=None, report_result=True): + """Start the game, watch it for `wait` seconds after its Lepton instance is up, then + read its log and classify how it went. Records a clear result as a compatibility report + (source meta-store, with the version code), shared only if the person chose to share + compatibility results; an inconclusive one isn't recorded. The game is left running, as + Test does, unless it hangs.""" + try: + return _run_test(pkg, wait, app, report_result) + except MetaError: + raise # fixed texts already + except android.FrameError: + # A failed ssh command's error is its output, which here can be the game's own log: + # never shown, not even redacted. + raise MetaError(LOG_UNREADABLE) from None + + +def _run_test(pkg, wait, app, report_result): + meta = android._meta_or_fail(pkg) + ctr = f"lepton-steamlaunch-{int(meta['instance'])}" + label = meta.get('label') or (app or {}).get('title') or pkg + if quest._running(pkg): + closed, _ = quest._close(pkg) # a fresh start gives a clean log + if not closed: + raise MetaError(f"{label} is open on the Frame and Frame Control couldn't close it, so it wasn't " + 'tested. Close it from Steam, then test again.') + android.launch(pkg) + started = up = _wait_running(pkg) + alive, log, seconds = False, '', 0 + if started: + # Look every TEST_STEP seconds, keeping the newest log: a game that quits can take + # its instance (and its log) with it. + steps = max(1, -(-wait // TEST_STEP)) + sh = f'podman exec {ctr} /system/bin/sh -c' + for _ in range(steps): + quest._sleep(wait / steps) + seconds += wait / steps + up = ctr in android.running_instances() + alive = up and bool(android.ssh(f"{sh} 'pidof {pkg}' 2>/dev/null || true").strip()) + if up: + log = collect_log(meta) or log + if not alive: + break + seconds = int(round(seconds)) + verdict = classify(log, alive, up, seconds, started) + code = meta.get('version_code') if meta.get('version_code') is not None else (app or {}).get('version_code') + verdict.update(package=pkg, version=meta.get('version'), version_code=code, label=label, source='meta-store') + verdict['notes'] = report_notes(verdict) + word = OUTCOME_WORDS[verdict['outcome']] + message = f'Test: {label} {word}.' + (f" {verdict['cause']}" if verdict['cause'] else '') + if verdict['detail']: + message += f" ({verdict['detail']})" + if meta.get('vr_issues') and any('platform SDK' in i for i in meta['vr_issues']) and \ + verdict['outcome'] not in ('runs', 'inconclusive'): + message += ' ' + RAN_PLATFORM_SDK + if verdict['outcome'] == 'hangs': + closed, _ = quest._close(pkg) + verdict['closed'] = closed + message += (' Frame Control closed it.' if closed else + " Frame Control couldn't close it: the game may still be running; close it from Steam.") + verdict['message'] = message + if report_result and verdict['result']: + verdict['report'] = submit(verdict) + elif verdict['result'] is None: + verdict['report'] = {'id': None, 'shared': False, 'skipped': 'inconclusive results are not recorded'} + return verdict + + +def report_notes(verdict): + """The report's notes, from fixed texts only: the outcome, the cause, the version code and + the names of the signals matched. Never log text, nothing from the account, never a file.""" + parts = [f"Meta library test: {verdict['outcome']}"] + if verdict.get('cause_key'): + parts.append(CAUSES[verdict['cause_key']]) + if isinstance(verdict.get('version_code'), int): + parts.append(f"version code {verdict['version_code']}") + matched = [k for k in verdict.get('signals', {}).get('matched', []) if k in CAUSES] + if matched: + parts.append('signals: ' + ', '.join(matched)) + return redact('. '.join(p.rstrip('.') for p in parts) + '.', 1000) + + +def submit(verdict): + """Record a test through the existing compatibility report path (frame_catalog.add_report): + kept on this computer, and shared only as the Privacy settings say.""" + import frame_catalog + import frame_telemetry + try: + r = frame_catalog.add_report(verdict['package'], verdict.get('version'), result=verdict['result'], + notes=verdict['notes'], via='probe', label=verdict.get('label'), + source='meta-store') + except Exception as e: # the test still happened: say why it wasn't recorded + return {'id': None, 'shared': False, 'error': redact(e, 300)} + shared = frame_catalog.compat_db.shared() or frame_telemetry.enabled('compat') + return {'id': r.get('id'), 'shared': bool(shared)} + + +# ---- CLI ------------------------------------------------------------------------------ + +def _arg(args, name, default=None): + return args[args.index(name) + 1] if name in args and args.index(name) + 1 < len(args) else default + + +def main(): + cmd, *args = sys.argv[1:] or ['help'] + say = lambda stage, percent=None: print(f'{percent:>3}% {stage}' if percent is not None else stage, file=sys.stderr) + try: + if cmd == 'connect': + if '--token-stdin' not in args: + raise MetaError('usage: connect --token-stdin (then paste the token and press Enter, or pipe it in)') + print(WARNING, file=sys.stderr) + r = connect(sys.stdin.readline()) + elif cmd == 'status': + r = status() + elif cmd == 'disconnect': + r = disconnect() + elif cmd == 'list': + r = [_public(a) for a in with_verdicts(library(refresh='--refresh' in args))] + elif cmd == 'download': + names = [a for a in args if not a.startswith('--')] + if len(names) != 1: + raise MetaError('usage: download APP_ID') + r = download(names[0], progress=say) + elif cmd == 'install': + names = [a for a in args if not a.startswith('--')] + if len(names) != 1: + raise MetaError('usage: install APP_ID [--test] [--allow-downgrade]') + r = install(names[0], test='--test' in args, allow_downgrade='--allow-downgrade' in args, progress=say) + elif cmd == 'test': + names = [a for a in args if not a.startswith('--') and a != _arg(args, '--wait')] + if len(names) != 1: + raise MetaError('usage: test PACKAGE [--wait SECONDS] [--no-report]') + wait = _int(_arg(args, '--wait', TEST_WAIT)) + if not wait or wait > 600: + raise MetaError('--wait must be 1 to 600 seconds') + r = run_test(names[0], wait=wait, report_result='--no-report' not in args) + else: + sys.exit(__doc__) + except (android.FrameError, OSError) as e: + sys.exit('error: ' + redact(e, 2000)) + print(json.dumps(r, indent=1)) + + +if __name__ == '__main__': + main() diff --git a/ui/frame_mods.py b/ui/frame_mods.py new file mode 100644 index 0000000..4f42f6f --- /dev/null +++ b/ui/frame_mods.py @@ -0,0 +1,532 @@ +#!/usr/bin/env python3 +"""Runs ON the Frame (piped over SSH): UEVR, a flat-to-VR mod for Unreal games. + +Usage: python3 - status APPID # can UEVR run here, is it installed, is the game running + python3 - install APPID # download the pinned official release into the game's prefix + python3 - start APPID # launch the game if needed, inject UEVR, confirm SteamVR frames + python3 - uninstall APPID # remove exactly what install (and UEVR's first run) created +Prints one JSON object. Errors are {"error": "..."} with exit status 1. + +Verified 2026-09-29 on SteamOS 0.4.1 (build 20260925.6191901), Proton 11.0 +ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has +UEVRBackend.dll loaded and SteamVR counts frame submits for it. Stereo image, +head tracking and controls were not seen: the headset was unworn (docs/mods.md). + +UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung +or ignored input in about one session in four, and gamescope keeps real input +away from it. Instead the official Windows embeddable Python runs INJECT_PY in +the game's Wine session, doing what the injector does on Inject. + +Nothing here is bundled. UEVR comes from praydog's GitHub release and Python +from python.org, each checked against a pinned hash. Files live in +/drive_c/frame-control, never in the game's own folder, and a receipt +records what to remove. +""" +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.request +import zipfile +from pathlib import Path + +HOME = Path.home() +STEAM = HOME / ".local/share/Steam" +CACHE = HOME / ".cache/frame-control/mods" +RECEIPTS = HOME / ".local/share/frame-control/mods" +LOCK = Path("/tmp/frame-control-mods.lock") # tmpfs: gone at reboot, never left in ~ +VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd" +MANAGED = "frame-control" # drive_c/ inside the game's prefix + +UEVR = {"version": "1.05", "dir": "uevr-1.05", + "url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip", + "sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"} +# Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's +# release-file API (sha256_sum). +PYTHON = {"version": "3.14.7", "dir": "python-3.14.7", + "url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip", + "sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"} +MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB +MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 MB + +# The game's environment a second program needs to join its Wine session. Not +# WINESERVERSOCKET and friends: those are inherited file descriptors. +GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|" + r"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|" + r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=") + + +# Runs under Windows Python in the game's Wine session: the steps of UEVR +# 1.05's frontend (UEVR/MainWindow.xaml.cs Inject_Clicked, UEVR/Injector.cs) +# with "Nullify VR plugins" on and the OpenVR runtime. Prints one JSON line. +INJECT_PY = r""" +import ctypes, json, os, sys +from ctypes import wintypes as W +exe, uevr, profile = sys.argv[1:4] +k32 = ctypes.WinDLL("kernel32", use_last_error=True) +H, P, SZ = W.HANDLE, ctypes.c_void_p, ctypes.c_size_t +for name, res, args in ( + ("OpenProcess", H, [W.DWORD, W.BOOL, W.DWORD]), + ("CloseHandle", W.BOOL, [H]), + ("VirtualAllocEx", P, [H, P, SZ, W.DWORD, W.DWORD]), + ("VirtualFreeEx", W.BOOL, [H, P, SZ, W.DWORD]), + ("WriteProcessMemory", W.BOOL, [H, P, P, SZ, P]), + ("CreateRemoteThread", H, [H, P, SZ, P, P, W.DWORD, P]), + ("WaitForSingleObject", W.DWORD, [H, W.DWORD]), + ("GetModuleHandleW", W.HMODULE, [W.LPCWSTR]), + ("LoadLibraryW", W.HMODULE, [W.LPCWSTR]), + ("FreeLibrary", W.BOOL, [W.HMODULE]), + ("GetProcAddress", P, [W.HMODULE, ctypes.c_char_p]), + ("CreateToolhelp32Snapshot", H, [W.DWORD, W.DWORD]), + ("Process32FirstW", W.BOOL, [H, P]), ("Process32NextW", W.BOOL, [H, P]), + ("Module32FirstW", W.BOOL, [H, P]), ("Module32NextW", W.BOOL, [H, P])): + fn = getattr(k32, name) + fn.restype, fn.argtypes = res, args +INVALID = ctypes.c_void_p(-1).value + +class Entry(ctypes.Structure): # PROCESSENTRY32W + _fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p), + ("module", W.DWORD), ("threads", W.DWORD), ("parent", W.DWORD), ("prio", ctypes.c_long), + ("flags", W.DWORD), ("name", W.WCHAR * 260)] + +class Module(ctypes.Structure): # MODULEENTRY32W + _fields_ = [("size", W.DWORD), ("mid", W.DWORD), ("pid", W.DWORD), ("glbl", W.DWORD), ("proc", W.DWORD), + ("base", ctypes.c_void_p), ("bytes", W.DWORD), ("handle", W.HMODULE), + ("name", W.WCHAR * 256), ("path", W.WCHAR * 260)] + +def fail(msg): + print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); sys.exit(1) + +def snapshot(flags, pid, entry, first, next_, match): + snap = k32.CreateToolhelp32Snapshot(flags, pid) + if not snap or snap == INVALID: + fail("couldn't list processes") + try: + entry.size = ctypes.sizeof(entry) + ok = first(snap, ctypes.byref(entry)) + while ok: + if match(entry): + return entry + ok = next_(snap, ctypes.byref(entry)) + finally: + k32.CloseHandle(snap) + +def remote_call(proc, fn, arg, wait_ms): + t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None) + if not t: + fail("CreateRemoteThread failed") + try: + return k32.WaitForSingleObject(t, wait_ms) == 0 # WAIT_OBJECT_0 + finally: + k32.CloseHandle(t) + +def inject(proc, pid, name): + path = os.path.join(uevr, name) + data = ctypes.create_unicode_buffer(path) + mem = k32.VirtualAllocEx(proc, None, ctypes.sizeof(data), 0x3000, 0x04) # commit|reserve, read/write + if not mem: + fail(f"couldn't allocate in {exe}") + try: + if not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None): + fail(f"couldn't write into {exe}") + load = k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW") + if not remote_call(proc, load, mem, 30000): + fail(f"loading {name} into {exe} didn't finish in 30 s") + finally: + k32.VirtualFreeEx(proc, mem, 0, 0x8000) # MEM_RELEASE + m = snapshot(0x18, pid, Module(), k32.Module32FirstW, k32.Module32NextW, # TH32CS_SNAPMODULE | SNAPMODULE32 + lambda m: m.path.lower() == path.lower()) + if not m: + fail(f"{name} didn't load into {exe}") + return path, m.base + +e = snapshot(2, 0, Entry(), k32.Process32FirstW, k32.Process32NextW, # TH32CS_SNAPPROCESS + lambda e: e.name.lower() == exe.lower()) +if not e: + fail(f"{exe} isn't running") +pid = e.pid +proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS +if not proc: + fail(f"couldn't open {exe}") +path, base = inject(proc, pid, "UEVRPluginNullifier.dll") +local = k32.LoadLibraryW(path) +fn = local and k32.GetProcAddress(local, b"nullify") +if not fn: + fail("couldn't find UEVRPluginNullifier.dll's nullify") +offset = fn - local # same DLL, same layout in both processes +k32.FreeLibrary(local) +# The frontend waits 2 s for nullify and carries on either way; so do we, but say so. +nullified = remote_call(proc, base + offset, None, 2000) +inject(proc, pid, "openvr_api.dll") +# The frontend saves the runtime choice in the per-game config before UEVRBackend reads it. +os.makedirs(profile, exist_ok=True) +cfg = os.path.join(profile, "config.txt") +lines = open(cfg).read().splitlines() if os.path.exists(cfg) else [] +lines = [l for l in lines if not l.startswith("Frontend_RequestedRuntime=")] + ["Frontend_RequestedRuntime=openvr_api.dll"] +open(cfg, "w").write("\n".join(lines) + "\n") +inject(proc, pid, "UEVRBackend.dll") +k32.CloseHandle(proc) +print(json.dumps({"pid": pid, "nullified": nullified})) +""" + + +class Fail(Exception): + pass + + +def libraries(): + """Steam library folders: the default one plus any in libraryfolders.vdf.""" + libs = [STEAM] + try: + text = (STEAM / "steamapps/libraryfolders.vdf").read_text(errors="replace") + libs += [Path(p.replace("\\\\", "\\")) for p in re.findall(r'"path"\s+"([^"]+)"', text)] + except OSError: + pass + seen, out = set(), [] + for lib in libs: + key = os.path.realpath(lib) + if key not in seen: + seen.add(key) + out.append(lib) + return out + + +def game(appid): + """Where the game is installed and its Proton prefix, from its app manifest.""" + for lib in libraries(): + manifest = lib / f"steamapps/appmanifest_{appid}.acf" + try: + text = manifest.read_text(errors="replace") + except OSError: + continue + m = re.search(r'"installdir"\s+"([^"]+)"', text) + if not m: + continue + name = re.search(r'"name"\s+"([^"]*)"', text) + return {"name": name.group(1) if name else str(appid), + "dir": lib / "steamapps/common" / m.group(1), + "prefix": lib / f"steamapps/compatdata/{appid}/pfx"} + raise Fail(f"app {appid} isn't installed on this Frame") + + +def shipping_exe(game_dir): + """The Unreal Engine game binary (…/Binaries/Win64/*-Win64-Shipping.exe), or None.""" + found = [p for p in game_dir.glob("*/Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")] + found += [p for p in game_dir.glob("Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")] + return max(found, key=lambda p: p.stat().st_size) if found else None + + +def game_pid(exe_name): + """The running Wine process for this .exe (its cmdline is the Windows path).""" + want = "\\" + exe_name.lower() + for d in Path("/proc").iterdir(): + if not d.name.isdigit(): + continue + try: + cmd = (d / "cmdline").read_bytes().split(b"\0")[0].decode(errors="replace").lower() + except OSError: + continue + if cmd.endswith(want): + return int(d.name) + return None + + +def loaded(pid, name): + try: + return any(line.rstrip().endswith("/" + name) for line in open(f"/proc/{pid}/maps")) + except OSError: + return False + + +def receipt_path(appid): + return RECEIPTS / f"{appid}-uevr.json" + + +def read_receipt(appid): + try: + return json.loads(receipt_path(appid).read_text()) + except (OSError, ValueError): + return None + + +def managed_dir(g): + return g["prefix"] / "drive_c" / MANAGED + + +def user_dirs(g, exe): + """What UEVR itself writes into the prefix when it first runs.""" + user = g["prefix"] / "drive_c/users/steamuser/AppData" + return {"profile": user / "Roaming/UnrealVRMod" / exe.stem, # per-game settings and log.txt + "injector": user / "Local/praydog"} # the injector's own settings + + +def status(appid): + g = game(appid) + exe = shipping_exe(g["dir"]) + r = read_receipt(appid) + pid = game_pid(exe.name) if exe else None + return {"appid": appid, "name": g["name"], "mod": "UEVR", "version": UEVR["version"], + "eligible": bool(exe), "exe": exe.name if exe else None, + "prefix": g["prefix"].is_dir(), "installed": bool(r), + "running": bool(pid), "injected": bool(pid and loaded(pid, "UEVRBackend.dll"))} + + +def fetch(url, digest, algo): + """Download into the cache once, and check its hash every time it's used.""" + CACHE.mkdir(parents=True, exist_ok=True) + path = CACHE / url.rsplit("/", 1)[1] + if not path.exists(): + part = path.with_suffix(path.suffix + ".part") + try: + with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f: + for block in iter(lambda: r.read(1 << 20), b""): + if f.tell() + len(block) > MAX_DOWNLOAD: + raise Fail(f"{url} is bigger than expected; stopped the download") + f.write(block) + except (OSError, Fail) as e: + part.unlink(missing_ok=True) + raise Fail(f"download failed: {url}: {e}") + part.rename(path) + h = hashlib.new(algo) + with open(path, "rb") as f: + for block in iter(lambda: f.read(1 << 20), b""): + h.update(block) + if h.hexdigest() != digest: + path.unlink() + raise Fail(f"{path.name} doesn't match its published {algo}; deleted it, try again") + return path + + +def unpack(archive, dest, budget): + """Extract a ZIP into dest, refusing anything that would land outside it.""" + root = os.path.realpath(dest) + with zipfile.ZipFile(archive) as z: + for info in z.infolist(): + target = os.path.realpath(os.path.join(dest, info.filename)) + if target != root and not target.startswith(root + os.sep): + raise Fail(f"{archive.name} has an entry outside its folder: {info.filename}") + if (info.external_attr >> 16) & 0o170000 == 0o120000: + raise Fail(f"{archive.name} contains a symlink: {info.filename}") + budget -= info.file_size + if budget < 0: + raise Fail(f"{archive.name} unpacks to more than expected") + z.extractall(dest) + return budget + + +def install(appid): + g = game(appid) + exe = shipping_exe(g["dir"]) + if not exe: + raise Fail(f"{g['name']} doesn't look like an Unreal Engine game (no *-Win64-Shipping.exe), so UEVR can't hook it") + if not g["prefix"].is_dir(): + raise Fail(f"{g['name']} has no Proton prefix yet; play it once, then add the mod") + if game_pid(exe.name): + raise Fail(f"{g['name']} is running; quit it first") + if read_receipt(appid): + return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(appid)} + archives = [(fetch(UEVR["url"], UEVR["sha256"], "sha256"), UEVR["dir"])] + archives.append((fetch(PYTHON["url"], PYTHON["sha256"], "sha256"), PYTHON["dir"])) + base = managed_dir(g) + dirs = user_dirs(g, exe) + fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty + stage = base / f".staging-{os.getpid()}" + shutil.rmtree(stage, ignore_errors=True) + try: + budget = MAX_UNPACKED + for archive, sub in archives: + budget = unpack(archive, stage / sub, budget) + for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"): + if not need.is_file(): + raise Fail(f"the download has no {need.name}") + if game_pid(exe.name): # it may have started during the download + raise Fail(f"{g['name']} started; quit it first") + (stage / PYTHON["dir"] / "frame_inject.py").write_text(INJECT_PY) + for sub in (UEVR["dir"], PYTHON["dir"]): + shutil.rmtree(base / sub, ignore_errors=True) + (stage / sub).rename(base / sub) + finally: + shutil.rmtree(stage, ignore_errors=True) + if str(base) in fresh: + try: + base.rmdir() # only succeeds if the install failed and left it empty + except OSError: + pass + receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name, + "installed": int(time.time()), "prefix": str(g["prefix"]), + "remove": [str(base / UEVR["dir"]), str(base / PYTHON["dir"])], + # Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first. + "remove_if_empty": fresh, + # UEVR creates these on first injection. Remove them on uninstall + # only if they weren't there before, so earlier settings survive. + "remove_if_created": {k: str(v) for k, v in dirs.items() if not v.exists()}, + "sources": [UEVR["url"], PYTHON["url"]]} + RECEIPTS.mkdir(parents=True, exist_ok=True) + tmp = receipt_path(appid).with_suffix(".tmp") + tmp.write_text(json.dumps(receipt, indent=1)) + tmp.rename(receipt_path(appid)) + return {"message": f"Installed UEVR {UEVR['version']} for {g['name']}", **status(appid)} + + +def x11_windows(display): + """(id, name, width) for each named top-level window, via xwininfo.""" + try: + out = subprocess.run(["xwininfo", "-root", "-tree"], env={**os.environ, "DISPLAY": display}, + capture_output=True, text=True, timeout=10).stdout + except (OSError, subprocess.TimeoutExpired): + return [] + return [(int(m[1], 16), m[2], int(m[3])) for m in re.finditer(r'(0x[0-9a-f]+) "([^"]*)":.*?\)\s+(\d+)x\d+', out)] + + +def frame_submits(appid): + """SteamVR's count of frames the app has submitted, or None if it isn't a scene app.""" + try: + stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout) + except (OSError, ValueError, subprocess.TimeoutExpired): + return None + if not isinstance(stats, list): + return None + return next((s.get("frame_submits") for s in stats if isinstance(s, dict) and s.get("key") == f"steam.app.{appid}"), None) + + +def wait(what, seconds, check, step=2): + end = time.time() + seconds + while time.time() < end: + v = check() + if v is not None and v is not False: # 0 frames is still an answer + return v + time.sleep(step) + raise Fail(f"timed out waiting for {what}") + + +def start(appid): + g = game(appid) + r = read_receipt(appid) + if not r: + raise Fail(f"UEVR isn't installed for {g['name']}") + exe = r["exe"] + pid = game_pid(exe) + if pid and loaded(pid, "UEVRBackend.dll"): + return {"message": f"UEVR is already running in {g['name']}", "frame_submits": frame_submits(appid), **status(appid)} + if not pid: + subprocess.Popen(["steam", f"steam://rungameid/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")}, start_new_session=True) + pid = wait(f"{g['name']} to start", 120, lambda: game_pid(exe)) + try: + environ = Path(f"/proc/{pid}/environ").read_text(errors="replace") + except OSError: + raise Fail(f"{exe} exited") from None + game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line)) + display = game_env.get("DISPLAY") + if not display or "WINEPREFIX" not in game_env: + raise Fail(f"{exe} doesn't look like a Proton game process") + # UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's + # own helper windows ("Steam", "Default IME", …) are small. + wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display))) + time.sleep(10) + # Our own environment (HOME and the rest) under the game's Wine settings. + env = {**os.environ, **game_env} + base = f"C:\\{MANAGED}" + profile = f"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\{Path(exe).stem}" + try: + out = subprocess.run(["wine", f"{base}\\{PYTHON['dir']}\\python.exe", f"{base}\\{PYTHON['dir']}\\frame_inject.py", + exe, f"{base}\\{UEVR['dir']}", profile], env=env, capture_output=True, text=True, + timeout=120, stdin=subprocess.DEVNULL, + cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED)) + except subprocess.TimeoutExpired: + raise Fail("the injection step didn't finish in 2 minutes") from None + reply = None + for line in reversed(out.stdout.splitlines()): + try: + reply = json.loads(line) + break + except ValueError: + continue + if not isinstance(reply, dict): + reply = None + if not reply or "error" in reply: + raise Fail("UEVR injection failed: " + (reply or {}).get("error", (out.stderr.strip().splitlines() or ["no output"])[-1])) + try: + wait("UEVRBackend.dll to load in the game", 20, lambda: loaded(pid, "UEVRBackend.dll")) + except Fail: + raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None + submits = None + try: + submits = wait("SteamVR frames", 30, lambda: frame_submits(appid)) + except Fail: + pass + return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None + else "; SteamVR hasn't reported frames from it yet"), + "frame_submits": submits, **status(appid)} + + +def uninstall(appid): + r = read_receipt(appid) + if not r: + raise Fail(f"UEVR isn't installed for app {appid}") + if game_pid(r["exe"]): + raise Fail("the game is running; quit it first") + prefix = os.path.realpath(r["prefix"]) + if not prefix.endswith(f"/steamapps/compatdata/{appid}/pfx"): + raise Fail(f"receipt names a prefix that isn't app {appid}'s: {r['prefix']}") + # Only ever our folder and UEVR's own settings folders for this game, even + # if the receipt says otherwise. + managed = os.path.join(prefix, "drive_c", MANAGED) + uevr_dirs = {os.path.realpath(str(d)) for d in user_dirs({"prefix": Path(prefix)}, Path(r["exe"])).values()} + targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values()) + for t in targets: + real = os.path.realpath(t) + if not (real.startswith(managed + os.sep) or real in uevr_dirs): + raise Fail(f"receipt lists a path Frame Control didn't create: {t}") + for t in targets: + shutil.rmtree(t, ignore_errors=True) + left = [t for t in targets if os.path.lexists(t)] + if left: # keep the receipt, so Remove can be tried again + raise Fail("couldn't remove " + ", ".join(left)) + parents = {os.path.realpath(os.path.join(prefix, "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod")), managed} + for t in r.get("remove_if_empty", []): + if os.path.realpath(t) in parents: + try: + os.rmdir(t) + except OSError: + pass # something else lives there now + receipt_path(appid).unlink() + if not any(RECEIPTS.glob("*-uevr.json")): # no other game uses the downloads + shutil.rmtree(CACHE, ignore_errors=True) + try: + RECEIPTS.rmdir() + except OSError: + pass + return {"message": "Removed UEVR", "appid": appid, "removed": targets} + + +def main(argv): + if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit(): + raise Fail("usage: status|install|start|uninstall APPID") + action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]] + if action is status: + return status(int(argv[1])) + import fcntl # only on the Frame; the server and its tests also import this module on Windows + # One change at a time, or an uninstall could delete what an install is moving in. + LOCK.parent.mkdir(parents=True, exist_ok=True) + with open(LOCK, "w") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + raise Fail("another mod action is still running on the Frame; try again when it finishes") from None + return action(int(argv[1])) + + +if __name__ == "__main__": + try: + print(json.dumps(main(sys.argv[1:]))) + except Fail as e: + print(json.dumps({"error": str(e)})) + sys.exit(1) + except Exception as e: # still one JSON object, for the server to show + print(json.dumps({"error": f"{type(e).__name__}: {e}"})) + sys.exit(1) diff --git a/ui/frame_quest.py b/ui/frame_quest.py new file mode 100644 index 0000000..97555da --- /dev/null +++ b/ui/frame_quest.py @@ -0,0 +1,644 @@ +"""Import from my Quest: copy games you own off your own Meta Quest onto the Frame. + +The Quest is plugged into this computer by USB with developer mode on; its own +adb copies the APK (and split APKs) into a per-user cache. The APK is checked +before anything goes to the Frame, then installed through frame_android.install +like any other APK. Game data (the OBB folder and, if asked, the external +app-data folder) is copied by a second import while the person has the game +open, since only a running Lepton instance can take it; Frame Control never +starts the game itself. The cache is always removed. Games that link Meta's Platform SDK +(libovrplatformloader.so) install with a warning: linking it doesn't mean the game +enforces a licence check, but if it does the game may quit or hang on the Frame, and +Frame Control does not remove the check or work around it. See docs/quest-import.md. + +Python stdlib only. CLI: python3 ui/frame_quest.py + devices | list [--serial SERIAL] | import PACKAGE [--serial SERIAL] [--data] [--allow-downgrade] +""" +import json +import os +from pathlib import Path +import re +import secrets +import shlex +import shutil +import subprocess +import sys +import tarfile +import threading +import time +import zipfile + +import frame_android as android +import frame_host + +OBB = '/sdcard/Android/obb' +DATA = '/sdcard/Android/data' +OBB_NAME = r'(main|patch)\.[0-9]+\.{}\.obb' +# Serials of adb connections that aren't a USB headset: Frame Control's own SSH tunnels +# to the Frame's Lepton instances (127.0.0.1:, server.AdbTunnel) and emulators. +TUNNEL_RE = re.compile(r'^(127\.0\.0\.1|localhost|\[::1\]):\d+$') +QUEST_MAKERS = {'oculus', 'meta'} +META_APPS = ('com.oculus.', 'com.meta.', 'com.facebook.') +CACHE_TTL = 6 * 3600 # an import left behind by a crash is swept after this +ADB_TIMEOUT = 30 +PULL_TIMEOUT = 3 * 3600 +READY_TIMEOUT = 150 +BATCH = 15 # packages per adb shell command while listing + +# Linking the Platform SDK doesn't mean a game enforces the licence check (Meta's +# VRC.Quest.Security.1 has only been Recommended since 2021), so it's a warning, not a refusal. +ENTITLEMENT = ("Uses Meta's licence check (Platform SDK, libovrplatformloader.so); it may quit, hang, or run " + "without online features on the Frame. Frame Control won't remove the check.") +VRAPI = ("Uses Meta's legacy VrApi and no OpenXR loader. The Frame doesn't have VrApi, so it may not run.") +NO_DEVICE = ("No Quest found on this computer's USB. Connect the Quest with a cable that carries data, " + "put the headset on and allow USB debugging. Developer mode must be on first: it needs a Meta " + "developer organisation on a verified Meta account (developers.meta.com), then Developer Mode " + "switched on for the headset in the Meta Horizon phone app.") +UNAUTHORISED = ("The Quest hasn't allowed this computer yet. Put the headset on and accept the \"Allow USB " + "debugging\" prompt (tick Always allow from this computer), then try again. No prompt? Unplug " + "and replug the cable, or switch developer mode off and on.") +NO_PERMISSIONS = ("This computer sees the Quest but isn't allowed to open it over USB. On Linux, add a udev " + "rule for Meta's USB vendor id 2833 (see docs/quest-import.md), then replug it.") +OFFLINE = "adb reports the Quest offline. Unplug and replug the cable, or restart the headset." +GONE = "The Quest was disconnected. Plug it back in, put it on, and try again." + + +class QuestError(android.FrameError): + pass + + +def _adb_cmd(): + try: + return [frame_host.adb()] + except frame_host.HostError as e: + raise QuestError(str(e)) from e + + +def _friendly(out): + low = out.lower() + if 'unauthorized' in low: + return UNAUTHORISED + if 'no permissions' in low or 'insufficient permissions' in low: + return NO_PERMISSIONS + if 'device offline' in low: + return OFFLINE + if 'no devices/emulators found' in low or re.search(r"device '[^']*' not found", low): + return GONE + return None + + +def adb(*args, serial=None, timeout=ADB_TIMEOUT): + """Run this computer's adb (the one Frame Control already finds) and return stdout.""" + cmd = _adb_cmd() + (['-s', serial] if serial else []) + list(args) + try: + r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors='replace', + timeout=timeout) + except subprocess.TimeoutExpired: + raise QuestError(f'adb {args[0]} took more than {timeout} s; check the Quest is still connected') + except OSError as e: + raise QuestError(f'could not run adb: {e}') + if r.returncode != 0: + out = (r.stderr or '').strip() or (r.stdout or '').strip() + raise QuestError(_friendly(out) or out[-600:] or f'adb exited {r.returncode}') + return r.stdout + + +# ---- Finding the Quest ------------------------------------------------------ + +def _parse_devices(out): + found = [] + for line in out.splitlines(): + line = line.strip() + if not line or line.startswith('List of devices') or line.startswith('*'): + continue + parts = line.split(None, 1) + serial, rest = parts[0], (parts[1] if len(parts) > 1 else '') + state = 'no permissions' if rest.startswith('no permissions') else (rest.split() or ['unknown'])[0] + fields = dict(t.split(':', 1) for t in rest.split() if re.match(r'^[a-z_]+:', t)) + found.append({'serial': serial, 'state': state, 'usb': 'usb' in fields, + 'model': fields.get('model', '').replace('_', ' '), 'manufacturer': ''}) + return found + + +def devices(): + """Every adb device on this computer, each with a kind: quest, frame, android (another + Android device), frame-tunnel (Frame Control's own SSH-tunnelled connection to a Lepton + instance, never touched here), emulator, or unknown (not authorised, offline...).""" + found = _parse_devices(adb('devices', '-l', timeout=15)) + for d in found: + if TUNNEL_RE.match(d['serial']): + d['kind'] = 'frame-tunnel' + elif d['serial'].startswith('emulator-'): + d['kind'] = 'emulator' + elif d['state'] != 'device': + d['kind'] = 'unknown' + else: + try: + props = adb('shell', 'getprop ro.product.manufacturer; getprop ro.product.model', + serial=d['serial'], timeout=15).splitlines() + ['', ''] + except QuestError: + d['kind'] = 'unknown' + continue + d['manufacturer'], d['model'] = props[0].strip(), props[1].strip() or d['model'] + maker = d['manufacturer'].lower() + d['kind'] = ('quest' if maker in QUEST_MAKERS or 'quest' in d['model'].lower() + else 'frame' if maker == 'valve' else 'android') + return found + + +def find_quest(serial=None): + """The one connected, authorised Quest, or a QuestError saying what to do.""" + found = devices() + if serial: + d = next((d for d in found if d['serial'] == serial), None) + if not d: + raise QuestError(f'No adb device {serial} on this computer. ' + NO_DEVICE) + if d['kind'] == 'quest': + return d + state = {'unauthorized': UNAUTHORISED, 'no permissions': NO_PERMISSIONS, 'offline': OFFLINE}.get(d['state']) + what = ' '.join(x for x in (d['manufacturer'] or d['kind'], d['model']) if x) + raise QuestError(state or f"{serial} isn't a Meta Quest ({what}).") + quests = [d for d in found if d['kind'] == 'quest'] + if len(quests) == 1: + return quests[0] + if quests: + raise QuestError('Several Quests are connected: ' + ', '.join(f"{d['model']} ({d['serial']})" for d in quests) + + '. Choose one, or unplug the others.') + states = {d['state'] for d in found if d['kind'] == 'unknown'} + for state, message in (('unauthorized', UNAUTHORISED), ('no permissions', NO_PERMISSIONS), ('offline', OFFLINE)): + if state in states: + raise QuestError(message) + others = [d for d in found if d['kind'] in ('android', 'frame')] + if others: + raise QuestError(NO_DEVICE + ' (adb sees ' + ', '.join( + f"{d['manufacturer']} {d['model']}".strip() for d in others) + ", which isn't a Quest.)") + raise QuestError(NO_DEVICE) + + +# ---- What's on it ------------------------------------------------------------- + +def _package_script(packages): + parts = [] + for p in packages: + parts += [f'echo @@{p}', f'pm path {p}', f'du -sk {OBB}/{p} 2>/dev/null', f'du -sk {DATA}/{p} 2>/dev/null'] + return '; '.join(parts + ['true']) + + +def _parse_packages(out, packages): + apps, current = {}, None + for line in out.splitlines(): + line = line.strip() + if line.startswith('@@') and line[2:] in packages: + current = apps[line[2:]] = {'package': line[2:], 'apks': [], 'obb': None, 'data': None} + elif current is None or not line: + continue + elif line.startswith('package:'): + current['apks'].append(line[len('package:'):]) + else: + size, _, where = line.partition('\t') if '\t' in line else line.partition(' ') + if size.isdigit(): + key = 'obb' if where.strip().startswith(OBB + '/') else 'data' if where.strip().startswith(DATA + '/') else None + if key: + current[key] = int(size) * 1024 + result = [] + for p in packages: + app = apps.get(p) + if not app or not app['apks']: + continue # removed while listing + app['split'] = len(app['apks']) > 1 + app['meta'] = p.startswith(META_APPS) + result.append(app) + return result + + +def packages(serial): + """Third-party apps on the Quest: APK paths (several means a split APK), and the size + in bytes of the OBB and external data folders (None when there is none).""" + out = adb('shell', 'pm list packages -3', serial=serial) + names = sorted({l.split(':', 1)[1].strip() for l in out.splitlines() if l.startswith('package:')}) + names = [n for n in names if android.PKG_RE.match(n)] + apps = [] + for i in range(0, len(names), BATCH): + batch = names[i:i + BATCH] + apps += _parse_packages(adb('shell', _package_script(batch), serial=serial, timeout=120), batch) + return apps + + +def package(serial, pkg): + apps = _parse_packages(adb('shell', _package_script([pkg]), serial=serial, timeout=60), [pkg]) + if not apps: + raise QuestError(f"{pkg} isn't installed on the Quest") + return apps[0] + + +def status(serial=None, with_apps=True): + """For the page: the Quest (or why there isn't one), what else adb sees, and its apps.""" + try: + found = devices() + except QuestError as e: + return {'quest': None, 'devices': [], 'guidance': str(e), 'apps': []} + try: + quest = find_quest(serial) + except QuestError as e: + return {'quest': None, 'devices': found, 'guidance': str(e), 'apps': []} + return {'quest': quest, 'devices': found, 'guidance': None, + 'apps': packages(quest['serial']) if with_apps else []} + + +# ---- Checking what was copied --------------------------------------------------- + +def inspect(package_name, base, splits=()): + """Refuse what can't run on the Frame (32-bit only, Android newer than Lepton's, splits + that can't be merged), before anything is copied to it; warn about what may not run.""" + names = set() + for path in [base, *splits]: + try: + with zipfile.ZipFile(path) as z: + names |= set(z.namelist()) + except (zipfile.BadZipFile, OSError) as e: + raise QuestError(f'{os.path.basename(path)} copied from the Quest is not a readable APK: {e}') + info = android.apk_info(base) + label = info['label'] + if info['package'] != package_name: + raise QuestError(f"The APK copied for {package_name} says it is {info['package']}; not importing it") + platform_sdk = any(n.rsplit('/', 1)[-1] == 'libovrplatformloader.so' for n in names) + abis = sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}) + if abis and 'arm64-v8a' not in abis: + raise QuestError(f"{label} is a 32-bit-only build ({', '.join(abis)}). The Frame runs 64-bit ARM " + "(arm64-v8a) apps only; older Quest games are often 32-bit. Ask the developer " + "whether a 64-bit build exists.") + android.check_installable(dict(info, abis=abis)) # Android version + if splits: + android.split_entries(base, splits) # a split APK Frame Control can't merge fails now + warnings = [ENTITLEMENT] if platform_sdk else [] + openxr = 'lib/arm64-v8a/libopenxr_loader.so' in names + if 'lib/arm64-v8a/libvrapi.so' in names and not openxr: + warnings.append(VRAPI) + return {'package': info['package'], 'label': label, 'version': info['version'], + 'version_code': info.get('version_code'), 'abis': abis, + 'vr': bool(info.get('vr') or openxr), 'platform_sdk': platform_sdk, 'warnings': warnings} + + +# ---- Importing ------------------------------------------------------------------ + +_import_lock = threading.Lock() +_sleep = time.sleep + + +def cache_root(): + return Path(frame_host.cache_dir('quest-import')) + + +def sweep_cache(now=None): + """Remove imports a crash left behind (older than CACHE_TTL).""" + root, now = cache_root(), now or time.time() + if root.is_dir(): + for d in root.iterdir(): + try: + if now - d.stat().st_mtime > CACHE_TTL: + shutil.rmtree(d, ignore_errors=True) + except OSError: + pass + + +def _safe(name): + return re.sub(r'[^A-Za-z0-9._-]', '_', name)[:120] or 'part.apk' + + +def _remote_sizes(serial, paths): + out = adb('shell', 'stat -c %s ' + ' '.join(shlex.quote(p) for p in paths), serial=serial) + return sum(int(x) for x in out.split() if x.isdigit()) + + +def _pull(serial, remote, local): + adb('pull', remote, str(local), serial=serial, timeout=PULL_TIMEOUT) + + +def _wait_ready(meta): + """Until the app's Lepton instance has finished booting Android.""" + container = f"lepton-steamlaunch-{int(meta['instance'])}" + deadline = time.monotonic() + READY_TIMEOUT + while time.monotonic() < deadline: + out = android.ssh(f"podman exec {container} /system/bin/sh -c 'getprop sys.boot_completed' " + "2>/dev/null || true", timeout=30) + if out.strip() == '1': + return + _sleep(3) + raise QuestError(f"{meta['label']} didn't start on the Frame within {READY_TIMEOUT // 60} minutes, so its " + "game data couldn't be added. Check it opens from the Steam library, then import again.") + + +def push_data(meta, folder): + """Stream a pulled /sdcard/Android/data/ folder into the running instance as a + tar, written straight into the SSH transfer: nothing is staged on this computer.""" + import frame_android_data + pkg = meta['package'] + container = f"lepton-steamlaunch-{int(meta['instance'])}" + script = f'set -eu; mkdir -p {DATA}/{pkg}; cd {DATA}/{pkg}; tar -x -o -f -' + read_end, write_end = os.pipe() + state = {'count': 0, 'error': None} + + def write(): + try: + with os.fdopen(write_end, 'wb') as out, \ + tarfile.open(fileobj=out, mode='w|', format=tarfile.GNU_FORMAT) as tar: + for path in sorted(Path(folder).rglob('*')): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + continue # regular files and folders only + info = tar.gettarinfo(str(path), arcname=path.relative_to(folder).as_posix()) + info.uid = info.gid = 0 + info.uname = info.gname = '' + if path.is_file(): + with path.open('rb') as f: + tar.addfile(info, f) + state['count'] += 1 + else: + tar.addfile(info) + except (OSError, tarfile.TarError, ValueError) as e: # ValueError: the reader closed first + state['error'] = e + + writer = threading.Thread(target=write, daemon=True) + writer.start() + try: + with os.fdopen(read_end, 'rb') as src: + frame_android_data._stream(shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script]), + src=src) + finally: + writer.join(60) + if state['error']: + raise QuestError(f'could not read the app files copied from the Quest: {state["error"]}') + return state['count'] + + +def space_needed(apk_bytes, merged_bytes, obb_bytes, data_bytes): + """Bytes an import needs free on this computer: the APKs as copied, a merged APK and the + install's patched copy of it (each up to merged_bytes), the OBB folder and the + Android/data folder (streamed to the Frame, never staged twice).""" + return apk_bytes + 2 * merged_bytes + obb_bytes + data_bytes + + +def _check_space(work, need, pkg): + free = shutil.disk_usage(str(work)).free + if need > free: + raise QuestError(f'Importing {pkg} needs about {need / 1024**3:.1f} GB free on this computer ' + f'({free / 1024**3:.1f} GB available in {work.parent})') + + +def import_game(package_name, serial=None, data=False, progress=None, allow_downgrade=False): + """Copy package_name from the Quest to the Frame. progress(stage, percent). + + Frame Control never starts the game itself. Its game data (OBB, and Android/data if + asked) can only be written into a running Lepton instance, and there is no way to + boot one without its game, so that's a second import: the person starts the game, + imports again, and Frame Control copies the data in and closes the game. + + allow_downgrade: replace a newer version on the Frame with the Quest's older one.""" + if not android.PKG_RE.match(package_name or ''): + raise QuestError('invalid package name') + report = progress or (lambda stage, percent=None: None) + if not _import_lock.acquire(blocking=False): + raise QuestError('Another Quest import is running; wait for it to finish') + try: + report('Looking for your Quest', 1) + quest = find_quest(serial) + app = package(quest['serial'], package_name) + sweep_cache() + work = cache_root() / f'{package_name}-{secrets.token_hex(4)}' + work.mkdir(parents=True) + try: + return _import(quest['serial'], app, data, work, report, allow_downgrade) + finally: + shutil.rmtree(work, ignore_errors=True) + finally: + _import_lock.release() + + +def _running(pkg): + # strict: a failed `podman ps` raises, never reads as "not running". + return f'lepton-steamlaunch-{android.instance_id(pkg)}' in android.running_instances(strict=True) + + +def _installed_code(pkg, meta): + """The installed version code: from the install record, else (installs made before + records kept it) read from the installed app.apk on the Frame; None if unknown.""" + code = (meta or {}).get('version_code') + if isinstance(code, int) and not isinstance(code, bool): + return code + return android.installed_version_code(pkg) if meta else None + + +def _version(name, code): + return ' '.join(x for x in (f'version {name}' if name else '', f'code {code}' if code is not None else 'unknown code') if x) + + +def _check_downgrade(existing, checked, allow): + """Refuse to replace a newer install on the Frame with the Quest's older copy.""" + if not existing or allow: + return + have, new = _installed_code(existing['package'], existing), checked['version_code'] + if have is None or new is None: + raise QuestError(f"Frame Control can't tell which version of {checked['label']} is on the Frame, so it " + "won't replace it in case that one is newer. Allow replacing a newer version (or " + "--allow-downgrade) to install your Quest's copy anyway.") + if int(new) < int(have): + raise QuestError(f"The Frame has a newer {checked['label']} ({_version(existing.get('version'), have)}) than " + f"your Quest ({_version(checked['version'], new)}). Frame Control won't replace it with " + "an older version unless you allow it (Allow replacing a newer version, or " + "--allow-downgrade).") + + +def _check_target(pkg, meta, checked): + """The install record of the game open on the Frame, if it is this package, in its own + instance, at the Quest's exact version code; else a QuestError.""" + label = checked['label'] + if (not meta or meta.get('package') != pkg or android._int(meta.get('instance')) != android.instance_id(pkg)): + raise QuestError(f"{label} is open on the Frame, but Frame Control has no matching install record for " + 'it. Close it, then import again to install it.') + have, new = _installed_code(pkg, meta), checked['version_code'] + if have is None or new is None or int(have) != int(new): + raise QuestError(f"{label} is open on the Frame with a different version (Frame: " + f"{_version(meta.get('version'), have)}; Quest: {_version(checked['version'], new)}). " + "Close it, then import again to install the Quest's version.") + return meta + + +def _check_android_package(meta, code): + """The package Android itself has installed in the instance is this one, at this version.""" + pkg = meta['package'] + container = f"lepton-steamlaunch-{int(meta['instance'])}" + out = android.ssh(f"podman exec {container} /system/bin/sh -c 'dumpsys package {pkg}' 2>/dev/null || true", + timeout=60) + # Only this package's own block (dumpsys can list other packages too). + block = re.search(r'^\s*Package \[' + re.escape(pkg) + r'\] \(.*?(?=^\s*Package \[|\Z)', out, re.M | re.S) + found = re.search(r'\bversionCode=(\d+)', block.group(0)) if block else None + if not found: + raise QuestError(f'Android in {container} does not have {pkg} installed') + if int(found.group(1)) != int(code): + raise QuestError(f'Android in {container} has {pkg} version code {found.group(1)}, not the ' + f"Quest's {code}") + + +def _close(pkg): + """Stop the game and confirm its instance is gone: (closed, why not).""" + try: + android.stop(pkg) + for _ in range(6): + if not _running(pkg): + return True, None + _sleep(2) + return False, 'it is still running' + except Exception as e: # never replaces the error that led here; unconfirmed counts as not closed + return False, str(e) or type(e).__name__ + + +def _import(serial, app, data, work, report, allow_downgrade=False): + pkg = app['package'] + obb_bytes = app['obb'] or 0 + data_bytes = (app['data'] or 0) if data else 0 + apk_bytes = _remote_sizes(serial, app['apks']) + _check_space(work, space_needed(apk_bytes, apk_bytes, obb_bytes, data_bytes), pkg) + # 1. The APK (and its splits), checked before anything else is copied. + local = [] + for i, remote in enumerate(app['apks']): + report(f'Copying the app from your Quest ({i + 1} of {len(app["apks"])})', 5 + 20 * i // len(app['apks'])) + name = 'base.apk' if os.path.basename(remote) == 'base.apk' else f'{i:02d}-{_safe(os.path.basename(remote))}' + _pull(serial, remote, work / name) + if not (work / name).is_file(): + raise QuestError(f'adb did not copy {remote} from the Quest') + local.append(work / name) + base = next((p for p in local if p.name == 'base.apk'), local[0]) + splits = [p for p in local if p != base] + report('Checking it can run on the Frame', 27) + checked = inspect(pkg, str(base), [str(p) for p in splits]) + warnings = list(checked['warnings']) + label = checked['label'] + # A merge stores what splits compressed: count it uncompressed, now the files are here. + merged = base.stat().st_size + android.split_size([str(p) for p in splits]) + _check_space(work, space_needed(0, merged, obb_bytes, data_bytes), pkg) + wants_data = app['obb'] is not None or bool(data and app['data'] is not None) + if not _running(pkg): + # 2a. Install. The game is never started here: see import_game. Checked and installed + # under the shared install lock, so nothing installs or removes it in between. + report('Installing on the Frame', 60) + with android._install_lock: + if _running(pkg): + raise QuestError(f'{label} was started on the Frame during the import. Close it, then import again.') + _check_downgrade(android.read_meta(pkg), checked, allow_downgrade) + meta = android.install(str(base), splits=[str(p) for p in splits], source='Meta Quest (USB)') + report('Done', 100) + message = f"Imported {meta['label']} from your Quest. It's in the Steam library." + if wants_data: + what = ' and '.join(x for x in ('its OBB game data' if app['obb'] is not None else '', + 'its Android/data files' if data and app['data'] is not None else '') if x) + message += (f" To add {what}, start {meta['label']} from the Steam library, leave it on its first " + "screen, then import it again here: Frame Control copies the data in and closes the game.") + note = android.layer_note(meta) + return {'message': ' '.join([message, *warnings, *([note] if note else [])]), 'app': meta, 'obb': [], + 'files': 0, 'warnings': warnings, 'split': bool(splits), 'needs_game_data': wants_data, + 'closed': None} + # 2b. The game is open on the Frame: add its data to exactly this install, never replacing it. + _check_target(pkg, android.read_meta(pkg), checked) # early, before copying the data off the Quest + if not wants_data: + raise QuestError(f"{label} is already installed and open on the Frame, and has no game data to add. " + 'Close it first to install it again.') + obb_files = [] + if app['obb'] is not None: + report('Copying game data (OBB) from your Quest', 30) + (work / 'obb').mkdir() + _pull(serial, f'{OBB}/{pkg}', work / 'obb') + folder = work / 'obb' / pkg + files = sorted(p for p in folder.iterdir() if p.is_file()) if folder.is_dir() else [] + obb_files = [p for p in files if re.fullmatch(OBB_NAME.format(re.escape(pkg)), p.name)] + skipped = [p.name for p in files if p not in obb_files] + if skipped: + warnings.append('Not copied, not standard OBB file names: ' + ', '.join(skipped)) + data_folder = None + if data and app['data'] is not None: + report('Copying app files from your Quest', 50) + (work / 'data').mkdir() + _pull(serial, f'{DATA}/{pkg}', work / 'data') + data_folder = work / 'data' / pkg if (work / 'data' / pkg).is_dir() else None + result = add_game_data(pkg, checked, [str(p) for p in obb_files], data_folder, report, warnings) + result['split'] = bool(splits) + return result + + +def add_game_data(pkg, checked, obb_files, data_folder, report, warnings=(), source='your Quest'): + """Copy game data into the game open on the Frame, then close it: the second step of a + two-step install (Import from my Quest, and My Meta library). checked is inspect()'s + result for the APK the data belongs to; obb_files are local OBB paths, data_folder a + local Android/data/ folder or None. Raises QuestError, after closing the game, + if anything fails.""" + warnings = list(warnings) + label = checked['label'] + copied_obb, copied_files, failure = [], 0, None + # Fetching the data can take a long time: check the target again, and hold the shared + # install lock (install, remove and install_obb take it too) until the game is closed. + with android._install_lock: + if not _running(pkg): + raise QuestError(f'{label} was closed on the Frame during the import. Start it, then import again.') + meta = _check_target(pkg, android.read_meta(pkg), checked) + closed, why = None, None + try: + report('Waiting for Android on the Frame', 70) + _wait_ready(meta) + _check_android_package(meta, checked['version_code']) + if obb_files: + report('Copying game data (OBB) to the Frame', 80) + copied_obb = android.install_obb(pkg, [str(p) for p in obb_files])['obb'] + if data_folder: + report('Copying app files to the Frame', 92) + copied_files = push_data(meta, data_folder) + except Exception as e: # any failure, local (OSError) or on the Frame, is reported below + failure = e + finally: + # Whatever happened, even Ctrl-C part-way through a write, the game is closed so it + # never runs on half-copied data. A failed close never hides the original error. + interrupted = sys.exc_info()[0] is not None + closed, why = _close(pkg) + if interrupted and not closed: + print(f"Frame Control couldn't close {meta['label']} on the Frame ({why}); close it from Steam " + 'before playing.', file=sys.stderr) + if failure: + message = f"Copying {meta['label']}'s game data failed: {failure}." + message += (' The game was closed; start it and import again to retry.' if closed else + f" Frame Control also couldn't close the game ({why}): close it from Steam, then start it " + 'and import again.') + raise QuestError(message) from failure + report('Done', 100) + message = f"Added {meta['label']}'s game data from {source}" + message += ('; the game is closed, start it again from the Steam library.' if closed else + f", but Frame Control couldn't close the game ({why}). Close it from Steam before playing, so it " + 'starts with the new data.') + if copied_obb: + message += f' Added {len(copied_obb)} OBB file{"s" if len(copied_obb) != 1 else ""}.' + if copied_files: + message += f' Copied {copied_files} app file{"s" if copied_files != 1 else ""}.' + return {'message': ' '.join([message, *warnings]), 'app': meta, 'obb': copied_obb, 'files': copied_files, + 'warnings': warnings, 'split': False, 'needs_game_data': False, 'closed': closed} + + +def main(): + cmd, *args = sys.argv[1:] or ['help'] + serial = args[args.index('--serial') + 1] if '--serial' in args and args.index('--serial') + 1 < len(args) else None + try: + if cmd == 'devices': + r = devices() + elif cmd == 'list': + r = packages(find_quest(serial)['serial']) + elif cmd == 'import': + names = [a for a in args if not a.startswith('--') and a != serial] + if len(names) != 1: + raise QuestError('usage: import PACKAGE [--serial SERIAL] [--data] [--allow-downgrade]') + r = import_game(names[0], serial, data='--data' in args, allow_downgrade='--allow-downgrade' in args, + progress=lambda stage, percent=None: print(f'{percent:>3}% {stage}' if percent is not None + else stage, file=sys.stderr)) + else: + sys.exit(__doc__) + except (android.FrameError, OSError) as e: + sys.exit(f'error: {e}') + print(json.dumps(r, indent=1)) + + +if __name__ == '__main__': + main() diff --git a/ui/frame_telemetry.py b/ui/frame_telemetry.py index 854e3b7..071b51c 100644 --- a/ui/frame_telemetry.py +++ b/ui/frame_telemetry.py @@ -512,7 +512,7 @@ def compat_report(report): if k in p: p[k] = scrub(p[k], n) src = str(p.pop('source', '') or '') - if src == 'F-Droid': + if src in ('F-Droid', 'meta-store'): # fixed source tags, never a person's file name p['source'] = src elif src.startswith(('http://', 'https://')) and _scrub_url(src) != '': p['source'] = _scrub_url(src) diff --git a/ui/index.html b/ui/index.html index b22c7ed..f4de328 100644 --- a/ui/index.html +++ b/ui/index.html @@ -27,8 +27,10 @@ } * { box-sizing: border-box; } [hidden] { display: none !important; } /* class display rules would otherwise win */ - html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); } - body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom)); + /* On a computer the header and the Activity bar are the window's chrome: the page scrolls in #scroll + between them, so neither covers anything. Phones scroll the window (see the phone rules below). */ + body { margin: 0; color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden; + padding-bottom: calc(42px + env(safe-area-inset-bottom)); font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif; background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%), linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; } @@ -38,13 +40,14 @@ ::-webkit-scrollbar-track { background: transparent; } /* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */ - header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px; + #scroll { flex: 1; min-height: 0; overflow: auto; scroll-behavior: smooth; scroll-padding-top: 12px; } + header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px; flex: none; min-width: 0; height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px); box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); } - .brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; } + .brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; flex: none; } .brand svg { width: 30px; height: 30px; } .brand b { font-weight: 700; font-size: 16px; letter-spacing: -.1px; } - nav { display: flex; gap: 4px; height: 100%; } + nav { display: flex; gap: 4px; height: 100%; flex: none; } nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none; font-weight: 500; font-size: 15px; border-bottom: 3px solid transparent; transition: color .15s; } @@ -62,11 +65,21 @@ .dot.off { background: var(--bad); } main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; } - .grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; } + .grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; align-items: start; } .grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; } @media (max-width: 1366px) { header { gap: 20px; } nav a { padding: 0 11px; } } - @media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } } - @media (max-width: 820px) { .brand b { display: none; } } + /* The header gives way as the window narrows: the wordmark goes, then the tabs tighten, then the + connection pill drops its second line. It fits down to the app's 760 px minimum, with room for + the macOS window buttons the app adds on the left. */ + .hdr-end > button:not(.pill), .hdr-end > .chip { flex: none; } + .hdr-end > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; } + @media (min-width: 1367px) and (max-width: 1500px) { header { gap: 22px; } } + @media (max-width: 1360px) { .brand b { display: none; } } + @media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } } + @media (max-width: 1030px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; } + .brand, #refreshAll { display: none; } + .pill .pt > b, .pill .via { display: none; } header .pill .pt > span { font-size: 13px; color: var(--bright); } header .pill { min-width: 118px; } } + @media (max-width: 820px) { nav a { padding: 0 6px; } } nav svg { display: none; } html { -webkit-text-size-adjust: 100%; } body { -webkit-tap-highlight-color: transparent; } @@ -92,13 +105,35 @@ .wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; } /* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */ - .pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 150px; flex: 0 1 auto; background: var(--btn); } + .pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 320px; min-width: 106px; flex: 0 1 auto; background: var(--btn); } .pill .dot { flex: none; } .pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; } @keyframes pulse { 50% { opacity: .35; } } .pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; } .pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + /* "Connected" is never cut short: when the route doesn't fit beside it, the route wraps out of sight. */ + .pill .pt > span { font-size: 11.5px; color: var(--muted); max-width: 100%; display: flex; flex-wrap: wrap; height: 1.2em; overflow: hidden; white-space: nowrap; } + .pill .via { white-space: pre; } + .conn-now { display: flex; gap: 10px; align-items: baseline; } + .conn-now .dot { position: relative; top: -1px; } + .conn-now b { color: var(--bright); font-weight: 500; font-size: 15px; } + .conn-offer { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 14px; padding: 10px 12px; border-radius: 4px; + background: rgba(26,159,255,.1); border-left: 3px solid var(--blue); font-size: 13px; } + .conn-offer .grow { flex: 1 1 260px; } + .h3-note { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 6px; } + #connAddrs .item { gap: 6px; } + #connAddrs .t { font-size: 13.5px; overflow-wrap: anywhere; } + .conn-add { grid-template-columns: 2fr 1.2fr auto; } + .conn-edit { grid-template-columns: 2fr 1.2fr auto auto; } + .addr-add button.small, .dev-name button.small { height: 40px; } + .dev-name { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; max-width: 520px; } + .adv { margin-top: 18px; border-top: 1px solid rgba(255,255,255,.06); padding-top: 4px; } + .adv .dev-form { grid-template-columns: 1.3fr 1fr .6fr; } + .conn-more { margin-top: 16px; } + .conn-more summary { margin-top: 0; } + /* The dialog scrolls when the steps are open in a short window; its buttons stay in reach. */ + #connDlg .rep-actions { position: sticky; bottom: -22px; margin: 16px -22px -22px; padding: 12px 22px; + background: #1e2329; border-top: 1px solid rgba(255,255,255,.06); } .devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; min-width: 90px; } .dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } @@ -142,8 +177,8 @@ .addr .t { overflow-wrap: anywhere; } .addr .s, .dev-problem .s { white-space: normal; } .addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; } - .addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; } - @media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } } + .addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; } + @media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } } .found { margin-top: 10px; } /* ---- drop anywhere ---- */ @@ -157,8 +192,10 @@ /* ---- panels and section headers ---- */ .panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%); border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; } - .shelf-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; } - .shelf-head h2 { margin: 0; font-size: 17px; font-weight: 600; color: var(--bright); } + /* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */ + .shelf-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 12px; margin: 0 0 14px; } + .panel > .shelf-head:first-child h2 { line-height: 28px; } /* as tall as a button, so headings line up across panels */ + .shelf-head h2 { margin: 0; font-size: 17px; font-weight: 600; color: var(--bright); white-space: nowrap; } .shelf-head .count { color: var(--muted); font-size: 13px; } .shelf-head .spacer { flex: 1; } .sub { color: var(--muted); font-size: 12.5px; } @@ -187,6 +224,11 @@ border-radius: 3px; padding: 9px 11px; font: inherit; } textarea { resize: vertical; min-height: 76px; } input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); } + /* Every drop-down dark, like the fields; the panels' own rules below still size them. */ + select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; + padding: 7px 8px; font: inherit; font-size: 13px; max-width: 100%; min-width: 0; } + select:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; } + option { background: #1e2329; color: var(--text); } .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } /* ---- headset view ---- */ @@ -204,6 +246,10 @@ .zoombar button:hover { background: rgba(255,255,255,.12); } .zoombar .lvl { min-width: 46px; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px; } .viewer .empty { text-align: center; color: var(--muted); padding: 24px; max-width: 460px; } + /* Nothing captured yet: a short strip rather than a big black box. */ + .viewer:has(> #viewerEmpty:not([hidden])) { aspect-ratio: auto !important; height: 190px; + background: radial-gradient(500px 160px at 50% 0%, rgba(42,71,94,.55), transparent 70%), #0b1016; } + .viewer:has(> #viewerEmpty:not([hidden])) .empty svg { width: 44px; height: 44px; margin-bottom: 4px; } .viewer .empty svg { width: 64px; height: 64px; opacity: .35; margin-bottom: 10px; } .viewer .badge { position: absolute; left: 12px; top: 12px; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase; background: rgba(0,0,0,.6); color: var(--text); padding: 4px 9px; border-radius: 2px; } @@ -231,15 +277,40 @@ @keyframes spin { to { transform: rotate(360deg); } } .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; } - /* ---- device ---- */ - .battery { display: flex; gap: 16px; align-items: center; padding: 16px; border-radius: 3px; background: rgba(0,0,0,.22); } - .battery svg { width: 74px; height: 38px; flex: none; } - .battery .pct { font-size: 34px; font-weight: 300; color: var(--bright); line-height: 1; font-variant-numeric: tabular-nums; } - .battery .state { font-weight: 700; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin-top: 6px; } - .battery .state.charging { color: var(--green-hi); } .battery .state.full { color: var(--green); } - .battery .state.low { color: var(--bad); } .battery .state.draining { color: var(--muted); } - .battery .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; } + /* ---- Home: what the headset's doing, and what you'd do next ---- */ + .now-state { margin: 0 0 4px; color: var(--bright); font-size: 15px; } + .now-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 16px; } + .now-actions button { justify-content: center; height: 38px; white-space: normal; line-height: 1.2; text-align: center; } + .now-actions button:first-child { grid-column: 1 / -1; } + @media (max-width: 1150px) { .now { order: -1; } } /* stacked: what's happening comes first */ + #padIntro { margin: 0; } + .group-head { margin: 4px 0 -8px; } + a.button-link { display: inline-flex; align-items: center; height: 34px; padding: 0 14px; border-radius: 3px; background: var(--btn); + color: var(--text); text-decoration: none; } + a.button-link:hover { background: var(--btn-hi); color: var(--bright); } + /* A panel that folds: its heading row is the summary. */ + .fold > summary { list-style: none; cursor: pointer; margin: 0; color: inherit; font-size: inherit; } + .fold > summary::-webkit-details-marker { display: none; } + .fold > summary h2::before { content: "▸"; display: inline-block; width: 1.1em; color: var(--muted); } + .fold[open] > summary h2::before { content: "▾"; } + .fold:not([open]) > summary { margin-bottom: 0; } + .fold > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 4px; } + + /* ---- the headset's details: a menu under the battery chip ---- */ + .pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto; + padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text); + box-shadow: 0 20px 60px rgba(0,0,0,.6); -webkit-app-region: no-drag; } + .pop .shelf-head { margin-bottom: 10px; } + .pop .shelf-head h2 { min-width: 0; overflow: hidden; text-overflow: ellipsis; } + .pop .stats { margin-top: 12px; } + .pop .chips { margin-top: 12px; } + .batt .state { font-weight: 700; font-size: 12.5px; letter-spacing: 1.2px; text-transform: uppercase; } + .batt .state.charging { color: var(--green-hi); } .batt .state.full { color: var(--green); } + .batt .state.low { color: var(--bad); } .batt .state.draining { color: var(--text); } + .batt .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; } + button.chip { height: 32px; padding: 0 12px; font-size: 13px; } .stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; } + .stats > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; } .stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; } .stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; } .stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums; @@ -340,7 +411,7 @@ white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); } .log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); } .log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); } - .toast { position: fixed; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px; + .toast { position: fixed; inset: auto; margin: 0; border: 0; overflow: visible; width: auto; height: auto; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px; background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue); opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; } .toast.show { opacity: 1; transform: none; } @@ -349,9 +420,9 @@ .and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; } .and-col { display: grid; gap: 22px; align-content: start; } .rep-item .s { white-space: normal; } - #bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; + #bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } - #bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); } + #bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); } #bugDlg { width: min(640px, calc(100vw - 40px)); } #bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; } #bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; } @@ -360,7 +431,7 @@ #bugForm .popt { margin: 14px 0 0; } #bugForm .sentlog { max-height: 200px; } #bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; } - #bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } + #bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2, #modDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } .about-text { max-height: 55vh; overflow: auto; } .about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); } .about-text summary { cursor: pointer; margin: 8px 0; } @@ -368,7 +439,7 @@ #repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; } #titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 8px 10px; font: inherit; } - #titleForm select:focus { outline: none; border-color: var(--blue); } + #titleForm select:focus { border-color: var(--blue); } #titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; } #titleForm .note.warn { color: #d9a23a; } #titleList { margin-top: 8px; } @@ -401,8 +472,9 @@ .cat-card .s { color: var(--muted); font-size: 12px; } .cat-card .why { color: var(--muted); font-size: 11.5px; line-height: 1.4; } .cat-card .row { margin-top: auto; } - .v-works, .v-likely { color: var(--green-hi); } .v-maybe { color: var(--warn); } - .v-unlikely, .v-no { color: var(--bad); } + .v-works, .v-likely, .item .s.v-works, .item .s.v-likely { color: var(--green-hi); } + .v-maybe, .item .s.v-maybe { color: var(--warn); } + .v-unlikely, .v-no, .item .s.v-unlikely, .item .s.v-no { color: var(--bad); } /* over .item .s's grey */ .vbadge { font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; margin-left: auto; } @media (max-width: 1100px) { .and-grid { grid-template-columns: 1fr; } } /* ---- Steam games (owned + store) ---- */ @@ -415,7 +487,6 @@ .game-card .bar > i { display: block; height: 100%; background: var(--action); } .f-0 { color: var(--muted); } /* ---- Android display ---- */ - #mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; } .mv-windows { max-height: 420px; overflow-y: auto; } .mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; } .mv-perm .row { margin-top: 8px; } @@ -424,7 +495,7 @@ .mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; } .disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 8px 10px; font: inherit; font-size: 13px; } - .disp select:focus { outline: none; border-color: var(--blue); } + .disp select:focus { border-color: var(--blue); } .disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; } .disp select:disabled { opacity: .6; } .disp .stat .v { font-size: 15px; } @@ -451,11 +522,17 @@ /* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */ @media (max-width: 640px), (max-height: 500px) and (hover: none) { - html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); } + html { scroll-behavior: smooth; scroll-padding-top: calc(64px + env(safe-area-inset-top)); } + body { height: auto; display: block; overflow: visible; } + #scroll { overflow: visible; } /* No blur here: it would make the header the containing block of the fixed tab bar. */ header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px; backdrop-filter: none; background: rgb(23, 29, 37); } .brand svg { width: 26px; height: 26px; } + .brand { display: flex; } /* the tabs are at the bottom here; Refresh is automatic */ + .pill .pt > b { display: block; } .pill .via { display: inline; } header .pill .pt > span { font-size: 11.5px; color: var(--muted); } + header .pill { min-width: 104px; } + @media (max-width: 374px) { .brand { display: none; } } /* the smallest phones: the pill needs the room */ header .chip { height: 30px; padding: 0 10px; } /* Let connection text yield before battery and header actions. */ .hdr-end { flex: 1; gap: 6px; } @@ -531,8 +608,9 @@ .store-chips { display:flex; flex-wrap:wrap; gap:7px; } .store-chips button { border-radius:20px; font-size:12px; height:34px; padding:0 14px; background:#ffffff07; color:#b5c0cb; border:1px solid #ffffff0c; } .store-chips button[aria-pressed=true] { background:#235779; border-color:#5287a5; color:white; } - .store-source-select select { max-width:180px; padding:8px 26px 8px 10px; font:inherit; font-size:12px; border:0; border-radius:5px; color:var(--text); background:#1d2b3b; } .store-notice { color:#a8b5c2; font-size:12px; margin:12px 0; } + .store-heading a, .cross-link a { color:var(--link); } + .cross-link { margin:-6px 0 12px; } body:has(.page.on #appStore:not([hidden])) #offline { padding:10px 16px; background:#ffffff06; box-shadow:inset 3px 0 0 var(--dim); } body:has(.page.on #appStore:not([hidden])) #offline .s { display:none; } .store-result-count { font-size:13px; color:var(--muted); margin-bottom:14px; } @@ -540,14 +618,24 @@ .store-hero { position:relative; display:block; width:100%; height:270px; border:0; padding:0; border-radius:12px; overflow:hidden; text-align:left; margin:0 0 32px; isolation:isolate; } .store-art { position:relative; display:block; overflow:hidden; background:radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%),transparent 70%),linear-gradient(140deg,hsl(var(--hue) 35% 18%),#101827); } .store-art > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; } - .art-initial { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:70px; font-weight:700; color:#ffffff45; letter-spacing:-4px; } + .store-art > img.art-wash { inset:-24px; width:calc(100% + 48px); height:calc(100% + 48px); z-index:0; filter:blur(26px) saturate(1.4); opacity:.55; } + .store-art > img.art-icon { inset:0; margin:auto; width:64px; height:64px; border-radius:15px; z-index:0; box-shadow:0 10px 28px #0007; } + .detail-banner > img.art-icon, .store-hero .store-art > img.art-icon { width:88px; height:88px; border-radius:20px; } + .store-art .art-glyph { position:absolute; inset:0; display:grid; place-items:center; color:hsl(var(--hue) 45% 72% / .38); } + .store-art .art-glyph svg { width:34%; max-width:84px; height:auto; } + .runs { font-size:11px; font-weight:500; padding:3px 8px; border-radius:10px; white-space:nowrap; } + .runs.works { color:#c6efb0; background:#70bb5426; } .runs.ready { color:#b7d8ec; background:#6ec9ff1a; } + .runs.unknown { color:#a8b5c2; background:#ffffff0d; } .runs.blocked { color:#f1c28a; background:#d9a23a22; } .store-hero > .store-art { position:absolute; inset:0; } .store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; } .hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; } .hero-copy .store-eyebrow { color:#c2e7ff; } .hero-copy strong { color:white; font-size:38px; letter-spacing:-.8px; line-height:1.1; } - .hero-copy > span:not(.store-eyebrow):not(.hero-cta) { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; } - .hero-cta { color:white; display:inline-flex; align-items:center; background:#ffffff20; border:1px solid #ffffff40; border-radius:6px; padding:10px 18px; font-size:13px; margin-top:6px; } + .hero-copy .hero-sum { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; } + .hero-actions { display:flex; gap:10px; margin-top:6px; } + .hero-actions button { height:38px; border-radius:6px; padding:0 18px; } + .hero-details { background:#ffffff20; color:#fff; border:1px solid #ffffff40; } + .store-hero { cursor:pointer; } .store-row { margin-bottom:34px; } .store-row-head { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; } .store-row-head h2 { text-transform:none; letter-spacing:-.3px; font-size:21px; font-weight:600; color:var(--bright); margin:0; } @@ -563,7 +651,6 @@ .store-card-title strong { font-size:15px; color:var(--bright); text-overflow:ellipsis; overflow:hidden; white-space:nowrap; } .store-summary { display:block; color:#a8b5c2; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .store-card-foot { display:flex; justify-content:space-between; align-items:center; gap:6px; margin-top:16px; font-size:11px; color:#a8b5c2; } - .store-card-foot b { color:#dbeecf; font-weight:500; } .store-badges { display:flex; flex-wrap:wrap; gap:7px; } .store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; } .store-badge.works { color:#b8e2ab; background:#70bb5418; } @@ -712,6 +799,7 @@ .source-setting strong { display:block; font-size:14px; color:white; } .source-setting p { color:#a3b4c3; font-size:12px; margin:3px 0; } .source-setting small { color:var(--muted); font-size:10px; } + .source-web { font-size:12px; white-space:nowrap; } .source-toggle { appearance:none; width:38px; height:23px; flex:none; border-radius:15px; background:#465463; position:relative; cursor:pointer; margin:0; } .source-toggle::after { content:""; position:absolute; top:3px; left:3px; width:17px; height:17px; background:#fff; border-radius:50%; transition:transform .15s; } .source-toggle:checked { background:#208dca; } @@ -723,6 +811,8 @@ .source-repo-form input { margin-top:7px; display:block; border-radius:6px; } .source-repo-form details { font-size:11px; color:var(--muted); } .source-repo-form button { justify-self:start; border-radius:6px; } + .source-token { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin:8px 0 4px; } + .source-token input { flex:1; min-width:150px; border-radius:6px; font-size:12px; } #appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; } .store-search input:focus-visible { outline:0!important; } @media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } } @@ -735,7 +825,7 @@ .store-chips { gap:5px; } .store-chips button { font-size:11px; padding:0 10px; } .store-hero { height:285px; border-radius:9px; margin-bottom:26px; } .hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; } - .hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; } + .hero-copy .hero-sum { font-size:13px; max-width:260px; } .store-row-head { display:flex; align-items:center; } .store-row-head h2 { font-size:18px; } .store-row-head p { display:none; } .store-heading .store-eyebrow, .store-heading p { display:none; } .store-heading h1 { font-size:26px; margin:0 0 12px; } .store-shelf { grid-auto-columns:minmax(200px,72%); gap:12px; } @@ -752,7 +842,7 @@ .detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; } .sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; } } - @media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } } + @media(prefers-reduced-motion:reduce) { html, #scroll { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } } /* Task hierarchy shared by desktop and phone. */ :root { --muted:#a1adba; } @@ -775,8 +865,6 @@ .quick-routes a:hover { background:#243a50; border-color:#66c0f470; } .quick-routes span { color:white; font-size:16px; font-weight:500; } .quick-routes small { color:var(--muted); font-size:13px; } - .home-status > summary { cursor:pointer; color:var(--link); padding:14px 0; min-height:44px; } - .home-status #device { max-width:900px; } [data-page=capture] .grid-top { display:block; } .library-controls { display:grid; gap:12px; } .scope-tabs { display:flex; gap:4px; flex-wrap:wrap; align-items:center; } @@ -952,16 +1040,30 @@
- — - +
+ +
← Tools

Capture & Live

See the headset, capture a moment, or control a desktop panel.

Screenshots
@@ -1223,6 +1312,12 @@
Made for your Frame

Apps

VR experiences, Android apps and desktop apps.

+
@@ -1241,6 +1336,57 @@
+
+
+

Import from my Quest

+
+
Plug your Quest into this computer by USB, put it on, then Look for my Quest.
+ + + +
+
Copies games you own from your own Quest (developer mode on) to the Frame. A game with + OBB data takes two imports: the first installs it; start it from the Steam library, then import again to + copy its data in. Frame Control never starts the game itself. Games that + use Meta's licence check install with a warning: on the Frame that check can't reach Meta, so they may quit, + hang, or run without online features. Frame Control won't remove that check. How it works
+
+
+

My Meta library

Experimental + +
+
Off. Nothing is sent to Meta until you connect.
+

Experimental. This uses Meta's undocumented internal API with your own Meta + access token. It may stop working whenever Meta changes things, and using it may be against Meta's terms; your + account is your responsibility. It only lists and downloads games your account owns, installs them as they are, + and never removes a licence check.

+
+ + +
+ +
+
How to get your token. + Sign in with Meta (desktop app) opens Meta's login page in its own Frame Control window and takes the token + from there; it never reads your browser. + Frame Control checks it with Meta, then keeps it in a private file on this computer (never on the Frame, and + never in reports). Disconnect deletes it.
+
+ + + +
+
Install downloads the game from Meta and installs it like Import from my Quest; a game with OBB + data takes a second Install while it is open. Install and test also starts it, adds its data, watches it for + about 30 seconds and records whether it ran; Test again does just the test. Results go to the compatibility + reports, shared only if you share compatibility results. How it works
+
+
@@ -1253,7 +1399,7 @@

VR comfort and performance

Waiting for the Frame
-
Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Temperature is the hottest readable sensor.
+
Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Battery and temperature are in the battery menu at the top.
@@ -1268,10 +1414,7 @@
Load this optional list to check the Frame account.
-

Family and comfort

- -
-

Share this screen with people in the room. Casting shows everything the wearer sees, including private content.

+

Family and comfort

Session timer and reminders
@@ -1299,6 +1442,9 @@

Keyboard and trackpad

+

Use this computer's keyboard and mouse in apps on the Frame, such as Chromium or the + desktop's Linux apps.

+ +

Display and reports

@@ -1395,8 +1542,8 @@
-

Media in the headset

Preview
-

Play your movies and stereo photos with Frame Control's own player.

+

Media player

Preview
+

Watch your own films and stereo photos on a big screen in the headset.

@@ -1431,7 +1578,7 @@
-

Library artwork settings

+

Library artwork

SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.

@@ -1468,22 +1615,23 @@

Panel switcher

- -
+
Refresh to see open panels.
+
Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard. Saved spatial layouts are not available yet.