diff --git a/.gitattributes b/.gitattributes index 6a5ef04..bad39d7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,3 +6,8 @@ *.json text eol=lf *.md text eol=lf *.bat text eol=crlf +# Test fixtures are byte-exact (hashes, signatures): never convert line endings. +tests/fixtures/** -text +*.apk binary +*.jar binary +*.obb binary diff --git a/README.md b/README.md index 640edf0..83557ee 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,15 @@ SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down. -Nothing is installed on the Frame for any of this: the app uses what SteamOS +The optional [Family and comfort](docs/family-comfort.md) card adds session +limits, breaks, local alerts and one-click casting. A session copies a small +Frame Control worker into your headset user account. + +For the other features, nothing is installed on the Frame: the app uses what SteamOS already ships (sideloading a game copies Valve's own devkit scripts to -`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md). +`~/devkit-utils`, as Valve's Devkit Client does). The optional +[performance HUD](docs/vr-utilities.md) copies our own Python helpers into +`~/.local/share/frame-control/vr/`. [How each feature works](docs/frame-control.md). ## Install @@ -209,7 +215,7 @@ Frame's software fits together, all checked against a real headset and labelled | [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances | | [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 | -| [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 | +| [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 | | [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes | diff --git a/app/main.js b/app/main.js index 3a93318..7d77675 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, clipboard, dialog, ipcMain, shell } = require("electron"); +const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron"); const { execFile, spawn } = require("child_process"); const { promisify } = require("util"); const fs = require("fs"); @@ -149,7 +149,7 @@ async function startServer() { const target = `http://127.0.0.1:${port}/`; for (let i = 0; i < 100; i++) { if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`); - if (await ping(target)) { url = target; return; } + if (await ping(target)) { url = target; serverStarted = Date.now(); return; } await new Promise((r) => setTimeout(r, 100)); } if (server === child) server = null; @@ -159,51 +159,76 @@ async function startServer() { // Closing stdin lets server.py close its SSH connections and exit (the only clean // way on Windows); SIGTERM does the same elsewhere. +// Resolves once it has exited (or after 20 s), so a replacement can take the server +// lock: server.py allows one per user. function endServer(child) { + const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve() + : new Promise((resolve) => child.once("exit", resolve)); try { child.stdin.end(); } catch {} if (!IS_WIN) child.kill("SIGTERM"); - setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref(); + // server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill. + setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref(); + return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]); } function stopServer() { if (server) endServer(server); } -function errorPage(message) { +function errorPage(message, title = "Frame Control couldn't start") { const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); const html = ` -

Frame Control couldn't start

-

${esc(message)}

Fix it, then choose Frame → Restart Server.

`; +

${esc(title)}

+

${esc(message)}

+

+

Frame → Restart Server does the same.

`; return "data:text/html;charset=utf-8," + encodeURIComponent(html); } +// A server that had been running starts again by itself (something stopped it: a +// signal, a crash). One that stops again within a minute shows the error instead, +// so a server that can't stay up doesn't restart forever. +let serverStarted = 0; function serverDied(why) { url = null; - if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`)); + if (!win) return; + if (Date.now() - serverStarted > 60000) restartServer(); + else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped")); } -async function restartServer() { - const old = server; - server = null; - url = null; - if (old) endServer(old); - await load(); +// Restarts that overlap share one: two could each start a server, and the one +// that lost the lock would leave the app pointing at nothing. +let restarting = null; +function restartServer() { + if (!restarting) { + restarting = (async () => { + const old = server; + server = null; + url = null; + if (old) await endServer(old); + if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh + await load(); + })().finally(() => { restarting = null; }); + } + return restarting; } // On macOS the page's sticky header becomes the title bar, clear of the traffic lights. const CHROME_CSS = IS_MAC && ` header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; } - header a, header button, header input, header .chip { -webkit-app-region: no-drag; } + header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; } `; // Restart Server can start a new load while an older one is still waiting for // its server; only the newest load may touch the window. let loadGen = 0; +let starting = null; // loads that overlap share one server start async function load() { const gen = ++loadGen; try { - if (!url) await startServer(); + if (!url) await (starting ||= startServer().finally(() => { starting = null; })); if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); } } catch (e) { if (gen === loadGen && win) await win.loadURL(errorPage(e.message)); @@ -247,13 +272,81 @@ function fromUi(e) { } catch { return false; } } +// The error page's Try Again button. The error page is the only data: page the window +// shows (`url` can still be set then: the server answered but the page failed to load). +ipcMain.handle("server:restart", (e) => { + if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer(); +}); ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : ""); +// A PNG or JPEG (a screenshot) onto the clipboard as an image. +ipcMain.handle("clipboard:writeImage", (e, bytes) => { + if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false; + const img = nativeImage.createFromBuffer(Buffer.from(bytes)); + if (img.isEmpty()) throw new Error("not an image"); + clipboard.writeImage(img); + return true; +}); ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); }); ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); }); ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null); ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null); ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); }); +// The page reports the headsets it knows (the server's Devices tab), so the Frame +// menu can switch between them. Only plain names and ids go into the menu. +const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; +let devices = []; +ipcMain.on("devices:changed", (e, list) => { + if (!fromUi(e) || !Array.isArray(list)) return; + const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || "")) + .map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active })); + if (JSON.stringify(next) === JSON.stringify(devices)) return; + devices = next; + buildMenu(); +}); +const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME; + +// SSH through the server, so it goes to the headset and address the app is using +// (with its own pinned identity), and refuses when there's none. +function openSsh() { + if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running."); + const body = JSON.stringify({ what: "terminal" }); + const req = http.request(new URL("/api/open", url), { + method: "POST", timeout: 15000, + headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) }, + }, (res) => { + let data = ""; + res.on("data", (c) => { data += c; }); + res.on("end", () => { + if (res.statusCode === 200) return; + let why = `HTTP ${res.statusCode}`; + try { why = JSON.parse(data).error || why; } catch {} + dialog.showErrorBox("Couldn't open SSH", why); + }); + }); + req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message)); + req.on("timeout", () => req.destroy(new Error("the server didn't answer"))); + req.end(body); +} +function showDevices() { + if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {}); +} + +ipcMain.handle("comfort:notify", (e, message) => { + if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification"); + if (!Notification.isSupported()) throw new Error("System notifications are unavailable"); + return new Promise((resolve, reject) => { + const notification = new Notification({title: "Frame Control", body: message}); + const timer = setTimeout(() => reject(new Error("Notification delivery was not confirmed. Check system notification settings.")), 5000); + notification.once("show", () => { clearTimeout(timer); resolve(true); }); + notification.once("failed", (_event, error) => { + clearTimeout(timer); + reject(new Error("Notification delivery failed. Check system notification settings: " + error)); + }); + notification.show(); + }); +}); + // 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 @@ -376,7 +469,7 @@ function createWindow() { title: "Frame Control", backgroundColor: BG, show: false, ...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } } : { icon: path.join(__dirname, "build", "icon.png") }), - webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, + webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false, preload: path.join(__dirname, "preload.js") }, }); win.once("ready-to-show", () => win.show()); @@ -410,13 +503,14 @@ async function runInTerminal(argv) { } } -async function setUpConnection() { - const alias = `FRAME_ALIAS=${FRAME}`; +// Set Up Connection for the headset in use (or another alias, from the Devices tab). +async function setUpConnection(name = activeAlias()) { + if (!ALIAS_RE.test(name)) return; + const alias = `FRAME_ALIAS=${name}`; if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]); const py = python || await findPython({ ...process.env, PATH: await loginPath() }); - const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")]; - // A new console inherits our environment on Windows; Linux terminals may not. - runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]); + // --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment. + runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]); } function buildMenu() { @@ -431,8 +525,15 @@ function buildMenu() { { label: "Frame", submenu: [ - { label: "Set Up Connection…", click: setUpConnection }, - { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) }, + { label: "Set Up Connection…", click: () => setUpConnection() }, + { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh }, + { type: "separator" }, + ...(devices.length > 1 ? [{ + label: "Headset", + submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active, + click: () => { if (win) win.webContents.send("use-device", d.id); } })), + }] : []), + { label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices }, { type: "separator" }, { label: "Open in Browser", click: () => url && shell.openExternal(url) }, { label: "Restart Server", click: () => win ? restartServer() : createWindow() }, diff --git a/app/package.json b/app/package.json index ca083aa..f59e889 100644 --- a/app/package.json +++ b/app/package.json @@ -48,9 +48,18 @@ "filter": [ "*.py", "*.html", + "*.js", "telemetry.json" ] }, + { + "from": "../ui/apk_sources", + "to": "ui/apk_sources", + "filter": [ + "*.py", + "*.json" + ] + }, { "from": "../scripts", "to": "scripts", @@ -63,7 +72,8 @@ "to": "frame/android", "filter": [ "*.sh", - "*.py" + "*.py", + "*.js" ] }, { diff --git a/app/preload.js b/app/preload.js index c8579b4..6dcec17 100644 --- a/app/preload.js +++ b/app/preload.js @@ -2,15 +2,25 @@ // to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells // the page where a dropped file or folder lives, so a folder can be sideloaded // as a title without zipping it (the local server reads it from there). -// It can open Set Up Connection when the headset can't be reached. +// It can put a screenshot on the clipboard as an image, open Set Up Connection when +// the headset can't be reached, and keeps the Frame menu's list of headsets up to date. // It also receives frame-control://install links (docs/web-install.md): only // what the link asked for, never an install; the page asks the user first. // And it passes update state both ways: see app/updater.js. const { contextBridge, ipcRenderer, webUtils } = require("electron"); contextBridge.exposeInMainWorld("frameApp", { + notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request), readClipboard: () => ipcRenderer.invoke("clipboard:read"), + writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes), setUpConnection: () => ipcRenderer.invoke("connection:setup"), + restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again + // The Frame menu's headset switcher: the page tells it the headsets, and hears picks. + devicesChanged: (list) => ipcRenderer.send("devices:changed", list), + onUseDevice: (cb) => { + ipcRenderer.removeAllListeners("use-device"); + ipcRenderer.on("use-device", (_e, id) => cb(String(id))); + }, // While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame. captureKeys: (on) => ipcRenderer.send("keys:capture", !!on), pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } }, diff --git a/docs/agents.md b/docs/agents.md index e77633c..76ee9c1 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -168,6 +168,43 @@ on this branch (run 36421345682). **Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated browser profile and SSH tunnel and remove the profile and panel log. +**Verified end to end on the same Frame/build (2026-09-29), with mutations:** +a stdio MCP client started `ui/frame_mcp.py` in its default mode (private +backend, no API key, no prestarted server) and a human approved or rejected +each change in the approval page in a real Chrome window: + +| Tool | Result on the Frame | +|---|---| +| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents | +| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s | +| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) | +| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app | +| `uninstall` | Approved; app and locale removed | +| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off | +| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) | +| `keep_awake` | `status` reports the script unavailable until PR #16 lands | + +A separate Claude Code CLI session, with only this server configured, read +status, `computer_state` and a headset capture, and requested an install. It +received an approval URL and did not execute anything. + +The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`. +Against a loopback stub model, a send without consent made zero requests. With +consent it made exactly one, carrying the text and a fresh Frame screenshot. +Consent unticked itself after sending. SIGTERM removed the panel, profile, log +and tunnel. + +**Not verified while unworn:** every headset capture was a uniform dark frame, +so SteamVR's rendered view of panels and the game could not be checked; window +captures (`xwd`) were used instead. MCP can launch a game or panel but has no +tool to stop one: the tester stopped them over SSH. Removing an app leaves any +runtime it pulled in; Flatpak may also remove related extensions when that +runtime is removed by hand. + +![Approval page showing the exact install action](img/mcp-approval-install.png) + +![The installed Flatpak rendering in its own gamescope panel window](img/mcp-panel-binary.png) + ![Assistant in Frame Chromium, after an opted-in request to the local test endpoint](img/assistant-panel.png) ## Computer-use coverage diff --git a/docs/apk-repos.md b/docs/apk-repos.md new file mode 100644 index 0000000..25a86dd --- /dev/null +++ b/docs/apk-repos.md @@ -0,0 +1,144 @@ +# APK repositories + +Frame Control supports **F-Droid-format repositories**, including F-Droid, +F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository +indexes are authenticated before their apps appear. Search lists builds with +Android API ≤30 and arm64-v8a or no native libraries, using the same streaming +reducer as the existing catalogue. This does not guarantee an app works in Lepton. + +## Formats considered + +| Format | Users and purpose | Support in this source | +|---|---|---| +| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes | +| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` | +| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index | +| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter | +| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source | +| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid | + +Research references (checked 2026-09-28): + +- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and + [repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/). +- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/) + and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/). +- [Droid-ify](https://github.com/Droid-ify/client) and + [Neo Store](https://github.com/NeoApplications/Neo-Store). +- [Obtainium](https://github.com/ImranR98/Obtainium), its + [configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/), + and [community app configurations](https://apps.obtainium.imranr.dev/). +- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest). + The absence of a specification in these materials is not proof that no + historical or private custom-feed format exists. + +## Add a repository in Frame Control + +From the Frame Control checkout, use its source-management CLI: + +```sh +python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps' +python3 ui/apk_sources/fdroid.py list +python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music' +python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app +python3 ui/apk_sources/fdroid.py remove SOURCE_ID +``` + +Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint` +can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…` +links are accepted and converted to HTTPS. Conflicting fingerprints are refused. +A URL must identify the repository directory, not its website or an index file. + +Adding fetches and validates the complete index **before saving** the source. +Without a fingerprint, Frame Control verifies the JAR signature and remembers +its signer: trust on first use (TOFU). This establishes continuity with the +first server response, not independent publisher identity. Obtain the published +fingerprint through a trusted channel when possible; the store's Add a source +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. + +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)`, +`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 +separate work. Built-in sources can be disabled but cannot be removed. + +Settings and pins live in `frame_host.data_dir('apk-repos.json')` +(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS). +Authenticated reduced indexes and APKs live under +`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. An +expired index is still served (marked stale in the store) while it refreshes in +the background; a failed refresh is retried after 10 minutes. +Only the running Frame Control app prunes cached APKs (at start and after store +downloads); the command-line tools never do. +The existing catalogue's unverified index cache is never treated as authenticated. + +Rollback protection: each repository's newest accepted signed index timestamp +is kept in `apk-repo-state.json` next to the settings, and an older index is +refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar` +is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar` +must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`). +Removing a repository clears its state. + +## Publish your own repository + +Only publish free APKs you own or have the developer's permission to distribute. +Do not publish paid app mirrors or bypass store licences. Check distribution +terms before adding someone else's repository; this module does not infer legal +permission from a signature or automatically audit a repository's terms. + +Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/) +and its documented Android/Java dependencies on the publishing machine, then: + +```sh +mkdir my-fdroid +cd my-fdroid +fdroid init +# Set repo_url in config.yml to https://example.org/fdroid/repo +# Also set repo_name and repo_description; keep the generated signing key safe. +cp /path/to/your-free-app.apk repo/ +fdroid update --create-metadata +# Review the generated metadata (name, summary, licence, source and website). +fdroid update +``` + +Serve the generated **repo directory** at that HTTPS URL, including APKs, +icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the +private signing keystore or configuration passwords. Configure fdroidserver's +`serverwebroot` and run `fdroid deploy` for managed publication, or copy the +public directory with your existing deployment tool. Publish the SHA-256 +repository certificate fingerprint displayed by fdroidserver in a link such as +`https://example.org/fdroid/repo?fingerprint=…`. + +Keep the repository signing key backed up: changing it breaks existing pins. +For updates, add the new APK, edit metadata as needed, run `fdroid update` and +publish again. Test the published URL with Frame Control's `add`, `search` and +`download` commands. The above publisher setup is documented from fdroidserver; +it was not executed as part of this implementation. + +## Verification and limits + +The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of +2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are +recognized. It checks the signer certificate pin, the signature over `.SF`, +the whole-manifest digest, and the manifest's digest of the JSON member. +ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are +rejected. Certificates are pinned identities, not validated as Web PKI chains. +HTTPS certificates are separately checked by Python's normal TLS validation. + +v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature, +fingerprint, index hash, TLS and server errors never trigger an unsigned +fallback. APKs are cached by SHA-256 and checked again before reuse. Here, +`verified: true` means the bytes match the signed repository's APK hash; it +is not an independent APK publisher-signature or runtime compatibility verdict. +There is no repository timestamp rollback/expiry policy or automated signing-key +rotation yet. An old correctly signed index can still validate. + +Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache +reuse, URL rejection and corruption of every signature/hash layer. On the Mac, +the real IzzyOnDroid repository was added with its published pin, searched for +Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256 +`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`. +No headset connection or installation was performed. diff --git a/docs/apk-sources.md b/docs/apk-sources.md new file mode 100644 index 0000000..7caca71 --- /dev/null +++ b/docs/apk-sources.md @@ -0,0 +1,103 @@ +# Developer-consented APK sources + +Surveyed 2026-09-28. Free access is not proof of redistribution permission or +Frame compatibility. These adapters fetch only public publisher releases or +link to publisher pages. They do not acquire store entitlements, defeat access +checks, install anything, or rehost APKs. See [VR compatibility](vr-apks.md). + +| Source | Developer consent and automated-access position | API/feed; VR coverage | Decision | +|---|---|---|---| +| [itch.io](https://itch.io/docs/legal/terms) | Publishers warrant distribution rights (§4). Users may access content through the service; this is not blanket scraping permission. Main robots excludes `/game/download/`; author subdomains exclude `/*/download/`. No challenge bypass. | Public free Android RSS for `openxr` and `oculus-quest`; substantial indie VR. Server API is mostly authenticated publisher/account functionality, not a general anonymous store-download API. | Implement RSS search, artwork and page links; `downloadable: False`. The supplied free-download script follows keyed download pages excluded by robots, so it is not shipped. | +| [GitHub releases](https://docs.github.com/en/rest/releases/releases) | Maintainers publish assets; curated repositories below establish provenance. Public hosting or an open-source topic alone does not establish rights to every uploaded binary. Use supported REST API under [API terms](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#h-api-terms), not HTML crawling. | Releases API includes APK assets and sometimes SHA-256. Topic search finds OpenXR/Quest projects. 60 unauthenticated requests/hour; authenticated user limits are generally 5,000/hour, with separate search/secondary limits. | Implement curated downloads and explicit topic discovery. Unreviewed topic results are page-only. | +| [Uptodown](https://www.uptodown.com/aboutus) | Developer distribution program exists, but that does not prove publisher authorization for every catalog item. [Privacy policy](https://www.uptodown.com/aboutus/privacy) explicitly describes protection against automated access. General automation permission was not established. | Broad Android catalog, limited VR focus; no supported public consumer-download API established in this survey. | Page links only; no downloader. Do not infer consent from an unchanged APK signature. | +| [APKPure](https://apkpure.com/terms) | Third-party APK catalog; individual publisher consent and automation rights were not established. Terms request returned HTTP 403; no bypass attempted. | Broad Android coverage, incidental VR; internal endpoints are not permission to automate. | Exclude automatic indexing/downloading; user may open site. | +| [APKMirror](https://www.apkmirror.com/faq/) | Publisher-signed files and a free-app policy are not a blanket developer-consent or automation grant. FAQ request returned HTTP 403, so current terms could not be confirmed. | General Android/version archive; APK bundles often need another installer; little VR focus. No supported consumer-download API established. | Page links only, no scraping or bundle conversion. | +| [Aptoide](https://en.aptoide.com/company/legal) | Terms define an app supplier as developer, owner or authorized distributor; user stores still require per-item provenance. API availability alone does not settle third-party access rights. | API ecosystem and general Android catalog; weak VR focus. | Defer until a publisher-owned store and its API terms can be approved. No blanket community-store downloader. | +| [Amazon Appstore](https://developer.amazon.com/docs/app-submission/understanding-submission.html) | Official developer submissions; store account, device and license rules apply. Publisher submission APIs do not authorize public binary extraction. | Fire-device distribution; Android-device Appstore support ended in 2025; little Quest relevance. | Official product links only; no account or entitlement extraction. | +| [PICO / ByteDance store](https://developer.picoxr.com/document/distribute) | Official publisher channel with store/device entitlements. No public unauthenticated binary-download grant established; documentation request encountered a redirect error. | Strong standalone VR; PICO builds may depend on PICO services/extensions. | Store links only. A developer's independently published GitHub/itch build can qualify separately. | +| [Meta Horizon Store / former App Lab](https://www.meta.com/experiences/) | Official developer submissions. A free store entitlement is still an entitlement; no license bypass or authenticated store extraction. App Lab was folded into the main store in 2024. | Strongest Quest coverage; no supported anonymous APK-download API established. | Store links only; independently distributed free builds use their publisher source. | +| [Khronos samples](https://github.com/KhronosGroup/OpenXR-SDK-Source) | Official upstream, Apache-2.0 sample; developer-published release APKs. GitHub API terms apply. | `hello_xr` Vulkan/OpenGL ES APKs; excellent OpenXR diagnostics. | Included in GitHub curated list, Vulkan variant selected. | +| [Meta OpenXR samples](https://github.com/meta-quest/Meta-OpenXR-SDK) | Official upstream; check each sample's license. Source availability does not imply a published APK, and some samples require Meta extensions/services. | Source/build examples, inconsistent ready-made APK releases. | Link to upstream; add specific free APKs only after release/provenance review. | +| [Godot XR demos](https://github.com/GodotVR/godot-xr-tools) | Official project source and publisher demo pages; licenses and dependencies vary by demo. | OpenXR examples on GitHub/itch. Older Godot builds can fail on Lepton's missing clipboard service. | Covered by source discovery; no compatibility promise from an OpenXR tag. | + +The table distinguishes observed restrictions from unknown permission. An +unverified policy is a reason to defer automation, not a claim that a site is +unlawful. Only the two implemented source kinds are registered by their own +`sources()` functions; the other rows are recommendations, not new UI entries. + +## Adapters + +`ui/apk_sources/github.py` uses `github_curated.json`: Khronos `hello_xr`, +[Open Brush](https://github.com/icosa-foundation/open-brush), and +[SuperTux 3D](https://github.com/SgtBilko76/SuperTux-3D). These have official +OpenXR project/release evidence, not a blanket claim of headset compatibility. +Open Brush's compatibility evidence is recorded in [vr-apks.md](vr-apks.md). +Open Brush and SuperTux publish the selected builds as prereleases; curated +opt-ins preserve that label in version records. Exact APK filename patterns +avoid downloading desktop archives or alternate non-Quest builds. +[OpenSaberPlus](https://github.com/arpruss/OpenSaberPlus) was examined but not +curated: GitHub reports its license as `NOASSERTION`, and current OpenXR APK +provenance was not established in this pass. + +Default GitHub search is offline against this small list. Queries +`topic:openxr`, `topic:oculus-quest`, and `topic:quest` explicitly call repository +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 +`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 +leaves the latter unknown and rejects a requested `version_code` rather than +silently fetching a different build. + +`itch.py` exposes separate OpenXR and Quest feed sources, so one feed's failure +does not suppress the other at the aggregator level. Queries filter the current +feed window locally: this is not an exhaustive historical itch search. Only +explicit zero-price Android entries are returned. Covers are exposed in +`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown. +Curated GitHub entries include publisher artwork and plain-language summaries. +Repository image URLs are pinned to inspected commits. Open Brush screenshots +come from its README-linked Steam listing; SuperTux uses the upstream gameplay +preview embedded in the port's README (not a headset capture). The hello_xr +sample has a launcher icon and GitHub social banner; no published screenshot +was found in the inspected repository/README, so its screenshot list is empty. +Uncurated topic results use the owner's avatar and GitHub's repository social +preview. These are repository placeholders, not app screenshots. Itch's recorded +RSS includes only covers, so screenshot lists remain empty without page scraping. VR is +based on curated evidence or a VR-specific feed/topic, not a compatibility claim. + +Downloads stream to unique temporary files, require an APK manifest entry, +restrict HTTPS origins and redirects, and enforce a 2 GiB ceiling. `verified` +means the downloaded SHA-256 matches GitHub's published digest. Without such a +digest, the computed SHA-256 is returned with `verified: False`; neither value +claims publisher-signature validation. Installation must inspect the APK as +usual. OBBs, split APKs, paid assets and external release-body download links +are unsupported. + +## Evidence and limits + +On this Mac, Python 3.9 downloaded the real Khronos Vulkan 1.1.63 APK through +the GitHub adapter, matched its published SHA-256 +`f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34`, and +`python3 ui/frame_android.py info ` exited 0: package +`org.khronos.openxr.hello_xr.vulkan`, version code 1063, minimum API 24, +arm64-v8a present, OpenXR detected. No Frame connection or installation occurred. + +The itch OpenXR RSS was fetched successfully and recorded as a fixture. +Subsequent live adapter search encountered HTTP 429; it is not claimed as a +successful live end-to-end search. Fixture search finds Off Nominal and parses +nine Android entries from the ten-item feed (one has only an HTML platform). +A real itch download and APK inspection were deliberately not performed: +robots restrictions take precedence over that requested proof. No current +policy text is claimed verified where the table records failed access. + +Tests use recorded, reduced API/RSS fixtures with network access blocked in +the new test class. They cover selection, prereleases, unknown topic results, +paid/non-Android exclusion, URL restrictions, redirect credential removal, +caching, rate limits, checksum mismatch, non-APK rejection and partial-file +cleanup. See `.claude/NOTES-more-sources.md` for commands and local evidence. diff --git a/docs/apks.md b/docs/apks.md index 56b8c25..4a841d4 100644 --- a/docs/apks.md +++ b/docs/apks.md @@ -331,3 +331,11 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified whether the settings survive the app or its Lepton instance relaunching. Lepton Development rebuilds its Android data on exit, so there they probably don't. + +## Expansion files and save backups + +SideQuest-inspired CLI helpers install local OBB files into an already-running +app instance and back up/restore a stopped instance's private app data. See +[SideQuest features and limits](sidequest.md) for commands, archive scope and +verification status. These paths have offline coverage; real Frame storage and +permissions remain unverified. They do not change APK install or launch behavior. diff --git a/docs/devices.md b/docs/devices.md new file mode 100644 index 0000000..1ed3200 --- /dev/null +++ b/docs/devices.md @@ -0,0 +1,180 @@ +# Headsets, addresses and the connection + +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. + +The code is in three modules, all stdlib-only Python on your computer: + +| Module | What it does | +|---|---| +| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys | +| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state | +| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API | + +## Headsets + +Each headset keeps its own SSH alias, as Set Up Connection has always written +it: the first is `frame`, the next `frame-2`, and so on. Terminal's +`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`) +work for each one. + +- **Nothing to migrate by hand.** On first start, the app imports every + `# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with + the block's HostName as its first address. It also copies the host key your + `known_hosts` already trusts for that address into the headset's own + known_hosts file, `~/.ssh/frame-control-hosts/`, so nobody is asked to trust it again. +- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS, + `ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias. + When it writes its block, the app picks the headset up by itself. If Set Up + Connection runs again and finds a headset somewhere new, that address is added + at the top of its list. +- **Use this headset** (or the switcher in the header, or the app's + **Frame → Headset** menu) moves the whole app to another headset; every panel + reloads from it. From that moment no command goes to the previous headset, even + if the new one never answers. It waits while an install is running, since an + install reads the SSH settings step by step. +- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick + the box; either way it isn't imported again unless Set Up Connection changes it. +- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the + app shows it as not set up and lets ssh's own config decide where it goes. + +## Addresses + +Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address +and changeable), an optional label, the networks it has worked on, and when it +last worked with its round-trip time. + +When connecting, the app **tries all addresses at once** (TCP to the SSH port) +and ranks them: + +1. addresses that worked on the network this computer is on now; +2. mDNS names; +3. Tailscale addresses, if Tailscale is running here; +4. addresses not tried on this network yet; +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 +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 +answered is tried. Every success records the network on that address, so next +time on that network it's tried first. + +**Test now** probes every address and tries SSH on each one that answers, without +disturbing the connection in use: "SSH works", "answered as a different +headset", "refused this computer's key", or why it didn't answer. **Find on +Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale +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`. + +## Networks + +A network is told apart by its default gateway: the router's IP address plus its +hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh` +(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and +on macOS 14 and later, which hides the Wi-Fi name from apps without Location +permission. Where the system does share the Wi-Fi name, it's shown, and you can +name any network yourself ("Home Wi-Fi") on the Devices tab. + +The app rereads the gateway every 5 seconds and Tailscale's state every +30 seconds. Changing networks reconnects. + +## The connection, stage by stage + +The connector runs in the server (`frame_link.Link`) and moves through: + +1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale. +2. **Finding the headset**: each address resolving, trying, answered in N ms, + no answer, refused, or can't be found. +3. **Opening SSH** to the address that answered. +4. **Checking the headset's identity**: the host key must match the one pinned + for this headset. +5. **Logging in** as the headset's user. +6. **Connected** via network N, address A, round trip T; or **failed** at a stage + with the reason in plain words and a countdown to the next try (5, 10, 20, + then every 30 seconds). Retry now skips the wait. + +Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the +connection is an SSH ControlMaster that every command shares; when it dies (the +headset slept or left the network) the connector notices and starts again. On +Windows, where OpenSSH can't share a connection, the same handshake runs once +and each command then connects on its own; a command that can't reach the +headset makes the connector start again. + +Once connected, every `ssh`, `scp` and `rsync` the app runs gets +`-o HostName=
-o HostKeyAlias=frame-control- +-o UserKnownHostsFile=~/.ssh/frame-control-hosts/ -o HashKnownHosts=no -o User=… -o Port=…`. The +alias's block in `~/.ssh/config` is also updated to the last address that +worked (and to the user and port you set), so Terminal's `ssh frame` and the +scripts follow. Edits to `~/.ssh/config` take a lock file +(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never +write over a change someone else made since the app last read the file. + +**Host keys are pinned per headset, not per address.** Your own `known_hosts` +is keyed by address, so a different device answering at a remembered IP (a DHCP +lease that moved) would look like a new host there. The app keeps one known_hosts +file per headset instead, so saving or forgetting one headset's key never touches +another's: a different device answering at one of its +addresses is refused, and the pill says so. A headset's first connection trusts +the key it shows, as Set Up Connection does. After reinstalling SteamOS the +headset has a new key; **Forget identity** on the Devices tab lets the next +connection save the new one. + +## One server at a time + +Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's +data folder). Two would each connect, reconnect and edit the headsets on their own, and +one could move the other's install to a different headset. A second one, say +`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already +running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets. + +## API + +All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header). + +| Request | Returns | +|---|---| +| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` | +| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) | +| `GET /api/devices` | Headsets, the current network, known networks, the next free alias | +| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first | +| `GET /api/devices/mdns?id=` | Headsets found on this network | +| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` | + +Every host, alias and user is checked against strict patterns before it's +stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes +through a shell. + +## The registry file + +`devices.json` in the app's data folder (`~/Library/Application Support/Frame +Control` on macOS, `%APPDATA%\Frame Control` on Windows, +`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can +share the format later (it still connects to one host; see +[iphone.md](iphone.md)): + +```json +{"version": 1, "active": "f67f8b7e", + "devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22, + "identity_files": ["~/.ssh/id_ed25519_frame"], + "addresses": [{"host": "frame.local", "kind": "mdns", "label": "", + "networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}], + "networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1", + "gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}} +``` + +A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`. + +## Tests + +`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run +with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that +prints what `ssh -v` prints and plays a ControlMaster, real sockets on this +computer for the addresses, and temporary folders for `~/.ssh` +(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they +never touch yours. diff --git a/docs/evidence/linux-vr/2026-09-29-alvr.png b/docs/evidence/linux-vr/2026-09-29-alvr.png new file mode 100644 index 0000000..70d67b3 Binary files /dev/null and b/docs/evidence/linux-vr/2026-09-29-alvr.png differ diff --git a/docs/evidence/linux-vr/2026-09-29-wivrn.png b/docs/evidence/linux-vr/2026-09-29-wivrn.png new file mode 100644 index 0000000..70d67b3 Binary files /dev/null and b/docs/evidence/linux-vr/2026-09-29-wivrn.png differ diff --git a/docs/evidence/linux-vr/2026-09-29.txt b/docs/evidence/linux-vr/2026-09-29.txt new file mode 100644 index 0000000..e1e87ec --- /dev/null +++ b/docs/evidence/linux-vr/2026-09-29.txt @@ -0,0 +1,29 @@ +Locked real-Frame repeat, 2026-09-29 +SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64. +mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup. +Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%. +Original Steam PID 49823 and vrserver PID 49571 present after cleanup. +Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt. +Installer functions loaded from a613735 before checkout was fast-forwarded to current main. +No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches. +Headset unworn. No Linux gaming host. No pairing or streaming session reached. + +WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC). +Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time +Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT +Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time +Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT +Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT +Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible. + +ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC). +Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time +Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time +Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10: +Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT +Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible. + +Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent. +Capture output directory removed. No global settings changed; Steam/SteamVR not stopped. +The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000). +Native clients and Valve host streaming not exercised: no native build or Linux gaming host available. diff --git a/docs/evidence/vr-utilities/device.json b/docs/evidence/vr-utilities/device.json new file mode 100644 index 0000000..a604007 --- /dev/null +++ b/docs/evidence/vr-utilities/device.json @@ -0,0 +1,53 @@ +{ + "date": "2026-09-28", + "os": { + "version": "0.4.1", + "build": "20260925.6191901", + "variant": "vr" + }, + "performance": { + "compositorFps": 72.0, + "frameMs": 13.89, + "appFps": null, + "gpuMs": 3.07, + "compositorCpuMs": 0.61, + "cpuPercent": 40.6, + "gpuMHz": 903.0 + }, + "batteryPercent": 16, + "maxTempC": 73.5, + "ownership": [ + { + "id": 1009850, + "owned": false, + "installed": false, + "frame": 0 + }, + { + "id": 1173510, + "owned": false, + "installed": false, + "frame": 0 + }, + { + "id": 1068820, + "owned": false, + "installed": false, + "frame": 0 + }, + { + "id": 908520, + "owned": false, + "installed": false, + "frame": 0 + }, + { + "id": 1494460, + "owned": false, + "installed": false, + "frame": 0 + } + ], + "hudLifecycle": "open, duplicate-open, close passed before control-test pause; overlay probe removal verified", + "comfort": "Initial 1cm seated probe restored exactly. Later commits/readback disagreed while Frame in use; Alex paused control tests. No controls shipped." +} diff --git a/docs/evidence/vr-utilities/hud-card.png b/docs/evidence/vr-utilities/hud-card.png new file mode 100644 index 0000000..d12d009 Binary files /dev/null and b/docs/evidence/vr-utilities/hud-card.png differ diff --git a/docs/evidence/vr-utilities/review.md b/docs/evidence/vr-utilities/review.md new file mode 100644 index 0000000..ae9b11f --- /dev/null +++ b/docs/evidence/vr-utilities/review.md @@ -0,0 +1,58 @@ +# Independent review attempts — 2026-09-28 + +Target: the implementation and evidence in +[3fb541d](https://github.com/saphid/frame-control/commit/3fb541d) and +[6a63a5a](https://github.com/saphid/frame-control/commit/6a63a5a), supplied as a +frozen diff before those commits were made. No executable code changed after +the final review snapshot. + +Requested model: **SWE-2 Max**, explicitly selected with `--model swe-2-max`. +No completed verdict or model self-identification was returned. This is not a +passed review, and there is no "no actionable findings" claim. + +## First attempt + +Launcher: + +```sh +devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-prompt.txt +``` + +The prompt required read-only review, no delegation, no edits and no Frame +access. The reviewer inspected surrounding code, then stopped while checking +the public OpenVR header. The tool runner reported: + +> warning: rejected a tool call that requires confirmation. Running in non-interactive mode. + +The real launcher exit status was **0**, but no verdict was returned. A zero +process status here is not evidence that the review completed. + +## Tool-free retry + +Launcher: + +```sh +devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-final-prompt.txt +``` + +The self-contained prompt supplied the complete frozen changes, surrounding +code, standards and locally fetched authoritative OpenVR header excerpts. It +explicitly prohibited tools, edits, delegation and device access. This avoided +the first attempt's permission boundary without escalating permissions. + +No output or verdict arrived within the fifteen-minute review window. The +process was sent SIGTERM at 918 seconds; the shell recorded real exit status +**143**. Findings are unavailable. Review must be completed before considering +this partial draft ready; playspace feasibility is also still paused. + +## Other validation + +- 166 Python unit tests passed locally. +- 8 website tests and UI JavaScript syntax passed locally. +- Desktop and phone-width attached-preview checks passed using live telemetry; + paid optional install buttons were disabled, and unavailable metrics cleared. +- [Fake-Frame CI](https://github.com/saphid/frame-control/actions/runs/36423548487/job/108931878908) + passed, as did Windows/Linux server tests and the main checks job. Docker was + unavailable locally. macOS/iOS jobs were still queued at this handoff. +- Real-device evidence and the paused-control limitation are in + [VR utilities](../../vr-utilities.md). diff --git a/docs/family-comfort.md b/docs/family-comfort.md new file mode 100644 index 0000000..e2f1afe --- /dev/null +++ b/docs/family-comfort.md @@ -0,0 +1,122 @@ +# Family and comfort + +Frame Control's Home tab has a **Family and comfort** card, on desktop and +on iPhone. No third-party notification or parental-control app is needed. +This is Frame Control code using Python, Steam and SteamVR already on the Frame. + +![Family and comfort controls in the desktop app](img/comfort-desktop.png) + +## Sessions + +Set a limit of 1–240 minutes, optional break and check-in intervals, then +**Start session**. Break and check-in intervals of 0 turn those reminders off. +**Cancel session** cancels the timer and monitoring without changing the game. +Cancel before starting a session with different settings. + +The Frame shows a one-minute warning, then opens Steam Home in its dashboard. +**Games stay running**: save and pause before the limit. Some games pause when +the dashboard opens; others do not. There is no kill, power-off, Steam restart, +account restriction or parental lock. The wearer can return to the game. + +**Documented implementation:** the timer is a single, opt-in Python worker in +the Frame user's account. Desktop and iPhone share its state. It keeps going +when the companion disconnects, closes or is suspended. It exits after +completion or cancellation (normally within five seconds). Cancellation waits +for any in-flight SteamVR action to finish within its timeout; it is not a boot +service. A Frame reboot invalidates the session. Suspend counts toward the +limit, using Linux's boot-time clock. If a warning was delayed by suspend or a +SteamVR failure, Home waits until at least a full minute after a successful +warning. A failed Home transition remains active and retries, with an error +shown in the companion. A stale worker is reported as unverified enforcement. + +## Alerts and breaks + +During a session, battery, overheating and check-in alerts go to connected +companions. Break reminders and session warnings also appear on the headset. + +- **Low battery:** 15% or below while discharging. One alert until charging or + recovery to 20%, so values around 15% do not produce repeated notifications. +- **Overheating:** a thermal zone reaches its own kernel-reported hot/critical + trip, or the battery reports `Overheat`. Missing sensors mean unknown, not + safe. These are status alerts, not medical advice or an extra thermal governor. +- **Check in:** an alert after the chosen number of active minutes. +- **Breaks:** a SteamVR reminder and companion notification at the chosen interval. + +**Inferred:** SteamVR activity levels 1 and 2 are a useful proxy for use, not +proof someone is wearing the headset. Inactive readings reset continuous use; +missing readings add no time. Long gaps count at most 30 seconds. Breaks and +check-ins are distinct from the elapsed-time session limit. + +Click **Enable / test notifications** on each companion. iOS asks for permission; +macOS, Windows and Linux follow their notification settings. The page also shows +recent events and errors. Keep Frame Control open and connected for companion +alerts. **Phone alerts are local, not push notifications:** iOS suspension, +force-quit or a lost SSH connection prevents live delivery. Old alerts are not +replayed as a notification burst on reconnect. Headset warnings and the session +limit continue without the phone. A physical iPhone's background delivery has +not been verified and is not guaranteed. + +## Casting + +**Cast headset view** 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 +available. The image includes private content visible to the wearer. + +## What is installed + +The shared authenticated `/api/comfort` endpoint copies three bundled Python +files to `~/.cache/frame-control/comfort//`. Session state and +locks live in `~/.local/state/frame-control/comfort/`, with a private directory +and 0600 state file. There is no network listener or system service. Cancel a +session before removing these directories. The iPhone's normal server still +exits on disconnect; the explicitly started comfort worker is the exception. + +## Verification + +**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`: shipped +`/opt/steamvr/bin/linuxarm64/vrcmd --notify TEXT` reported success for a custom +reminder. Steam's CDP `SteamUIStore.Navigate('/library/home')` and +`SteamClient.OpenVR.VROverlay.ShowDashboard('valve.steam.gamepadui.main')` +opened Home while the running app ID stayed unchanged. Prior page and dashboard +visibility were restored. Kernel hot/critical trips and SteamVR activity were +read from the real device. No temperature or battery fault was induced. + +**Verified locally:** deterministic fake-Frame tests cover late warnings, +failed warnings/Home actions, cancellation, activity gaps, thresholds, duplicate +suppression, reboot invalidation, shared session state and the exact Home +JavaScript. `python3 -m unittest discover -s tests` runs them. The iOS Simulator +build tests notification content and bounds. Physical iPhone delivery and +wearer-perceived headset notification visibility remain unverified. + +**Verified end to end on the same Frame:** a two-minute session with no companion +connection for 135 seconds emitted its warning, break and check-in, then opened +Home. The running app ID was unchanged; the test restored the previous page and +dashboard visibility and confirmed the worker exited. Casting through the Home +shortcut decoded the existing headset stream at 30 fps. + +**Verified on the iOS 26.5 Simulator:** connected to the real Frame, approved the +notification prompt, and saw the native Frame Control test banner. Seven iOS +tests passed. + +![Native test notification in the iOS Simulator](img/comfort-notification-ios.png) + +Desktop and 390-pixel phone layouts had no horizontal overflow. +On macOS the development Electron app's real notification attempt was denied +(`UNErrorDomain` 1); the bridge now returns that failure instead of reporting +success. Successful macOS/Windows/Linux notification display remains unverified. + +**Verified on the real Frame:** its naturally discharging 15% battery produced +one low-battery event during a short session; the test then cancelled the +session. Overheating alerts use fake sensor samples in tests: the shared +headset was not deliberately overheated. + +**Verified 2026-09-29 on the same Frame:** a fresh one-minute session opened +Home more than 60 seconds after the successful warning. The test restored the +previous page and dashboard visibility. Local regression coverage now includes +slow notification delivery, a total Home-action timeout, failed worker startup, +unreadable saved state, malformed activity samples and notification UX: 173 +Python tests passed. Desktop and 390-pixel layouts were checked again; system +notification-denial guidance stayed visible across polls. Initial event history +did not replay notifications, and only the latest new event was announced. diff --git a/docs/frame-control.md b/docs/frame-control.md index 2637c10..3ec3b4c 100644 --- a/docs/frame-control.md +++ b/docs/frame-control.md @@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810 ## Features -The window has four tabs: **Home** (headset view, status, screenshots), +The window has five tabs: **Home** (headset view, status, screenshots), **Games** (installed games, sideloaded titles, getting games), **Android** (apps, -the catalogue, display settings, reports) and **Tools** (sending files and text, -Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped -anywhere in the window. When the Frame can't be reached, one banner says why in -plain words and the app retries every few seconds, filling everything in once it -answers. Flatpak and Android installs run in the background; the bottom bar +the catalogue, display settings, reports), **Tools** (sending files and text, +Flatpaks, remote and power) and **Devices** (your headsets and their addresses). +Keys 1–5 switch between them. Files can be dropped anywhere in the window. A +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 +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. - **Headset view**: what the lenses show, as SteamVR composites it (the room, @@ -78,6 +81,11 @@ counts them while they run. an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md). - **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC, Remmina). +- **Devices**: several headsets, each with several addresses (LAN IPs per + network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app + tries them all at once and learns which worked on which network. Add, edit, + reorder and test addresses, find a headset on Tailscale or on this network, + name your networks, and switch headsets. See [devices.md](devices.md). - **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on Linux). Sleep, restart and shut down open a terminal window because SteamOS @@ -101,7 +109,9 @@ Frame for the keyboard and trackpad. The server is Python stdlib only and listens on 127.0.0.1. It rejects requests with a non-local `Host` header, and any `/api/` request without a custom header, so other websites can't drive it or read captures. Everything reaches -the headset through the `frame` SSH alias. On macOS and Linux it keeps one +the headset through its SSH alias (`frame` for the first one), pointed at the +address that answered with `-o HostName=` (`ui/frame_link.py`, described in +[devices.md](devices.md)). On macOS and Linux it keeps one multiplexed SSH connection open, so status and each capture take about 0.3 s. Windows' OpenSSH can't share a connection, so there each request connects on its own and the app is a little slower. What differs between the three diff --git a/docs/img/comfort-desktop.png b/docs/img/comfort-desktop.png new file mode 100644 index 0000000..6625c57 Binary files /dev/null and b/docs/img/comfort-desktop.png differ diff --git a/docs/img/comfort-notification-ios.png b/docs/img/comfort-notification-ios.png new file mode 100644 index 0000000..7ed15d0 Binary files /dev/null and b/docs/img/comfort-notification-ios.png differ diff --git a/docs/img/mcp-approval-install.png b/docs/img/mcp-approval-install.png new file mode 100644 index 0000000..780add4 Binary files /dev/null and b/docs/img/mcp-approval-install.png differ diff --git a/docs/img/mcp-panel-binary.png b/docs/img/mcp-panel-binary.png new file mode 100644 index 0000000..00e7074 Binary files /dev/null and b/docs/img/mcp-panel-binary.png differ diff --git a/docs/img/media-ui-panel.png b/docs/img/media-ui-panel.png new file mode 100644 index 0000000..2e2a41b Binary files /dev/null and b/docs/img/media-ui-panel.png differ diff --git a/docs/img/panel-switcher.png b/docs/img/panel-switcher.png new file mode 100644 index 0000000..71237ce Binary files /dev/null and b/docs/img/panel-switcher.png differ diff --git a/docs/iphone.md b/docs/iphone.md index d9f6c93..debc954 100644 --- a/docs/iphone.md +++ b/docs/iphone.md @@ -26,7 +26,10 @@ as its transport too), so the desktop and phone share one code path. Android display settings use `podman exec` into each Lepton container instead of adb, which the Frame doesn't have. -Nothing is left running on the Frame after the phone disconnects; the copied +The app server stops after the phone disconnects. An explicitly started +[comfort session](family-comfort.md) keeps its timer and headset reminders running +until the session ends or is cancelled; phone notifications require the app to +remain connected and running. The copied files stay in `~/.cache/frame-control` (delete it any time). ## Pairing @@ -108,3 +111,11 @@ running), a real sleep/restart/shut down on the Frame, and a physical iPhone. Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`, `FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds 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 +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/linux-vr-streaming.md b/docs/linux-vr-streaming.md index af1789a..40466e7 100644 --- a/docs/linux-vr-streaming.md +++ b/docs/linux-vr-streaming.md @@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected. All test app files, compatdata, shortcuts and containers were removed afterwards. SteamVR's original process remained running. No global settings changed. +### Locked repeat, 2026-09-29 (verified) + +Acquired `/tmp/frame-test.lock` before installing or launching anything and +released it after cleanup. Battery was 44% and charging at preflight, 46–47% +during the launches, and 47% at cleanup. The SteamOS version/build was unchanged. + +Reinstalled and launched both original APKs in immersive Lepton instances. +WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension; +ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the +unmodified-client test, not the newer installer's automatic compatibility-layer +path. Neither reached a session that could be paired or exercised further. +Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt) +record the failures. + +SteamVR's screenshot API returned a 1920×1080 headset capture after each +launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png) +and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove +rendering or a working client. The headset was unworn; visibility, controllers, +frame rate and motion-to-photon latency could not be judged. The explicit +OpenXR errors, rather than the dark captures, establish the client blocker. + +Both test installs, app data, shader caches, shortcuts, containers and temporary +capture files were removed. The original Steam and SteamVR process IDs were +unchanged. No reboot, power action or global setting change was used. Native +clients remain untested, and no Linux gaming host was available for Valve's +streaming path. The recommendation below is unchanged. + ### Relation to the VR APK branch **Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20) diff --git a/docs/mac-in-headset.md b/docs/mac-in-headset.md index 590a831..ed1b90a 100644 --- a/docs/mac-in-headset.md +++ b/docs/mac-in-headset.md @@ -468,3 +468,18 @@ versus on, medians of the runs, ms): window to the front first. - Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired with the Frame. + +## Switching panels and workspace limits + +**Tools → Panel switcher** lists open SteamVR panels, including Mac viewers. +Use **Show** to request focus or **Open in headset** for Frame Control's own +switcher panel. It uses SteamVR/gamescope and Chromium, with no third-party +overlay app. [Device checks and limits](panels.md#frame-controls-panel-switcher) +include the difference between a panel surviving a scene launch and staying +visible over it. + +Saved spatial layouts are blocked on this build: the public OpenVR transform +setter denies access to gamescope-owned panels. Reconnecting an existing viewer +is supported; restoring its room position after a reboot is not. We do not +save short-lived Mac window IDs or viewer access keys as if they were a durable +workspace. See [the feasibility evidence](panels.md#saved-spatial-layouts-blocked-on-the-current-panel-route). diff --git a/docs/panels.md b/docs/panels.md index a4e3026..5b17828 100644 --- a/docs/panels.md +++ b/docs/panels.md @@ -121,8 +121,165 @@ a limit on the number of floating panels. script needed. - **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth keyboard) or virtual desktops arrange windows within the 1280×800 rectangle. -- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a - PC's desktop in SteamVR. They don't run on the Frame's standalone Linux. +- **Optional overlay tools:** Desktop+, OVR Toolkit and similar software are + 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 + headset creates its own gamescope panel using built-in tools. It needs no + third-party overlay app. [Metrics and verification](vr-utilities.md). + +## Frame Control's panel switcher + +**Verified 2026-09-28**, SteamOS 0.4.1, BUILD_ID `20260925.6191901`, +SteamVR 2.18.1: **Tools → Panel switcher** lists SteamVR's open main panels, +including panels that are currently hidden. **Show** asks SteamVR to bring one +forward. **Open in headset** opens the same switcher as its own panel; choose +it again from Steam's dashboard after switching away. Refresh updates the list. +This is a list, not thumbnail Exposé. + +![Frame Control's switcher rendered on the Frame](img/panel-switcher.png) + +This is our own Python/HTML implementation (`ui/frame_panels.py`), using the +Frame's shipped `vrcmd` OpenVR client and gamescope. The headset page uses +Chromium (Chromium XR when present, then system Chromium, then the existing +Chromium Flatpak). No XSOverlay, OVR Toolkit, WayVR or other overlay application +is needed. This dependency boundary also applies to future layout and panel +persistence work: platform APIs and bundled libraries are fine; another app +must not implement the feature for us. + +The companion runs the helper over SSH. Opening it in the headset installs a +copy under `~/.local/share/frame-control/panels/` and starts a loopback HTTP +server and an isolated Chromium profile. There is no startup service or global +setting change. Close the switcher to stop its server and browser. Other +Chromium profiles, Steam and SteamVR are left alone. If the window or runtime +closes, use **Open in headset** again. + +The page carries a random, per-process access key in its URL fragment, removes +it from the address bar, keeps it in tab session storage for page reloads, and +sends it in a header. Panel lists and actions need +that key; Host and Origin checks reject other sites. The key permits only +listing panels, requesting focus and closing this switcher. Like Mac viewer +launch tickets, it is initially readable by another process running as the +same Frame user. Panel titles are rendered as text, never HTML. The companion +retains its existing request guards. No Mac capture credentials cross this API. + +**Verified:** the real headset page rendered its panel list (image above), its +HTTP focus request changed `GAMESCOPE_FOCUSED_APP` to `2000999030`, a request +without the key returned HTTP 403, and Close stopped the helper and its browser. +Opening an already running switcher requests its focus rather than creating a +second one. The companion uses the same list/focus helper. **Unverified:** laser +selection while wearing the headset, physical placement, and non-XR Chromium. +The API reports that focus was *requested*: another action can take focus before +we observe the result. Closed panels are rejected after re-enumeration. + +### Shared-device recheck, 2026-09-29 + +**Verified:** the follow-up's atomic `mkdir /tmp/frame-test.lock` attempts +failed because another thread held the lock. The existing lock was left alone; +no applications were installed, launched or stopped in this follow-up. The last +read-only battery check showed 62%, charging. The 180 Python and 8 website tests +passed again locally. + +**Unverified in this follow-up:** the prepared browser-button test (Refresh, +selection, reload and Close) and repeated OpenXR transition could not run under +the shared lock. The device results elsewhere in this page are the earlier +2026-09-28 observations, not results from this blocked recheck. In particular, +HTTP focus is not evidence of worn-headset laser input. Follow the +[shared-device test procedure](testing.md#headset-smoke-test) for the next run. + +## Saved spatial layouts: blocked on the current panel route + +**Verified 2026-09-28**, same build, using a temporary xterm panel with +`STEAM_GAME=2000999031` and `FnTable:IVROverlay_028` from +`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`: + +| OpenVR call | Result | +|---|---| +| `FindOverlay("valve.steam.desktopgame.2000999031")` | Success | +| `GetOverlayWidthInMeters` | Success, 2.67 m | +| `SetOverlayWidthInMeters` (same width) | Success | +| `GetOverlayTransformType` | Success, type 5 (`VROverlayTransform_DashboardTab`) | +| `GetOverlayTransformAbsolute` | 18 (`WrongTransformType`) | +| `SetOverlayTransformAbsolute` (identity rotation, 1.2 m up, 1.5 m forward) | 12 (`PermissionDenied`); type remained 5 | + +The public interface names type 5 **DashboardTab**; SteamVR's dashboard code +places these panels through its scene graph. It owns the frame/docking +transforms. A successful width setter does not grant permission to restore the +position. `vrcmd --dock-overlay world ` dispatched a docking request but +the dashboard logged `Failed to get SGTransform in setInitialTransformForLocation. +Invalid transform ID`. This does not establish working world placement. + +**Inferred:** saving X11 pixel rectangles or Mac window IDs would not restore +this spatial arrangement. Mac window IDs also change when an application +reopens; viewer tickets and reconnect keys must not go into a layout file. +The base Mac stream reconnects after a network break, but that is different +from recreating windows and their room positions after a reboot. + +There is consequently no Save/Restore control yet. A durable layout needs a +working transform restore path, stable source identity, and a fresh capture +permission/ticket flow. The tested gamescope-owned overlay route denies that +transform operation. A future Frame Control-owned overlay renderer, or a +supported platform API for dashboard frame transforms, needs its own device +proof before building layout UI. This is a blocker for the current approach, +not a claim that all possible implementations are impossible. Reboot recovery +was not tested: the shared headset was not rebooted. + +## Panels during an immersive session + +**Verified 2026-09-28**, same build: our Chromium switcher panel remained in +OpenVR's overlay list before, during and after the Frame's shipped `helloxr -g +Vulkan` sample. During the test `vrcmd --stats` identified +`system.generated.openxr.helloxr.helloxr`, with 242 frame submissions. The test +ended only its own sample process; no SteamVR, Steam, power or global settings +were changed. The switcher was still selectable afterwards. + +This proves survival of that panel across an OpenXR scene session, **not** that +it stayed visibly composited over the scene: OpenVR reported it `not_visible` +before, during and after. **Verified:** calling `ShowOverlay` on our +*gamescope-owned* switcher overlay returns 12 (`PermissionDenied`). A helper +cannot force that panel visible using the public overlay call. Use the +switcher/dashboard to request access to it; we do not fight the runtime with a +repeated force-focus loop. + +**Verified in a second controlled run:** a live H.264 test-pattern stream from +this checkout's Mac helper, through its own SSH tunnel and a temporary Chromium +profile, survived the same OpenXR sample (257 scene-frame submissions). Its +panel `2000999032` changed from `visible` before launch to `not_visible` during +and after the scene. The Mac helper still reported the same `test` stream; +captured frames increased from 35 to 232, with 29.5 decoded/drawn fps afterwards. +The test did not capture personal Mac windows or inject Mac input. The sample, +viewer, temporary profile, tunnel and Mac helper were cleaned up. This proves +stream survival, and also shows why it must not be advertised as always visible. + +**Unverified:** persistent visible placement while playing a Steam-launched VR +game, Plasma desktop and real Mac-window behavior during that launch, and worn +headset input. Other threads were launching games and changing the runtime on +the shared device, so those transitions were not treated as controlled evidence. +A runtime/X-server restart can destroy the viewer windows; a network reconnect +cannot recreate them. No “always visible during games” guarantee is shipped. + +## Keyboard passthrough feasibility + +**Verified 2026-09-28**, same build, using `FnTable:IVRTrackedCamera_006`: +`HasCamera(0)` returned success and true. `GetCameraFrameSize` returned 100 +(`OperationFailed`), with zero dimensions, for all three public frame types +(distorted, undistorted and maximum-undistorted), including after acquiring the +video service. Acquisition returned success and a handle; release returned 101 +(`InvalidHandle`). The probe shut down its OpenVR client afterwards. No camera +frames were captured and no camera settings were changed. + +**Documented:** the public OpenVR camera interface provides camera frame sizes, +intrinsics, projections and streaming handles; these are prerequisites for a +spatially aligned camera cutout. See Valve's +[OpenVR C API](https://github.com/ValveSoftware/openvr/blob/master/headers/openvr_capi.h). + +**Inferred:** camera presence alone does not establish access to camera pixels. +The failed frame-size path blocks a keyboard cutout in our current panel +implementation. We have not established a keyboard detector or a calibrated +camera-to-panel mapping. Built-in full-room passthrough is not proof of a +public, selectively masked camera stream. No keyboard cutout is offered, and +no third-party camera/overlay app is substituted for it. ## Frame Control's media theatre diff --git a/docs/privacy.md b/docs/privacy.md index db7004f..1ec1755 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -103,8 +103,10 @@ privately to Frame Control's PostHog project as a `problem_report` event, the same way as the analytics above, so only the maintainer can read it and nothing is published. It works whatever the analytics settings are, because the person sends it deliberately. The report has the kind, title and text you -wrote, how to reach you if you gave it, a short reference shown after sending, -and the diagnostics below. It has its own random id, so it isn't linked to +wrote, a short reference shown after sending, and the diagnostics below. Your +email address goes with it only if you tick **The maintainer may contact me +with follow-up questions** (the report then carries `contact_followup: true`); +it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to your analytics events. With **Include diagnostics** ticked (the default), the report adds: @@ -128,11 +130,60 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or with `python3 ui/frame_report.py inbox [days]`, which uses the same personal API key as `frame_compat_db.py sync`. +## Contact email (optional) + +Frame Control never needs an email address. If you'd like to leave one, there +are two separate choices, both off until you tick them: + +| Choice | What it's for | +|---|---| +| **Email me about Frame Control updates** | Occasional notices about new releases and updates | +| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly | + +You're asked once, in a bar at the top of the page, after the Frame has +connected for the first time, and never while or straight after the +first-run privacy notice is showing. **No thanks** hides it for good, and it isn't +shown again even if you ignore it. **Contact email** in **Privacy & updates** +is where you add, change or remove the address and either choice at any time. + +**What's sent, and where.** The address and the two choices go privately to +Frame Control's PostHog project, the same place as problem reports, as a +`contact_consent` event with `email`, `updates`, `followup`, `action` (`set` +or `withdraw`) and the common properties above. Only the maintainer can read +that project, and nothing in it is published or shared. It's sent only when +you save, whatever the analytics settings are, because you chose to. It +carries its own random contact id, not the analytics id, so it isn't linked +to your usage events, and a `rev` number that goes up with each change, so +the newest choice always wins. Like everything else sent, it's listed under +**Show what's been sent**. On this computer the address and choices are kept in +`contact/contact.json` in Frame Control's data folder. An address is only +kept with at least one choice ticked. + +**Removing it.** **Remove my email** (or clearing the address and saving) +deletes it from this computer, including from the **Show what's been sent** +log (in earlier contact events and problem reports), and sends a `withdraw` +event with no address in it. The maintainer's list only uses the newest event from each copy, so from +then on the address isn't listed for either choice. Unticking one choice +works the same way for that choice. If you're offline, the change waits on +this computer and is sent when PostHog can be reached. The earlier event +stays in PostHog until its data retention removes it; to have it deleted +sooner, ask the maintainer (for example in a problem report). + +Nothing sends email yet: this only records who agreed to what. The +maintainer lists the addresses with +`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same +personal API key as `inbox`. + ## Turning it all off Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in the environment that starts Frame Control. A copy run from a source checkout -never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set. +never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set. + +These switches cover the analytics above. A problem report or a contact email +is sent only because you pressed its Send or Save button, so those still go +when you choose to send them (a contact change saved while offline is sent +by itself once PostHog can be reached); if you don't, nothing is sent. ## Update checks diff --git a/docs/sidequest.md b/docs/sidequest.md new file mode 100644 index 0000000..eba0f02 --- /dev/null +++ b/docs/sidequest.md @@ -0,0 +1,142 @@ +# SideQuest and Frame Control + +Researched 2026-09-28. SideQuest is both a Quest discovery website and a desktop +sideloading/device-management app. Its Quest labels are **not** evidence that a +game works on Lepton: inspect the APK for arm64/OpenXR, Android API requirements, +VrApi and Meta services (see [VR APKs](vr-apks.md)). + +## Features worth borrowing + +Desktop evidence is the public [SideQuest source at af2ac70](https://github.com/SideQuestVR/SideQuest/tree/af2ac7043db122bca3c8db18f2b58f1660e9befb), +especially [ADB operations](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/adb-client.service.ts), +[drag and drop](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/drag-and-drop.service.ts), +and the [legacy repository index](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/packages/package.service.ts). +Website evidence: [SideQuest](https://sidequestvr.com/) and its public Angular +bundle `main-4MMXZRXL.js`, inspected locally without browser automation. +No SideQuest implementation code was copied. + +| SideQuest feature | Frame Control before this change | Borrow? / effort | +|---|---|---| +| Store descriptions, screenshots, banners, trailers, ratings | F-Droid names, icons, compatibility verdicts and reports; no equivalent rich VR store | Yes, from authorised sources; medium. Search and library workers own presentation/artwork. | +| OBB expansion-file install | APK-only install | **Implemented helper and CLI**, medium. Essential for games whose assets are separate from the APK. | +| App-data backup/restore | Persistent instances and optional keep-data uninstall, no portable save archive | **Implemented private-data helper and CLI**, medium. Back up before updates or experiments. | +| File manager (list, upload, download, remove) | General Send to Frame, no Android file browser | Useful later, medium; requires clear instance selection and scoped paths. | +| Installed-app management (launch, uninstall, backup) | List, launch, stop, remove, probe | Already mostly covered. Backup added here. | +| Update notices / account library | Compatible-version lookup; no source-aware installed update notices | Useful later, medium; needs original version code and source identity recorded on install. | +| Custom repositories | Built-in F-Droid catalogue and compatible-version indexes | Separate user-repos worker. Legacy SideQuest source has a fixed SideQuestRepos index; arbitrary current custom-repo support was not verified. | +| Drag-and-drop APK/OBB install | APK drag-and-drop already works | OBB backend added here; future UI can call it. UI drop wiring is not included. | +| Tags, price, headset filters, reviews | Text search and Lepton verdicts, not Quest headset metadata | Useful, medium; search worker owns filters. Keep source headset claims distinct from tested Frame compatibility. | +| Screenshot/video capture and streaming | Frame screenshots/VR capture already present | Reuse existing tools; do not port Quest capture commands. | +| Device settings and ADB utilities | Frame/Android display settings, SSH and own-instance tools | Borrow selectively; Quest CPU/GPU presets and wireless-ADB setup do not map directly to Lepton. | + +Priority: expansion files, then save backup/restore. Rich discovery and update +notices follow once a permitted metadata source and source/version persistence +are available. This patch deliberately exposes CLI/backend operations, leaving +shared UI, install(), Steam artwork and launch behavior to sibling work. + +## SideQuest as a source: page-only + +[Terms](https://sidequestvr.com/terms), “Prohibited Activities”, (i) prohibits +copying/distributing/disclosing the Service including automated or non-automated +“scraping”; (xi) prohibits content access through means other than those provided +or authorised by the Service; (xii) prohibits bypassing access restrictions. +The terms describe downloading developer-posted games through the Service, but +do not establish permission for this third-party API integration. + +[robots.txt](https://sidequestvr.com/robots.txt) requests a three-second crawl +delay and disallows `/search/`, `/user/*` and `/sideload/*`. Robots permission +would not override the terms. The API host's robots request returned HTTP 403; +a request for the first shared website JS chunk also returned 403. No bypass, +account token, cookies, browser session or private endpoint was used. + +The homepage publishes `https://api.sidequestvr.com` and +`https://cdn.sidequestvr.com`. The website bundle calls `searchApps(...)` and +`getApp(id, null)`; their actual HTTP search/detail routes could not be established +from the retrieved bundle. Do not invent endpoints. The open-source desktop +[install flow](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/electron/app.ts) +POSTs `{token: ...}` to `/install-from-key`. It consumes +`data.apps[].urls[]`, with `provider` values including `APK`, `OBB`, +`Github Release` and `Mod`, and `link_url`. This is a website-issued install-key +flow, not evidence of an anonymous download API. It is not implemented here. + +`ui/apk_sources/sidequest.py` implements the shared interface conservatively: + +- `sources()` marks SideQuest `page_only` and explains why. +- `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. +- `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; + unknown facts, including free/VR status, stay `None`. +- `download()` refuses with that page link. Paid/external listings cannot be + downloaded by this adapter either. No downloads means no verification claim. + +The JSON fixture records policy evidence, **not a purported live app response**. +No listing metadata, artwork URLs, or OBB download URLs were scraped. +The requested real SideQuest → OpenXR APK → `frame_android.py info` test is +**blocked by the terms**, and was not performed. No alternate source is silently +substituted. A future integration needs SideQuest's permission or an expressly +supported third-party API, plus recorded search/detail/download fixtures, +free/direct-download classification, and size/hash verification. A calculated +local SHA-256 alone must not be called publisher verification. + +## OBB files + +```sh +python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb +python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.42.org.example.game.obb +``` + +Install the APK first. The named instance must already be running; the helper +never launches an app or uses Lepton Development. It requires standard +`main|patch...obb` filenames and nonempty files, validates +the entire batch before transfer, streams each file through SSH into that +instance, checks its SHA-256 **inside Android**, then renames it into +`/sdcard/Android/obb//`. `verified: True` here means transfer integrity +against the local input, not publisher authentication. Publication is atomic per +file, not for the whole batch; retry after a partial batch failure. Existing OBBs +with different version codes remain. The filename version must match the game; +the current install metadata does not expose its version code for comparison. +Restart the game yourself after the transfer if it cached missing expansion data. + +Both read-only SSH attempts to the Frame timed out. Therefore the exact +host-side `/sdcard` mapping and persistence of expansion data were **not verified**. +`compatdata//internal/` is documented as `/data/data/`; +it must not be mistaken for `/sdcard`. Using Android's path avoids guessing a +host layout, but device verification across restart/update is still required. +No OBB file was installed on the Frame during this work. + +## Private app-data backups + +```sh +python3 ui/frame_android.py stop org.example.game +python3 ui/frame_android.py backup-data org.example.game ./game-save.tar.gz +python3 ui/frame_android.py restore-data org.example.game ./game-save.tar.gz +``` + +Keep the instance stopped throughout either operation; do not launch it from +Steam concurrently. The remote guard fails if Podman cannot enumerate containers +or reports that instance running. The helpers use `podman unshare` to read/write +Android's mapped ownership without changing the live data's permissions. + +The archive covers **only** `compatdata//internal/`, not the +APK, external `/sdcard/Android/data`, OBBs, keystore, or the full Android snapshot. +It contains a package/instance manifest and regular files/directories. Backups +are private (0600), validated before publication, and never overwrite an existing +backup. Keep them safe: app data can contain credentials and is not encrypted. + +Restore checks the package and instance, rejects absolute/traversing/duplicate +paths, links and devices, caps files at 100,000 and content at 20 GiB, and validates +again on the Frame. It extracts into a separate directory, preserves numeric +ownership, ordinary modes and timestamps, then swaps the private-data directory. +Setuid/setgid bits are not restored. The previous directory remains beside it as +`..before-restore-`; the returned `previous` path identifies +it. This is an additional recovery copy, not an automatic deletion policy. + +Locally verified: archive round trip including recovery copy, malformed archive +rejection, transfer command construction and failure handling. Not verified: +real Frame UID mappings/permissions, Android app-level recovery, live FUSE OBB +writes or persistence. Backups reject symlinks/special files; an app requiring +those needs a separately designed backup format. These CLI features still need +a real-device acceptance pass before being exposed as a polished UI workflow. diff --git a/docs/steam-games.md b/docs/steam-games.md index 068a153..ddaa4c7 100644 --- a/docs/steam-games.md +++ b/docs/steam-games.md @@ -110,3 +110,11 @@ returns nothing without `cc`, so Frame Control takes the country from - Installing when there's more than one library folder, such as a microSD card. - Uninstalling. `steam://uninstall/` should open a confirmation in the headset. + +## Optional VR software + +The [VR utilities list](vr-utilities.md#optional-software) is separate from our +controls and HUD. It checks software ownership as well as games; paid utilities +are installable only when already in the loaded Frame account library. No +purchase flow is added. Public reports, local results and ownership are shown +separately, and untested tools remain untested. diff --git a/docs/testing.md b/docs/testing.md index acc7a18..82a1826 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -104,6 +104,20 @@ switch while SSH is down: ## Headset smoke test +**Documented shared-device procedure:** before a test installs, launches or +stops an application, acquire `ssh frame 'mkdir /tmp/frame-test.lock'`. If it +fails, leave that lock alone and continue offline work. Only the thread that +acquired it releases it with `ssh frame 'rmdir /tmp/frame-test.lock'`, after +cleanup. Keep each device session to a few minutes. + +Check battery capacity and charging state under `/sys/class/power_supply` +before and after; keep capacity above 20%. Stop only processes started by the +test, remove temporary installs and profiles, and restore the prior dashboard +state. Leave Steam and SteamVR running. Do not reboot or change global settings. +Record the build, actual interaction results, cleanup and any unworn-headset +limits alongside screenshots or logs. These are caller responsibilities; the +smoke script below does not acquire this shared lock itself. + ```sh scripts/frame-smoke.sh # needs `ssh frame` to work without a password scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset @@ -170,3 +184,29 @@ assistant against an in-process HTTP endpoint with canned responses (no keys or external calls). `tests/e2e/test_agents.py` runs the MCP/HTTP/SSH path against the fake Frame for approved installs, clipboard and file transfer. Headset Chromium rendering and real screenshots still need a device; see [agent evidence](agents.md#evidence-and-limits). + +## Family and comfort + +`tests/test_comfort.py` uses an injected clock, fake headset sensor readings and +actions, plus a Node fake of Steam's Home API. It covers warnings before Home, +late/suspended sessions, cancellation, failed actions, duplicate alerts, reboot +invalidation, per-zone thermal trips and shared on-headset state. The server +guards reject invalid session settings before SSH. See +[real-device evidence and limits](family-comfort.md#verification). + +## Panel switcher + +`tests/test_panels.py` supplies fake-Frame `vrcmd --overlays` output, checks +main-panel filtering (including hidden panels), revalidates closed panels before +focus, and drives the headset helper's real loopback HTTP server to test access +keys, Host/Origin guards, malformed requests, offline errors and Close. It runs +in the normal unit suite without OpenVR or a headset. The fixture format comes +from SteamVR 2.18.1, BUILD_ID `20260925.6191901`; it does not simulate rendering. + +On the Frame, run `python3 -` over SSH with `ui/frame_panels.py` on stdin to +list panels. `--focus ` rechecks the list and requests focus. In Frame +Control, **Tools → Panel switcher → Open in headset** exercises installation, +Chromium rendering and the same helper through HTTP. Close the switcher after +testing. [The recorded device checks](panels.md#frame-controls-panel-switcher) +cover actual focus, HTTP guards and an OpenXR sample transition, and separately +identify the unverified Steam-game, spatial layout, reboot and laser behaviors. diff --git a/docs/vr-apks.md b/docs/vr-apks.md index e57325c..bce6817 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -84,6 +84,132 @@ not being worn, so it did not reach `FOCUSED`). The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a Khronos-style loader and found SteamVR through `/vendor`. +## In the Steam library + +Every successful APK install goes through the same mandatory artwork writer: +CLI (including `scripts/install-apk.sh`), upload, catalogue, version finder, +web download and source modules calling `frame_android.install`. Native +Linux/Windows sideloads also use it, preserving their devkit runtime wiring. +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 +`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 +returned by the settings API or copied to the headset. Exact title matches +(including a trailing “VR” variant) use the highest-scored returned static, +non-NSFW image in each slot. Provider failures use the next source. + +Sources pass `install(apk_path, artwork={...})`: keys are `grid`, `wide`, +`hero`, `logo`, `icon`, `banner`, `feature_graphic`, `screenshot`, or a list +`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB and +4096×4096 pixels maximum; any PNG depth or interlace, since the Frame's +Chromium decodes them). URLs must resolve to public addresses, follow at most +three redirects and share one deadline per install. Any source that fails, +for any reason, becomes a warning and generated art. Banners and feature graphics supply hero/wide art; +screenshots are the next fallback. Source images are cached for refresh. +All images are fitted to 600×900 portrait, 920×430 wide, 3840×1240 hero, +1280×480 logo and 256×256 icon. Explicit logos retain transparency. +Photo-based portrait, wide and hero slots are JPEG: Steam takes at most +12 MiB per slot, and on the Frame (2026-09-28) a noise-heavy 3840×1240 hero +came to more than 12 MiB as PNG, 3.7 MB as JPEG (2.7 s to render); a +landscape photo hero 5.6 MB as PNG, 0.76 MB as JPEG (0.75 s). A render that +still fails is retried once with generated art. Steam keeps a slot's `.png` +and `.jpg` side by side, so each slot is cleared before it is set. + +Generated art uses the APK icon, a dominant-colour gradient, a blurred +backdrop and large foreground icon with shadow. Steam's Chromium canvas and +Motiva Sans render real text consistently regardless of the host OS; no +Pillow, host font installation or bitmap font is needed. The hero has no +title; the generated logo is a transparent title. APKs with no usable icon +get a typographic monogram. The desktop package includes the renderer. + +Backfill installed Android apps without reinstalling or stopping them: + +```sh +python3 ui/frame_android.py refresh-art org.godotengine.open_saber_plus +python3 ui/frame_android.py refresh-art --all +``` + +Devkit titles installed by Frame Control have the same command, +`python3 ui/frame_titles.py refresh-art ID|--all`. The settings panel's +refresh covers both. The API is `POST /api/android` with +`{"action":"refresh-art","all":true}` (apps and titles) or a `package`, and +`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a +background job. Batch results retain per-item errors, and the CLIs exit +nonzero if any failed. Apps and titles without complete artwork show **Add +artwork** and `list` prints the command. Only entries marked `art_pending` at +install (a title Steam registered after an install made while it wasn't +running) are backfilled automatically, when Frame Control lists them with +Steam running (at most every five minutes), and that backfill only fills +slots Steam has no art for: names, icons, flags and any art the user set are +kept. Older installs without the flag are refreshed only on request. + +Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build +20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by +its saved id, or by an executable or start folder inside +`~/devkit-game//`, read from `appDetailsStore`; never by display name. +That the devkit shortcut's exe/start folder sit inside the title folder is +inferred from `docs/sideloading.md` (`proton waitforexitandrun +"/home/steamos/devkit-game//"`), not yet seen in app details. +Devkit titles keep the VR flag Steam gave them. + +**Verified on build 20260925.6191901, SteamVR 2.18.1 (2026-09-28):** both +Open Saber Plus and SuperTux were backfilled. Steam's cached portrait, wide, +hero and logo PNGs have the dimensions above; each shortcut points at its +256×256 icon. This Frame client mishandles custom-art type 4 (documented as +Icon), overwriting the wide capsule; the implementation uses custom types +0–3 and **SetShortcutIcon** separately. + +Steam accepts display name, executable/start directory, icon, VR flag and +sort-as name. Android apps join **Android**, immersive apps also **Android +VR**; native sideloads join **Sideloaded**. Existing collection members and +unrelated collections are preserved (both games retained **Played**). +Dynamic/read-only collection conflicts produce warnings. The native notes +API supports a managed **Installation details** note (package, version and +source) while preserving other notes. Notes are keyed by sanitized shortcut +name, so Steam itself cannot distinguish equal-name shortcut notes. No +supported shortcut description/store-page, developer/publisher, release +metadata or custom achievement API was found; these are not fabricated. + +The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by +stopping its own container and child process group. A lock refuses duplicate launches; +a container still running while the lock is free was orphaned by a killed +launcher and is stopped before the new launch. Lepton doesn't inherit the +lock. Orphan recovery only stops the app's own, deterministically named +container; a Lepton host process whose launcher was killed before it created +the container may linger briefly. Removing an app or title still deletes its files when Steam isn't +running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact +64-bit game ID string. Frame Control's Stop additionally has a direct-container +fallback. The stable instance ID and compatdata paths remain unchanged. + +Lepton normally forwards the instance `SteamAppId` to Android, causing +SteamVR to associate the scene with a different, artwork-less app. The +launcher uses Lepton's supported `LEPTON_ENV_SteamAppId` passthrough to send +the actual shortcut ID to Android while retaining the stable container ID. +**Verified:** Open Saber was alive 22 seconds after Steam Play, SteamVR +identified `steam.app.3346865537`, and its scene appeared in the headset +capture without the previous blank Resume tile. Steam Stop then removed its +tracked process and stopped the container. An earlier 32-second session was +also tracked until Steam Stop. No global standby or dashboard overrides were +installed; wear detection and other user-opened overlays still apply. + +**SuperTux limitation:** Steam launched and tracked it, but SDL crashed during +activity creation because Lepton lacks `ClipboardManager`. Its container +cleaned up on exit after about 17 seconds. Consequently sustained SuperTux +Play/Stop and its VR scene could not be verified. This is an APK/runtime +compatibility failure, separate from library presentation. + +Evidence is under `/tmp/vrlib-evidence/` on the development Mac: final artwork +preview and three design passes, `steam-cache-final.log`, +`steam-details-targets.json`, `opensaber-identity-session.log`, +`opensaber-identity-headset.png`, and `supertux-lepton.log`. The preview is +rendered artwork, not a Steam UI screenshot; CDP screenshot capture timed +out. Authenticated SteamGridDB, Windows/Linux packaged builds and the sibling +source-search endpoint remain unverified (the public install seam is tested). + ## Out of scope - **Meta entitlement.** Apps that call the Oculus Platform SDK diff --git a/docs/vr-utilities.md b/docs/vr-utilities.md new file mode 100644 index 0000000..0e73669 --- /dev/null +++ b/docs/vr-utilities.md @@ -0,0 +1,139 @@ +# VR comfort and performance + +Frame Control owns its HUD and telemetry. They use SteamVR/OpenVR, gamescope, +Python and xterm already on the Frame, plus the Frame's sensors. No feature +requires fpsVR, OVR Advanced Settings, XSOverlay or another third-party app. +The software list is a separate, optional convenience. + +This is **part of [#25](https://github.com/saphid/frame-control/issues/25)**, +not completion of the issue. Playspace controls remain blocked on the device +checks below. The PR stays draft. + +## Our performance HUD + +On **Home → VR comfort and performance**, 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 +it to a controller. **Close HUD**, or closing its terminal, ends it. Opening +it twice reuses the existing process. + +| Value | Meaning and source | +|---|---| +| Compositor FPS / period | Differences between two `IVRCompositor_029::GetFrameTiming` frame indices and monotonic compositor timestamps, sampled 200 ms apart. Output cadence, not game FPS or a long-term average. | +| Application FPS | Reciprocal of OpenVR's client frame interval. Unavailable if there is no positive interval; not inferred from refresh rate. | +| Render GPU time | OpenVR total render GPU milliseconds, not GPU utilisation. | +| 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`. | + +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. + +Missing sensors, a stopped or incompatible SteamVR runtime, and non-advancing +frame indices display **Unavailable**, never invented zero FPS. Failed status +refreshes clear the HUD card rather than keeping a stale live-looking sample. +The HUD itself adds CPU/GPU work; it is a diagnostic, not a zero-overhead benchmark. + +**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`, SteamVR +2.18.1: the exact OpenVR interface and 192-byte timing layout returned advancing +frame indices and live GPU/CPU timing. Sensor reads, creation of overlay +`valve.steam.desktopgame.2000250025`, duplicate-open handling, and closing the +HUD passed. The temporary probe overlay disappeared after its process closed. +[Sanitized device sample](evidence/vr-utilities/device.json). + +**Unverified:** visual placement while wearing the headset, controller docking, +and overhead during gameplay. The companion card was checked in the attached +preview using live Frame data. Creating an overlay does not establish that it +was visible to the wearer. + +The optional HUD copies only `frame_status.py` and `frame_vr.py` into +`~/.local/share/frame-control/vr/`. It tags only the window whose X11 PID matches +its own xterm, avoiding other threads' windows. Stop checks both PID and Linux +process start time before sending SIGTERM. It does not stop Steam or SteamVR, +edit their settings, install a service, or need sudo. + +## Playspace, seated height and recenter: paused + +**Verified 2026-09-28:** SteamVR exposes `IVRChaperoneSetup_006` and +`IVRChaperone_004`. An initial 1 cm seated zero-pose translation committed, +read back and restored numerically. A later trial, while the shared Frame was +in use, changed universe IDs after commits and returned transforms that did +not match the requested write or restore. Journal entries reported +`CommitWorkingCopy`, `VREvent_ChaperoneUniverseHasChanged` and +`VREvent_ChaperoneRoomSetupCommitted`. The collision-bound arrays and play-area +size matched in the saved before/after records, but the origin matrices did not. + +Alex confirmed the headset was in use and asked to pause control tests. No +further playspace writes were made. The exploratory control implementation was +removed from the shipping API; `recenter`, `adjust` and `restore` are rejected. +This is an **unresolved feasibility check**, not evidence that OpenVR controls +cannot work. Concurrent use and the Frame driver's coordinate-system handling +still need to be separated. + +The probe's original and last-read poses remain in the Frame's +`~/.local/share/frame-control/vr/comfort.json` for investigation. This draft +neither reads nor applies that baseline. Do not blindly replay it into a room +that may have changed. No recenter test was reached in the later trial. + +Before adding controls, on an idle Frame: + +1. Establish current room and tracking state, and inspect the retained probe + evidence before considering any restoration. +2. Prove seated and standing height/move operations in the Frame driver's + current coordinates, including delayed readback, coordinate rebasing and + recovery. Show that the physical safety boundary stays correct. +3. Verify recenter independently, and test a full apply/restore cycle plus a + concurrent room-change refusal. Add a fake OpenVR test for those contracts. +4. Check the apparent result in a seated and a standing app before exposing UI. + +**Documented:** seated and standing are tracking origins selected by an app; +changing a seated origin cannot force every game to support seated play. + +**Inferred from the installed SteamVR defaults:** there is no generic snap-turn +or locomotion-vignette setting. `dashboard.verticalOffsetCm_2` and +`steamvr.panelMaskVignette` affect panels, not the player's height or game +locomotion. The app gives game-setting hints for snap-turn, teleport movement +and movement vignette instead of writing these unrelated settings. + +## Optional software + +**Verified 2026-09-28 (same build):** a read-only query of Steam's loaded +`appStore.allApps` found none of these five apps on the Frame account. The query +includes software, which the existing games-only library filter excludes. +Steam's public app-details API listed only Desktop+ as free. No software was +purchased, installed or launched during these ownership checks. + +| Utility | Local Frame status | Public evidence / optional source | +|---|---|---| +| OVR Advanced Settings (1009850) | **Untested**, not owned | Steam edition is paid. Developer's [free source and releases](https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings). No verified Frame result in this work. | +| XSOverlay (1173510) | **Untested**, not owned | Supplied research attributes Proton support with tweaks to [Road to VR](https://www.roadtovr.com/valve-steam-frame-review/). This is a public report, not our verification. | +| OVR Toolkit (1068820) | **Untested**, not owned | Same [public Frame report](https://www.roadtovr.com/valve-steam-frame-review/); no local verification. | +| fpsVR (908520) | **Untested**, not owned | [Steam listing](https://store.steampowered.com/app/908520/). No Frame-specific result established in the supplied research. General PC VR reviews do not verify Frame support. | +| Desktop+ (1494460) | **Untested**, free | [Developer source](https://github.com/elvissteinjr/DesktopPlus). No verified Frame result in this work. | + +**Documented (supplied research):** the XSOverlay/OVR Toolkit claims are kept as +attributed leads. **Verified source check 2026-09-28:** fetching the linked +review returned HTTP 200 and the expected review title, but neither utility name +appeared in the fetched HTML or extracted text. The claims could not be +corroborated from that page; this is not proof of incompatibility. They do not make those apps dependencies or mark them +locally verified. No paid utility is auto-acquired. A server-side check blocks +installation through the Steam endpoint if the paid utility is absent from the +loaded Frame library; an empty/unavailable library fails closed. Desktop+ uses +the existing free Steam install flow, which may need a license confirmation in +the headset. None is advertised as known-good without local evidence. + +Compatibility reports reuse the existing `compat-db` storage and validation +with `package: "steam:"`, rather than colliding with Android package IDs. +The optional list shows the latest `works`, `issues` or `broken` report with its +date, build and notes, separately from public sources and ownership. With no +report the status stays **untested**. No shared database schema change or +production deployment is needed; no reports were published by this work. + +## Validation and review + +166 unit tests and 8 website tests passed locally. The new fake-Frame cases +passed in GitHub CI (Docker was unavailable locally). Desktop and phone-width +preview checks passed. The two independent review attempts did not produce a +verdict; [commands, real exit statuses and limitations](evidence/vr-utilities/review.md). diff --git a/docs/vr-video.md b/docs/vr-video.md index 1e242a6..2232915 100644 --- a/docs/vr-video.md +++ b/docs/vr-video.md @@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved. ![Our Gaussian-splat stereo preview on the Frame](img/media-splat-proof.png) +**Verified end to end, 2026-09-29** (same build; headset unworn): uploads +through the HTTP API, the web UI and `scripts/push-vr-video.sh`, all played by +the owned player. Our generated test files: + +| Case | Result | +|---|---| +| H.264 half-SBS 1920×1080 with AAC, theatre | 240/240 frames in 8.09 s; audio stream "Frame Control Media" in PulseAudio | +| H.265 half-OU 1920×1080 | 180/180 frames in 6.03 s; red left eye, cyan right | +| H.264 full-SBS 3840×1080, `stereo_mode=left_right` only | Detected from metadata; 150/150 frames in 5.03 s | +| H.264 1280×720, explicit 2D | 150/150 frames in 5.02 s | +| SBS PNG, OU JPEG (theatre) | Correct eye in each capture | +| 3,000-Gaussian `.splat` | Rendered in about 5 s, then held until Stop | +| 2D file on Auto, `_SBS_OU` file, HEIC, VP9 | Refused with the documented message | +| Second Play while one runs | Refused: "Stop the current media…" | + +Stop always left the unit inactive, and no player process remained. + +**Standby (verified):** an unworn Frame turns its displays off a few seconds +after it wakes. `SetOverlayRaw` then returns `RequestFailed` (23). The +first run's movie died there. The player now drops frames while the headset +is in standby, keeps the audio and its clock going, and resumes the picture +when the headset wakes. The 8 s movie above dropped 40 frames and finished. +Stills and the theatre surround are re-sent after waking. Five minutes +without an accepted frame is reported as an error. Headset-view captures +taken during standby show a flat dark frame, not our screen. + +![Media panel in Frame Control while a photo plays on the Frame](img/media-ui-panel.png) + **Verified failed route:** GStreamer 1.24.2's `playbin` selected `v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139) in the basic appsink probe and the OpenVR probe. We do not ship that route. diff --git a/frame/android/app-data.py b/frame/android/app-data.py new file mode 100644 index 0000000..15c740b --- /dev/null +++ b/frame/android/app-data.py @@ -0,0 +1,168 @@ +"""Private-data archives, run under podman unshare on the Frame. Stdlib only.""" +import contextlib +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import sys +import tarfile +import tempfile +import time + +MAX_BYTES = 20 * 1024 ** 3 +MAX_FILES = 100000 +MAX_MANIFEST = 1024 * 1024 + + +def inspect_archive(path, package, instance): + names, total, manifest = set(), 0, None + with tarfile.open(path, 'r:gz') as archive: + for member in archive: + name = member.name + parts = PurePosixPath(name).parts + if (not parts or name.startswith('/') or '..' in parts or + name != '/'.join(parts) or name in names or '\\' in name): + raise ValueError('unsafe or duplicate archive path') + if name == 'data' and not member.isdir(): + raise ValueError('data root must be a directory') + names.add(name) + if len(names) > MAX_FILES or not (member.isdir() or member.isfile()): + raise ValueError('archive has too many files, links or special files') + if member.uid < 0 or member.gid < 0 or member.uid > 65535 or member.gid > 65535: + raise ValueError('archive owner outside Android user namespace') + total += member.size + if total > MAX_BYTES: + raise ValueError('archive exceeds 20 GiB') + if name == 'manifest.json' and member.isfile() and member.size <= MAX_MANIFEST: + manifest = json.load(archive.extractfile(member)) + elif parts[0] != 'data': + raise ValueError('unexpected archive member') + if (not isinstance(manifest, dict) or manifest.get('format') != 1 or + manifest.get('package') != package or manifest.get('instance') != instance or + 'data' not in names): + raise ValueError('backup does not match this package and instance') + return {'files': len(names) - 1, 'bytes': total, 'package': package, 'instance': instance, + 'skipped_links': manifest.get('skipped_link_count', 0)} + + +def backup(root, package, instance, output): + import io + source = root / package + if source.is_symlink() or not source.is_dir(): + raise ValueError('private app data does not exist or is a symlink') + count, total, links, skipped = 0, 0, [], 0 + + def checked(member): + nonlocal count, total, skipped + if member.issym(): # never followed or restored; listed in the manifest instead + skipped += 1 + if len(links) < 1000: + links.append({'path': member.name[:512], 'target': member.linkname[:256]}) + return None + if member.islnk(): # a second name for a file already archived: store its content again + member.type, member.linkname = tarfile.REGTYPE, '' + member.size = os.lstat(str(source / member.name[len('data/'):])).st_size + count += 1 + total += member.size + if not (member.isdir() or member.isfile()) or count > MAX_FILES or total > MAX_BYTES: + raise ValueError('private data contains special files or exceeds backup limits') + return member + + with tarfile.open(fileobj=output, mode='w|gz', dereference=False) as archive: + archive.add(str(source), arcname='data', filter=checked) + # Written last so that it can list what was skipped. + manifest = json.dumps({'format': 1, 'package': package, 'instance': instance, + 'skipped_links': links, 'skipped_link_count': skipped}).encode() + member = tarfile.TarInfo('manifest.json') + member.size, member.mode = len(manifest), 0o600 + archive.addfile(member, io.BytesIO(manifest)) + + +def restore(root, package, instance, input_stream): + source = root / package + if source.is_symlink() or not source.is_dir(): + raise ValueError('private app data does not exist or is a symlink') + with tempfile.TemporaryDirectory(prefix='.frame-restore-', dir=str(root)) as work: + work = Path(work) + archive_path = work / 'backup.tar.gz' + with archive_path.open('wb') as output: + size = 0 + while True: + chunk = input_stream.read(1024 * 1024) + if not chunk: + break + size += len(chunk) + if size > MAX_BYTES: + raise ValueError('compressed backup exceeds 20 GiB') + output.write(chunk) + result = inspect_archive(archive_path, package, instance) + stage = work / 'stage' + stage.mkdir(mode=0o700) + with tarfile.open(archive_path, 'r:gz') as archive: + directories = [] + for member in archive: + if member.name == 'manifest.json': + continue + target = stage / member.name + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + directories.append((target, member)) + else: + target.parent.mkdir(parents=True, exist_ok=True) + with archive.extractfile(member) as src, target.open('xb') as dst: + shutil.copyfileobj(src, dst, 1024 * 1024) + apply_metadata(target, member) + for target, member in reversed(directories): + apply_metadata(target, member) + with package_lock(root, package): # another restore of this package must not delete our copy + previous = root / ('.' + package + '.before-restore-' + str(time.time_ns())) + source.rename(previous) + try: + (stage / 'data').rename(source) + except BaseException: + previous.rename(source) + raise + # Keep only the newest pre-restore copy of this package's data. + for old in root.glob('.' + package + '.before-restore-*'): + if old != previous and not old.is_symlink(): + shutil.rmtree(str(old), ignore_errors=True) + result['previous'] = str(previous) + return result + + +@contextlib.contextmanager +def package_lock(root, package): + import fcntl + fd = os.open(str(root / ('.' + package + '.restore.lock')), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + os.close(fd) # releases the lock + + +def apply_metadata(path, member): + os.chown(str(path), member.uid, member.gid) + os.chmod(str(path), member.mode & 0o777) + os.utime(str(path), (member.mtime, member.mtime)) + + +def main(): + action, package, instance = sys.argv[1:] + instance = int(instance) + root = Path.home() / '.local/share/Steam/steamapps/compatdata' / str(instance) / 'internal' + if root.is_symlink() or root.resolve() != root.absolute(): + raise ValueError('private-data directory traverses a symlink') + if action == 'backup': + backup(root, package, instance, sys.stdout.buffer) + elif action == 'restore': + print(json.dumps(restore(root, package, instance, sys.stdin.buffer))) + else: + raise ValueError('unknown app-data action') + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, tarfile.TarError) as error: + sys.exit(str(error)) diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh index 63b0572..49080cb 100644 --- a/frame/android/lepton-app.sh +++ b/frame/android/lepton-app.sh @@ -19,6 +19,25 @@ done # A number that isn't a real Steam app; it names this app's Lepton context. export SteamAppId="$(cat "$DIR/instance.id")" +[[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; } +# Keep the stable Lepton context, but identify the Android VR client as its +# actual Steam shortcut. Lepton applies LEPTON_ENV_* after its own passthrough. +if [[ -f "$DIR/shortcut.id" ]]; then + shortcut="$(cat "$DIR/shortcut.id")" + [[ "$shortcut" =~ ^[0-9]+$ ]] || { echo "invalid shortcut.id" >&2; exit 1; } + export LEPTON_ENV_SteamAppId="$shortcut" +fi +exec 9>"$DIR/launch.lock" +flock -n 9 || { echo "Android app is already running" >&2; exit 1; } +CONTAINER="lepton-steamlaunch-$SteamAppId" +# Holding the lock means no launcher owns a running container: it was orphaned +# (this script SIGKILLed), so stop it rather than refuse every later Play. The +# name is this app's alone. A Lepton host process whose launcher was killed +# before it made the container may linger briefly; nothing else is killed. +if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then + echo "Stopping orphaned $CONTAINER" >&2 + podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true +fi export STEAM_COMPAT_INSTALL_PATH="$DIR" # Must be under ~/.local/share/Steam: only that tree is mounted in the container. export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId" @@ -29,4 +48,29 @@ mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH" # Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts # and SSH don't have; give it its own session instead. export IS_PARENT=true -exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" +# Keep this shell in Steam's process tree; setsid alone has no container cleanup. +child="" +cleanup() { + trap '' TERM INT HUP + if [[ -n "$child" ]]; then + kill -TERM -- "-$child" 2>/dev/null || true + kill -TERM "$child" 2>/dev/null || true + fi + podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true + if [[ -n "$child" ]]; then + kill -KILL -- "-$child" 2>/dev/null || true + kill -KILL "$child" 2>/dev/null || true + wait "$child" 2>/dev/null || true + fi +} +trap cleanup EXIT +trap 'exit 143' TERM +trap 'exit 130' INT +trap 'exit 129' HUP +# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held. +setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- & +child=$! +rc=0 +wait "$child" || rc=$? +child="" +exit "$rc" diff --git a/frame/android/library_artwork.js b/frame/android/library_artwork.js new file mode 100644 index 0000000..89ef0d6 --- /dev/null +++ b/frame/android/library_artwork.js @@ -0,0 +1,146 @@ +// Runs in Steam's Chromium context: identical fonts/rendering from every host OS. +async function renderLibraryArtwork(input) { + const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]}; + const label = String(input.label || 'Untitled').trim().slice(0,180); + const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif'; + await document.fonts.load(`800 120px ${font}`, label); + const images = {}, warnings = []; + for (const [slot, item] of Object.entries(input.images || {})) { + try { + const img = new Image(); + img.src = `data:image/${item[0]};base64,${item[1]}`; + await img.decode(); + if (!img.width || !img.height || img.width*img.height > 16777216) throw Error('dimensions'); + images[slot] = img; + } catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); } + } + let icon = images.icon; + if (icon) { + // Remove only a near-black matte connected to the outside of an opaque icon. + const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height; + const c=cut.getContext('2d');c.drawImage(icon,0,0); + const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height; + const corners=[0,w-1,(h-1)*w,h*w-1]; + if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) { + const seen=new Uint8Array(w*h), queue=corners.slice(); + for(let q=0;q24)continue; + d[i*4+3]=0; + if(i%w)queue.push(i-1);if(i%w=w)queue.push(i-w);if(i220 || hi-lo<25) continue; + const key=rgb.map(v=>Math.round(v/32)*32).join(','); + bins.set(key,(bins.get(key)||0)+1); + } + const ranked=[...bins].sort((a,b)=>b[1]-a[1]); + if (ranked.length) { + colors[0]=ranked[0][0].split(',').map(Number); + colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number); + } + } + // Preserve hue while lifting muted icon colors into a richer background palette. + colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1; + return c.map(v=>45+(v-low)/range*165);}); + const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`; + function image(ctx,img,x,y,w,h,cover=false) { + const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height); + const dw=img.width*scale,dh=img.height*scale; + ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip(); + ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore(); + } + function title(ctx,w,h,top,bottom,maxSize) { + let lines=[],size=maxSize; + const maxWidth=w*.84; + for (;size>=18;size-=2) { + ctx.font=`800 ${size}px ${font}`; + lines=[]; let line=''; + for (const word of label.split(/\s+/)) { + const next=line?line+' '+word:word; + if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next; + } + lines.push(line); + if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break; + } + if(lines.length===2) { + const words=lines[0].split(' '); + if(words.length>1) { + const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1]; + if(ctx.measureText(second).width<=maxWidth && + Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)< + Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second]; + } + } + // A long unbroken label is still fitted, including scripts without spaces. + ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff'; + ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06; + let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54; + for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;} + ctx.shadowBlur=0; ctx.shadowOffsetY=0; + } + const result={}; + for (const [slot,[w,h]] of Object.entries(sizes)) { + const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h; + const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high'; + const direct=images[slot]; + const feature=images.feature_graphic||images.banner; + const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot)); + if (scene) { + if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h); + else image(ctx,scene,0,0,w,h,true); + } else if (slot==='logo') { + title(ctx,w,h,h*.08,h*.92,150); + } else { + const gradient=ctx.createLinearGradient(0,0,w,h); + gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68))); + gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32))); + gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h); + if (icon) { + ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`; + image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore(); + } + const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8); + glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0)); + ctx.fillStyle=glow;ctx.fillRect(0,0,w,h); + const vignette=ctx.createLinearGradient(0,h*.25,0,h); + vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)'); + ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h); + const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]: + slot==='wide'?[w*.36,h*.06,w*.28,h*.59]: + slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84]; + if (icon) { + ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055; + ctx.shadowOffsetY=Math.min(w,h)*.022; + // Opaque square icons read as deliberate app tiles, not pasted rectangles. + const [x,y,bw,bh]=box, side=Math.min(bw,bh); + if(slot!=='hero' && icon===images.icon) { + ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip(); + } + image(ctx,icon,...box);ctx.restore(); + } else if (slot !== 'hero') { + // A typographic monogram when the APK contains no usable image. + ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)'; + ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38); + } + if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66); + if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52); + // Hero intentionally has no title: Steam overlays the transparent logo. + } + // Photos as PNG can pass Steam's 12 MiB limit at hero size; the logo keeps its transparency. + const jpeg=scene && slot!=='logo' && slot!=='icon'; + result[slot]=[jpeg?'jpg':'png', canvas.toDataURL(jpeg?'image/jpeg':'image/png',.9).split(',')[1]]; + } + return {images:result,warnings,font}; +} diff --git a/frame/android/steam_shortcuts.py b/frame/android/steam_shortcuts.py index 6aea8de..bf614e5 100644 --- a/frame/android/steam_shortcuts.py +++ b/frame/android/steam_shortcuts.py @@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - < this`. steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id steam_shortcuts.py list -> JSON [{appid, name, exe}] + steam_shortcuts.py configure APPID NAME EXE START_DIR ICON VR ARTWORK_JSON + steam_shortcuts.py stop APPID steam_shortcuts.py remove APPID """ -import base64, json, os, socket, struct, sys, urllib.request +import base64, glob, json, os, re, socket, struct, sys, urllib.request DEVTOOLS = 'http://127.0.0.1:8080/json' @@ -22,10 +24,10 @@ def target_ws(): class WS: """Just enough RFC 6455 for one CDP request/response on loopback.""" - def __init__(self, url): + def __init__(self, url, timeout=20): host_port, path = url[len('ws://'):].split('/', 1) host, port = host_port.split(':') - self.s = socket.create_connection((host, int(port)), timeout=20) + self.s = socket.create_connection((host, int(port)), timeout=timeout) key = base64.b64encode(os.urandom(16)).decode() self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n' f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n' @@ -69,8 +71,8 @@ class WS: return msg.decode() -def evaluate(js): - ws = WS(target_ws()) +def evaluate(js, timeout=20): + ws = WS(target_ws(), timeout) ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': { 'expression': js, 'awaitPromise': True, 'returnByValue': True}})) while True: @@ -83,6 +85,206 @@ def evaluate(js): return res.get('result', {}).get('value') +# Steam's ELibraryAssetType (Capsule, Hero, Logo, Header, Icon). +ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4} + + +def collections_js(appid, wanted=()): + wanted = list(wanted) + return f'''async function syncCollections() {{ + const wanted = {json.dumps(wanted)}; + if (typeof collectionStore === "undefined" || + typeof collectionStore.GetUserCollectionsByName !== "function" || + typeof collectionStore.NewUnsavedCollection !== "function" || + typeof collectionStore.SaveCollection !== "function") + return ["Steam collections API unavailable"]; + const app = {{appid: {appid}}}; + const warnings = []; + for (const name of ["Android", "Android VR", "Sideloaded"]) {{ + const matches = collectionStore.GetUserCollectionsByName(name); + let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop); + if (wanted.includes(name)) {{ + if (!collection && matches.length) {{ + warnings.push(name + " is an existing dynamic or read-only collection"); + continue; + }} + if (!collection) {{ + collection = collectionStore.NewUnsavedCollection(name, undefined, [app]); + }} else {{ + collection.AsDragDropCollection().AddApps([app]); + }} + await collectionStore.SaveCollection(collection); + }} else if (collection) {{ + collection.AsDragDropCollection().RemoveApps([app]); + await collectionStore.SaveCollection(collection); + }} + }} + return warnings; + }}''' + + + +def notes_js(name, details): + filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip()) + content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k)) + return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" && + typeof SteamClient.GameNotes.SaveNotes === "function") {{ + try {{ + const file = {json.dumps(filename)}; + const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/"); + if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result); + const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}}; + if (!Array.isArray(data.notes)) throw Error("unexpected notes format"); + const id = "frame-control-library", now = Math.floor(Date.now()/1000); + const old = data.notes.find(n => n.id === id); + const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details", + content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length, + time_created: old ? old.time_created : now, time_modified: now}}; + data.notes = data.notes.filter(n => n.id !== id).concat([note]); + const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data)); + if (result !== 1) throw Error("save " + result); + }} catch (e) {{ warnings.push("Steam notes: " + String(e)); }} + }}''' + + +MAX_ART = 12 * 1024 * 1024 # Steam's custom artwork limit per slot + + +def render(plan): + with open(plan) as f: + source = json.load(f) + images = {} + for slot, path in source['images'].items(): + ext = os.path.splitext(path)[1][1:] + with open(path, 'rb') as f: + data = f.read(MAX_ART + 1) + if len(data) > MAX_ART: + raise ValueError('source artwork too large') + images[slot] = [ext, base64.b64encode(data).decode()] + renderer = globals().get('ART_RENDERER') + if renderer is None: + with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f: + renderer = f.read() + try: + return _render(plan, renderer, source['label'], images) + except (ValueError, OSError, EOFError, SystemExit) as e: + # Generated art from the icon alone always fits; a photo that didn't must not fail the install. + result = _render(plan, renderer, source['label'], {k: v for k, v in images.items() if k == 'icon'}) + result['warnings'].insert(0, 'Source artwork could not be rendered (' + str(e)[:120] + '); generated art used') + return result + + +def _render(plan, renderer, label, images): + # A 4K photo takes seconds to decode and encode on the Frame; allow well beyond that. + result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': label, 'images': images}) + ')', + timeout=75) + if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS): + raise ValueError('incomplete artwork render') + paths = {} + for slot, (ext, encoded) in result['images'].items(): + data = base64.b64decode(encoded, validate=True) + signature = {'png': b'\x89PNG\r\n\x1a\n', 'jpg': b'\xff\xd8\xff'}.get(ext) + if not signature or not data.startswith(signature) or len(data) > MAX_ART: + raise ValueError(slot + ' render is ' + str(len(data)) + ' bytes of ' + str(ext)) + paths[slot] = os.path.join(os.path.dirname(plan), slot + '.' + ext) + with open(paths[slot] + '.tmp', 'wb') as f: + f.write(data) + for slot, path in paths.items(): + os.replace(path + '.tmp', path) + for stale in ('png', 'jpg'): + other = os.path.join(os.path.dirname(plan), slot + '.' + stale) + if other != path and os.path.exists(other): + os.remove(other) + return {'paths': paths, 'warnings': list(result.get('warnings', []))} + + +# Steam's own file for each custom-art type in userdata/*/config/grid/. +GRID_FILES = {0: 'p', 1: '_hero', 2: '_logo', 3: ''} + + +def custom_art(appid): + """The custom-art types this shortcut already has in Steam, for any local user.""" + found = set() + for kind, suffix in GRID_FILES.items(): + pattern = os.path.expanduser(f'~/.local/share/Steam/userdata/*/config/grid/{int(appid)}{suffix}.*') + if any(os.path.splitext(p)[1].lower() in ('.png', '.jpg', '.jpeg') for p in glob.glob(pattern)): + found.add(kind) + return found + + +def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None): + """options: category, details, fill_only (only empty slots and a missing icon; name and flags untouched).""" + options = options or {} + category = options.get('category', 'Android') + fill = bool(options.get('fill_only')) + existing = custom_art(appid) if fill else set() + if set(artwork) != set(ASSETS): + raise ValueError('all five Steam artwork slots are required') + images = [] + for slot, path in artwork.items(): + if slot not in ASSETS: + raise ValueError('unknown artwork slot') + ext = os.path.splitext(path)[1][1:] + if ext not in ('png', 'jpg'): + raise ValueError('artwork must be PNG or JPEG') + with open(path, 'rb') as f: + data = f.read(MAX_ART + 1) + if len(data) > MAX_ART: + raise ValueError('artwork is too large') + # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon. + if slot != 'icon' and ASSETS[slot] not in existing: + images.append([ASSETS[slot], ext, base64.b64encode(data).decode()]) + return evaluate(f'''(async () => {{ + const id = {int(appid)}, warnings = [], fill = {json.dumps(fill)}; + const overview = appStore.GetAppOverviewByAppID(id); + if (!fill) {{ + SteamClient.Apps.SetShortcutName(id, {json.dumps(name)}); + if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)}); + if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)}); + if (typeof SteamClient.Apps.SetShortcutSortAs === "function") + SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)}); + }} + if (!fill || !(overview && overview.icon_data)) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)}); + // null (devkit titles) or filling gaps: leave the VR flag as Steam has it. + if ({json.dumps(vr)} !== null && !fill) {{ + if (typeof SteamClient.Apps.SetShortcutIsVR === "function") + SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)}); + else warnings.push("Steam VR shortcut flag API unavailable"); + }} + if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{ + for (const [type, ext, data] of {json.dumps(images)}) {{ + // Steam keeps a slot's PNG and JPEG side by side; clear it so a stale one can't win. + if (!fill && typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") + try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}} + await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type); + }} + }} else throw new Error("Steam artwork API unavailable; installation is incomplete"); + {collections_js(int(appid), [category] + (['Android VR'] if vr and category == 'Android' else []))} + try {{ warnings.push(...await syncCollections()); }} + catch (e) {{ warnings.push("Steam collections: " + String(e)); }} + {notes_js(name, options.get('details', {}))} + return {{warnings}}; + }})()''', timeout=60) + + +def remove(appid): + # Collections and artwork are tidy-up: only a missing RemoveShortcut may fail the removal. + return evaluate(f'''(async () => {{ + const id = {int(appid)}, warnings = []; + {collections_js(int(appid))} + try {{ warnings.push(...await syncCollections()); }} + catch (e) {{ warnings.push("Steam collections: " + String(e)); }} + if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{ + for (const type of [0, 1, 2, 3]) {{ + try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} + catch (e) {{ warnings.push("Steam artwork " + type + ": " + String(e)); }} + }} + }} else warnings.push("Steam artwork removal API unavailable"); + SteamClient.Apps.RemoveShortcut(id); + return {{warnings}}; + }})()''') + + def main(): cmd, args = sys.argv[1], sys.argv[2:] if cmd == 'add': @@ -97,12 +299,30 @@ def main(): }})()''' print(evaluate(js)) elif cmd == 'list': - js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824) - .map(a => ({appid: a.appid, name: a.display_name})))()''' + # Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered. + js = '''(async () => Promise.all(appStore.allApps.filter(a => a.app_type === 1073741824).map(async a => { + let d = typeof appDetailsStore !== "undefined" && appDetailsStore.GetAppDetails(a.appid); + if (!d && typeof SteamClient.Apps.RegisterForAppDetails === "function") d = await new Promise(ok => { + let reg; + const timer = setTimeout(() => { if (reg) reg.unregister(); ok(null); }, 3000); + reg = SteamClient.Apps.RegisterForAppDetails(a.appid, x => { + clearTimeout(timer); setTimeout(() => reg && reg.unregister()); ok(x); }); + }); + return {appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid, + exe: d ? d.strShortcutExe || "" : "", start_dir: d ? d.strShortcutStartDir || "" : ""}; + })))()''' print(json.dumps(evaluate(js))) + elif cmd == 'render': + print(json.dumps(render(args[0]))) + elif cmd == 'configure': + vr = {'1': True, '0': False}.get(args[5]) # '' leaves Steam's VR flag alone + print(json.dumps(configure(int(args[0]), *args[1:5], vr, json.loads(args[6]), + json.loads(args[7]) if len(args) > 7 else None))) + elif cmd == 'stop': + evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)') + print('stopping') elif cmd == 'remove': - evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})') - print('removed') + print(json.dumps(remove(int(args[0])))) else: sys.exit(__doc__) diff --git a/ios/FrameControl.xcodeproj/project.pbxproj b/ios/FrameControl.xcodeproj/project.pbxproj index c1fa8f9..dcc0271 100644 --- a/ios/FrameControl.xcodeproj/project.pbxproj +++ b/ios/FrameControl.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; }; 84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; }; 9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; }; + A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */; }; A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; }; DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; }; E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; }; @@ -48,6 +49,7 @@ BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = ""; }; D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = ""; }; DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; + E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfortNotificationTests.swift; sourceTree = ""; }; EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = ""; }; F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -107,6 +109,7 @@ 75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = { isa = PBXGroup; children = ( + E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */, 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */, ); path = FrameControlTests; @@ -274,6 +277,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */, DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/FrameControl/Web/WebShell.swift b/ios/FrameControl/Web/WebShell.swift index fee7df2..3ddbe4d 100644 --- a/ios/FrameControl/Web/WebShell.swift +++ b/ios/FrameControl/Web/WebShell.swift @@ -1,6 +1,7 @@ import SwiftUI import UIKit import WebKit +import UserNotifications /// The Frame Control page, served by the server on the headset, in a web view. /// window.frameApp (the same bridge the desktop app's preload.js provides) lets @@ -48,6 +49,7 @@ struct WebShell: UIViewRepresentable { let installCb = null; window.frameApp = { platform: "ios", + notify: (message, request) => call("notify", { message, request }), readClipboard: () => call("readClipboard"), setUpConnection: () => call("setUpConnection"), open: (what) => call("open", what), @@ -58,13 +60,31 @@ struct WebShell: UIViewRepresentable { })(); """ - final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate { + final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate, UNUserNotificationCenterDelegate { let model: AppModel weak var web: WKWebView? var loaded: URL? private var installReady = false - init(model: AppModel) { self.model = model } + init(model: AppModel) { + self.model = model + super.init() + UNUserNotificationCenter.current().delegate = self + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .sound, .list]) + } + + static func notificationContent(_ message: String) -> UNMutableNotificationContent? { + guard !message.isEmpty, message.count <= 500 else { return nil } + let content = UNMutableNotificationContent() + content.title = "Frame Control" + content.body = message + content.sound = .default + return content + } // MARK: bridge @@ -76,6 +96,28 @@ struct WebShell: UIViewRepresentable { } let arg = body["arg"] switch name { + case "notify": + guard message.frameInfo.isMainFrame, + message.frameInfo.securityOrigin.host == "127.0.0.1", + let args = arg as? [String: Any], let text = args["message"] as? String, + let content = Self.notificationContent(text) else { + return replyHandler(nil, "Invalid notification") + } + let center = UNUserNotificationCenter.current() + let send: (Bool, Error?) -> Void = { allowed, error in + guard allowed else { + return replyHandler(nil, error?.localizedDescription ?? "Notifications are off. Enable them in iOS Settings.") + } + let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + center.add(request) { error in replyHandler(error == nil, error?.localizedDescription) } + } + if args["request"] as? Bool == true { + center.requestAuthorization(options: [.alert, .sound], completionHandler: send) + } else { + center.getNotificationSettings { settings in + send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil) + } + } case "readClipboard": replyHandler(UIPasteboard.general.string ?? "", nil) case "setUpConnection": diff --git a/ios/FrameControlTests/ComfortNotificationTests.swift b/ios/FrameControlTests/ComfortNotificationTests.swift new file mode 100644 index 0000000..68fe4c1 --- /dev/null +++ b/ios/FrameControlTests/ComfortNotificationTests.swift @@ -0,0 +1,14 @@ +import XCTest +import UserNotifications +@testable import Frame_Control + +final class ComfortNotificationTests: XCTestCase { + func testNotificationContentAndBounds() { + let content = WebShell.Coordinator.notificationContent("Time for a break") + XCTAssertEqual(content?.title, "Frame Control") + XCTAssertEqual(content?.body, "Time for a break") + XCTAssertNotNil(content?.sound) + XCTAssertNil(WebShell.Coordinator.notificationContent("")) + XCTAssertNil(WebShell.Coordinator.notificationContent(String(repeating: "x", count: 501))) + } +} diff --git a/scripts/connect.sh b/scripts/connect.sh index 00bcb41..477020b 100755 --- a/scripts/connect.sh +++ b/scripts/connect.sh @@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args] fi } -# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off. +# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a +# running app and this script never write over each other's change. write_config() { + local lockfd="" rc + zmodload zsh/system 2>/dev/null + touch "$CONFIG.frame-control.lock" 2>/dev/null + zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd="" + write_config_locked; rc=$? + [[ -n "$lockfd" ]] && zsystem flock -u "$lockfd" + return $rc +} + +# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off. +write_config_locked() { touch "$CONFIG" && chmod 600 "$CONFIG" || return 1 - local tmp + local tmp new="$CONFIG.frame-control.$$" tmp=$(mktemp) || return 1 # Drop any previous managed block, then PREPEND a fresh one: ssh uses the first # value it sees per option, so this block must precede any other "Host frame" @@ -126,7 +138,8 @@ write_config() { print -r -- "Host *" print -r -- "$END_MARK" cat "$tmp" - } > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; } + } > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \ + || { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; } rm -f "$tmp" } diff --git a/scripts/keep-awake.sh b/scripts/keep-awake.sh index c4a3301..f2c6815 100755 --- a/scripts/keep-awake.sh +++ b/scripts/keep-awake.sh @@ -15,11 +15,13 @@ set -euo pipefail FRAME_ALIAS=${FRAME_ALIAS:-frame} +# Frame Control passes the headset it has chosen: its address and pinned identity. +ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}) HERE=${0:A:h} cmd=${1:-status} case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac -ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \ +ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \ 'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py" # Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the @@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \ # written the way Steam's settings page does (steamui module exporting the # SetSetting wrapper). logind refuses an inhibitor from an SSH session # ("Interactive authentication required") but allows one from a user unit. -ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF' +ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF' import json, os, subprocess, sys sys.path.insert(0, os.path.expanduser("~/.cache/frame-control")) from frame_steam import Page diff --git a/scripts/panel-on-frame.sh b/scripts/panel-on-frame.sh index 781767a..2ce218c 100755 --- a/scripts/panel-on-frame.sh +++ b/scripts/panel-on-frame.sh @@ -21,6 +21,8 @@ set -euo pipefail FRAME_ALIAS=${FRAME_ALIAS:-frame} +# Frame Control passes the headset it has chosen: its address and pinned identity. +ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}) REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina" id="" name="" @@ -109,4 +111,4 @@ EOF ) b64=$(print -rn -- "$remote" | base64) -ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}" +ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}" diff --git a/tests/e2e/test_android.py b/tests/e2e/test_android.py index b2b5373..2b40489 100644 --- a/tests/e2e/test_android.py +++ b/tests/e2e/test_android.py @@ -33,8 +33,8 @@ class AndroidApps(harness.FrameTestCase): self.assertEqual(shortcut['name'], 'App label') self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh') self.assertEqual(shortcut['start_dir'], APP_DIR) - self.assertEqual(shortcut['icon'], f'{APP_DIR}/icon.png') - for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'icon.png', 'lepton-show-flatscreen'): + self.assertEqual(shortcut['icon'], f'{APP_DIR}/artwork/icon.png') + for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'artwork/icon.png', 'lepton-show-flatscreen'): self.assertTrue(exists(f'{APP_DIR}/{f}'), f) self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000) diff --git a/tests/e2e/test_device.py b/tests/e2e/test_device.py index e649e02..9c22b58 100644 --- a/tests/e2e/test_device.py +++ b/tests/e2e/test_device.py @@ -83,5 +83,27 @@ class Device(harness.FrameTestCase): self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam') +class VRUtilities(harness.FrameTestCase): + def test_missing_vr_runtime_is_unavailable_not_zero_fps(self): + data = ok('GET', '/api/status') + self.assertIsNone(data['performance']['compositorFps']) + self.assertIsNone(data['performance']['appFps']) + self.assertEqual(data['temp'], 41.5) + self.assertEqual(data['battery']['percent'], 76) + + def test_optional_paid_utilities_are_not_installed(self): + # The fake library contains games but none of these paid software titles. + for appid in (1009850, 1173510, 1068820, 908520): + code, body, _ = api('POST', '/api/steam', {'action': 'install', 'appid': appid}) + self.assertEqual(code, 502, body) + self.assertIn('not owned', body['error']) + self.assertEqual(launches('install'), []) + + def test_unverified_controls_are_not_exposed(self): + for action in ('recenter', 'adjust', 'restore'): + code, body, _ = api('POST', '/api/vr', {'action': action, 'origin': 'seated', 'y': .1}) + self.assertEqual(code, 400, body) + + if __name__ == '__main__': unittest.main() diff --git a/tests/fakeframe/rootfs/usr/local/bin/podman b/tests/fakeframe/rootfs/usr/local/bin/podman index a996bf3..9c10c24 100755 --- a/tests/fakeframe/rootfs/usr/local/bin/podman +++ b/tests/fakeframe/rootfs/usr/local/bin/podman @@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)} if cmd == 'ps': for name, c in sorted(containers.items()): print(f"{name} {c['port']}") +elif cmd == 'inspect': + if args[-1] not in containers: + sys.exit(1) + print('true') elif cmd == 'stop': name = args[-1] c = containers.get(name) diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js new file mode 100644 index 0000000..8a95e86 --- /dev/null +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub.js @@ -0,0 +1,33 @@ +// Synthetic canvas for API-path tests only. It emits transparent PNGs, not visual proof. +const zlib = require('zlib'); +function crc(data) { + let c=0xffffffff; + for(const b of data) {c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);} + return (c^0xffffffff)>>>0; +} +function chunk(name,data) { + const body=Buffer.concat([Buffer.from(name),data]), n=Buffer.alloc(4), sum=Buffer.alloc(4); + n.writeUInt32BE(data.length);sum.writeUInt32BE(crc(body));return Buffer.concat([n,body,sum]); +} +function png(w,h) { + const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=6; + return Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),chunk('IHDR',header), + chunk('IDAT',zlib.deflateSync(Buffer.alloc((w*4+1)*h))),chunk('IEND',Buffer.alloc(0))]).toString('base64'); +} +function surface() { + const canvases=[]; + const document={fonts:{load:async()=>[]},createElement(tag) { + if(tag!=='canvas')throw Error('unexpected element'); + const canvas={width:1,height:1,text:[],draws:0}; + const ctx={measureText:t=>({width:String(t).length*30}),fillText(t){canvas.text.push(t);}, + drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}), + createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})}; + for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{}; + canvas.getContext=()=>ctx;canvas.toDataURL=type=>type==='image/jpeg'?'data:image/jpeg;base64,'+Buffer.from('ffd8ffe000104a464946','hex').toString('base64'): + 'data:image/png;base64,'+png(canvas.width,canvas.height); + canvases.push(canvas);return canvas; + }}; + class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}} + return {document,Image,canvases}; +} +module.exports={surface}; diff --git a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js index e50f3cf..c4fa0dc 100644 --- a/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js +++ b/tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js @@ -27,7 +27,7 @@ function newShortcutId(steam) { function build(steam) { const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id)); const gameOverview = a => ({ - appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: 1, + appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: a.app_type ?? 1, steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only, size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0, rt_last_time_played: a.last_played || 0, @@ -37,7 +37,8 @@ function build(steam) { }, }); const shortcutOverview = s => ({ - appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, + appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid, + icon_data: s.icon ? 'fake-icon' : undefined, local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 }, }); const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)]; @@ -54,7 +55,30 @@ function build(steam) { } }; + // Library API shapes from SteamTracking / decky-frontend-lib (2026-09-28). + // Not yet verified on this Frame build: Steam was unavailable during testing. + steam.collections ||= []; + const collection = value => ({ + ...value, displayName: value.name, bIsDynamic: !!value.dynamic, bAllowsDragAndDrop: true, + AsDragDropCollection() { return this; }, + AddApps(apps) { value.apps = [...new Set([...value.apps, ...apps.map(a => a.appid)])]; }, + RemoveApps(apps) { value.apps = value.apps.filter(id => !apps.some(a => a.appid === id)); }, + value, + }); return { + ...require('./canvas_stub').surface(), + collectionStore: { + GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); }, + NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); }, + async SaveCollection(c) { if (!steam.collections.includes(c.value)) steam.collections.push(c.value); }, + }, + // Shortcut exe/start folder live in app details, not overviews (Frame, 2026-09-28). + appDetailsStore: { + GetAppDetails(id) { + const s = findShortcut(id); + return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null; + }, + }, appStore: { get allApps() { return allApps(); }, GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; }, @@ -75,6 +99,17 @@ function build(steam) { SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); }, SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); }, SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); }, + SetShortcutIsVR(id, vr) { const s = findShortcut(id); if (s) s.vr = vr; }, + async SetCustomArtworkForApp(id, data, ext, type) { + const s = findShortcut(id); + if (s) { s.artwork ||= {}; s.artwork[type] = {data, ext}; } + }, + async ClearCustomArtworkForApp(id, type) { + const s = findShortcut(id); + if (s?.artwork) delete s.artwork[type]; + }, + // Container fallback in frame_android.stop performs the simulated stop. + TerminateApp(gameid) { steam.last_terminate = gameid; }, RemoveShortcut(id) { steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id)); delete steam.compat_tools[String(id)]; diff --git a/tests/fakessh/ssh b/tests/fakessh/ssh new file mode 100755 index 0000000..45b2b17 --- /dev/null +++ b/tests/fakessh/ssh @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""A stand-in for OpenSSH's ssh, for tests/test_link.py: prints what `ssh -v` prints at +each step of a connection, and plays a ControlMaster. What each HostName does comes +from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key), "denied" or +"slow" (hangs after connecting); +every call is appended to $FAKESSH_LOG as a JSON line. POSIX only.""" +import json +import os +import signal +import sys +import time + +args = sys.argv[1:] +with open(os.environ["FAKESSH_LOG"], "a") as f: + f.write(json.dumps(args) + "\n") +hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}")) +opts = {} +i = 0 +while i < len(args) and args[i].startswith("-"): + if args[i] in ("-o", "-O", "-p", "-l"): + key = args[i] + val = args[i + 1] + if key == "-o": + k, _, v = val.partition("=") + opts[k.lower()] = v + else: + opts[key] = val + i += 2 + else: + opts[args[i]] = True + i += 1 +alias = args[i] if i < len(args) else "" +host = opts.get("hostname", alias).replace("%%", "%") +marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_")) +say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush()) + +if "-G" in opts: + print(f"hostname {alias}\nport 22\nuser tester") + sys.exit(0) +if opts.get("-O") == "check": + sys.exit(0 if os.path.exists(marker) else 255) +if opts.get("-O") == "exit": + if os.path.exists(marker): + os.unlink(marker) + sys.exit(0) + +what = hosts.get(host) +if what is None: + say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known") + sys.exit(255) +say(f"debug1: Connecting to {host} [127.0.0.1] port {opts.get('port', 22)}.") +say("debug1: Connection established.") +if what == "slow": + time.sleep(30) +say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'") +say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak") +if what == "wrong": + say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@") + say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @") + say("Host key verification failed.") + sys.exit(255) +say(f"debug1: Host '{opts.get('hostkeyalias', host)}' is known and matches the ED25519 host key.") +say("debug1: Next authentication method: publickey") +if what == "denied": + say(f"tester@{host}: Permission denied (publickey).") + sys.exit(255) +say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".') +if opts.get("controlmaster") == "yes": + open(marker, "w").close() + def bye(*_): + if os.path.exists(marker): + os.unlink(marker) + sys.exit(0) + signal.signal(signal.SIGTERM, bye) + while True: + time.sleep(0.2) + if not os.path.exists(marker): + sys.exit(0) +sys.exit(0) diff --git a/tests/fixtures/apk-search/artwork/README.md b/tests/fixtures/apk-search/artwork/README.md new file mode 100644 index 0000000..6c67814 --- /dev/null +++ b/tests/fixtures/apk-search/artwork/README.md @@ -0,0 +1,19 @@ +# Store preview artwork + +Recorded public artwork for offline UI verification, fetched 2026-09-28. +`urls.json` records each original URL. No unit test downloads these files. + +- Open Brush banner, icon and screenshots: Icosa Foundation's public + `icosa-foundation/openbrush.app` website assets. Artwork remains credited to + its creators; used here to preview the Open Brush listing. +- Mindustry, AntennaPod and NewPipe icons/screenshots: their public F-Droid + listings. Corresponding projects use GPL licences; these images represent + those same apps in the store preview. +- Luanti, SuperTuxKart and other social previews: public GitHub-generated + repository preview images. Project names/logos belong to their owners. + +`../store.json` contains illustrative listing metadata, including mock package +names, popularity, dates, version/size and compatibility fields. It is not a +catalogue or evidence that a particular release works on the Frame. `_demo.py` +is opt-in and cannot download APKs. `tests/search_preview.py` preloads these +recordings into the image cache and simulates installation without a headset. diff --git a/tests/fixtures/apk-search/artwork/brush-banner.jpg b/tests/fixtures/apk-search/artwork/brush-banner.jpg new file mode 100644 index 0000000..b7bd790 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-banner.jpg differ diff --git a/tests/fixtures/apk-search/artwork/brush-icon.png b/tests/fixtures/apk-search/artwork/brush-icon.png new file mode 100644 index 0000000..32cfb0a Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/brush-shot1.png b/tests/fixtures/apk-search/artwork/brush-shot1.png new file mode 100644 index 0000000..3cd6b99 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-shot1.png differ diff --git a/tests/fixtures/apk-search/artwork/brush-shot2.webp b/tests/fixtures/apk-search/artwork/brush-shot2.webp new file mode 100644 index 0000000..c22ad8c Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-shot2.webp differ diff --git a/tests/fixtures/apk-search/artwork/brush-shot3.webp b/tests/fixtures/apk-search/artwork/brush-shot3.webp new file mode 100644 index 0000000..13b255e Binary files /dev/null and b/tests/fixtures/apk-search/artwork/brush-shot3.webp differ diff --git a/tests/fixtures/apk-search/artwork/kart.png b/tests/fixtures/apk-search/artwork/kart.png new file mode 100644 index 0000000..6d90ef5 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/kart.png differ diff --git a/tests/fixtures/apk-search/artwork/luanti-icon.png b/tests/fixtures/apk-search/artwork/luanti-icon.png new file mode 100644 index 0000000..47d7345 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/luanti-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/luanti.png b/tests/fixtures/apk-search/artwork/luanti.png new file mode 100644 index 0000000..c8fa35d Binary files /dev/null and b/tests/fixtures/apk-search/artwork/luanti.png differ diff --git a/tests/fixtures/apk-search/artwork/mindustry-icon.png b/tests/fixtures/apk-search/artwork/mindustry-icon.png new file mode 100644 index 0000000..db8b4f2 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/mindustry-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/mindustry-shot.png b/tests/fixtures/apk-search/artwork/mindustry-shot.png new file mode 100644 index 0000000..b9c5874 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/mindustry-shot.png differ diff --git a/tests/fixtures/apk-search/artwork/newpipe-icon.png b/tests/fixtures/apk-search/artwork/newpipe-icon.png new file mode 100644 index 0000000..561ea34 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/newpipe-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/newpipe-shot.png b/tests/fixtures/apk-search/artwork/newpipe-shot.png new file mode 100644 index 0000000..3ecd0de Binary files /dev/null and b/tests/fixtures/apk-search/artwork/newpipe-shot.png differ diff --git a/tests/fixtures/apk-search/artwork/pod-icon.png b/tests/fixtures/apk-search/artwork/pod-icon.png new file mode 100644 index 0000000..9b54286 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/pod-icon.png differ diff --git a/tests/fixtures/apk-search/artwork/pod-shot.png b/tests/fixtures/apk-search/artwork/pod-shot.png new file mode 100644 index 0000000..a189a10 Binary files /dev/null and b/tests/fixtures/apk-search/artwork/pod-shot.png differ diff --git a/tests/fixtures/apk-search/artwork/urls.json b/tests/fixtures/apk-search/artwork/urls.json new file mode 100644 index 0000000..7574e46 --- /dev/null +++ b/tests/fixtures/apk-search/artwork/urls.json @@ -0,0 +1,16 @@ +{ + "brush-banner.jpg": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg", + "brush-icon.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png", + "brush-shot1.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png", + "brush-shot2.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp", + "brush-shot3.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp", + "luanti.png": "https://opengraph.githubassets.com/1/luanti-org/luanti", + "kart.png": "https://opengraph.githubassets.com/1/supertuxkart/stk-code", + "luanti-icon.png": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png", + "pod-icon.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png", + "mindustry-icon.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png", + "mindustry-shot.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png", + "pod-shot.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png", + "newpipe-icon.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png", + "newpipe-shot.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png" +} diff --git a/tests/fixtures/apk-search/entries.json b/tests/fixtures/apk-search/entries.json new file mode 100644 index 0000000..1b1f236 --- /dev/null +++ b/tests/fixtures/apk-search/entries.json @@ -0,0 +1,7 @@ +[ + {"source":"one","id":"brush","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":true,"version":"1","version_code":1,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2025-01-01"}, + {"source":"two","id":"brush2","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":false,"version":"2","version_code":2,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2026-01-01"}, + {"source":"one","id":"other","package":"org.other","name":"Open Brush","free":true,"downloadable":true,"min_sdk":31,"abis":["arm64-v8a"],"vr":true}, + {"source":"one","id":"unknown","package":null,"name":"Pocket Radio!","free":true,"downloadable":false,"vr":false}, + {"source":"two","id":"unknown2","package":null,"name":"pocket radio","free":true,"downloadable":false,"vr":false} +] diff --git a/tests/fixtures/apk-search/store.json b/tests/fixtures/apk-search/store.json new file mode 100644 index 0000000..26bf886 --- /dev/null +++ b/tests/fixtures/apk-search/store.json @@ -0,0 +1,182 @@ +[ + { + "id": "brush", + "package": "org.preview.brush", + "name": "Open Brush", + "summary": "Make the world your canvas. Paint, sculpt and create in a space without limits.", + "description": "Your imagination deserves more room. Open Brush turns the space around you into a canvas, with expressive brushes, vivid colors and light you can paint with.\n\nCreate something small, build something extraordinary, or just enjoy making your first mark in VR. This community-led painting app is free and open source.", + "developer": "Icosa Foundation", + "license": "Apache-2.0", + "free": true, + "downloadable": true, + "version": "2.32.29", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": true, + "updated": "2026-09-27", + "size": 85000000, + "popularity": 100, + "images": { + "banner": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg", + "icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png", + "screenshots": [ + "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png", + "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp", + "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp" + ] + }, + "engine": "Unity OpenXR", + "frame_tested": true, + "icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png", + "page": "https://openbrush.app" + }, + { + "id": "mindustry", + "package": "org.preview.mindustry", + "name": "Mindustry", + "summary": "Build a factory. Defend your world.", + "description": "Build a factory. Defend your world.", + "developer": "Anuken", + "license": "GPL-3.0", + "free": true, + "downloadable": true, + "version": "1.2", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-26", + "size": 85000000, + "popularity": 92, + "images": { + "banner": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png", + "icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png", + "screenshots": [ + "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png" + ] + }, + "icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png" + }, + { + "id": "luanti", + "package": "org.preview.luanti", + "name": "Luanti", + "summary": "A world of blocks. Endless possibilities.", + "description": "A world of blocks. Endless possibilities.", + "developer": "Luanti contributors", + "license": "LGPL-2.1", + "free": true, + "downloadable": true, + "version": "1.3", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-25", + "size": 85000000, + "popularity": 84, + "images": { + "banner": "https://opengraph.githubassets.com/1/luanti-org/luanti", + "icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png", + "screenshots": [ + "https://opengraph.githubassets.com/1/luanti-org/luanti" + ] + }, + "icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png" + }, + { + "id": "pod", + "package": "org.preview.pod", + "name": "AntennaPod", + "summary": "Your favorite stories, wherever you listen.", + "description": "Your favorite stories, wherever you listen.", + "developer": "AntennaPod contributors", + "license": "GPL-3.0", + "free": true, + "downloadable": true, + "version": "1.4", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-24", + "size": 85000000, + "popularity": 76, + "images": { + "banner": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png", + "icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png", + "screenshots": [ + "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png" + ] + }, + "icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png" + }, + { + "id": "newpipe", + "package": "org.preview.newpipe", + "name": "NewPipe", + "summary": "Your videos and music, without the distractions.", + "description": "Your videos and music, without the distractions.", + "developer": "Team NewPipe", + "license": "GPL-3.0", + "free": true, + "downloadable": true, + "version": "1.5", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-23", + "size": 85000000, + "popularity": 68, + "images": { + "banner": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png", + "icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png", + "screenshots": [ + "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png" + ] + }, + "icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png" + }, + { + "id": "kart", + "package": "org.preview.kart", + "name": "SuperTuxKart", + "summary": "A little friendly competition. A lot of colorful chaos.", + "description": "A little friendly competition. A lot of colorful chaos.", + "developer": "SuperTuxKart Team", + "license": "GPL-3.0", + "free": true, + "downloadable": false, + "version": "1.6", + "version_code": 1, + "min_sdk": 26, + "abis": [ + "arm64-v8a" + ], + "vr": false, + "updated": "2026-09-22", + "size": 85000000, + "popularity": 60, + "images": { + "banner": "https://opengraph.githubassets.com/1/supertuxkart/stk-code", + "icon": null, + "screenshots": [ + "https://opengraph.githubassets.com/1/supertuxkart/stk-code" + ] + }, + "icon": null, + "page": "https://supertuxkart.net" + } +] diff --git a/tests/fixtures/fdroid/README.md b/tests/fixtures/fdroid/README.md new file mode 100644 index 0000000..c50721a --- /dev/null +++ b/tests/fixtures/fdroid/README.md @@ -0,0 +1,21 @@ +# F-Droid verification fixtures + +`entry.jar` and `index-v1.jar` are synthetic RSA-2048/SHA-256 signed JARs, +including CMS signed attributes. `fingerprint.txt` identifies their throwaway +certificate. Their JSON describes org.example.app; `example.apk` is deliberately +plain test data, not an installable app. The v2 index includes incompatible +Android-31 and x86-only versions to exercise the shared reducer. + +`izzy-entry.jar` was recorded from +https://apt.izzysoft.de/fdroid/repo/entry.jar on 2026-09-28. Its certificate +fingerprint matches the operator's published fingerprint: +3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A. +It exercises an independent production JAR/CMS encoder without network access. +The index it references is not needed by this signature-only fixture test. + +`artwork-v1.json` and `artwork-v2.json` are unsigned metadata/reducer fixtures +based on the synthetic indexes above. They exercise en-US preference, per-field +locale fallback, v1 artwork paths, phone/tablet ordering, the six-image cap, +author names and HTML/multiline summaries. The signed integrity fixtures remain +unchanged; artwork tests feed these JSON files directly through the reducer and +then round-trip the resulting entries through the source cache. diff --git a/tests/fixtures/fdroid/artwork-v1.json b/tests/fixtures/fdroid/artwork-v1.json new file mode 100644 index 0000000..a156a0c --- /dev/null +++ b/tests/fixtures/fdroid/artwork-v1.json @@ -0,0 +1,54 @@ +{ + "apps": [ + { + "packageName": "org.example.app", + "name": "Example", + "license": "MIT", + "authorName": "Example Developer", + "summary": "Fallback summary", + "localized": { + "de": { + "name": "Beispiel", + "summary": "Deutsch", + "icon": "german.png", + "phoneScreenshots": [ + "german.png" + ] + }, + "en-US": { + "name": "Example", + "summary": "Offline fixture & music.\n One\t line.", + "icon": "icon.png", + "phoneScreenshots": [ + "1.png", + "2.png", + "3.png", + "4.png" + ] + }, + "fr": { + "featureGraphic": "featureGraphic.png", + "sevenInchScreenshots": [ + "1.png", + "2.png", + "3.png", + "4.png" + ] + } + } + } + ], + "packages": { + "org.example.app": [ + { + "versionName": "1", + "versionCode": 1, + "apkName": "example1.apk", + "hash": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "hashType": "sha256", + "size": 51, + "minSdkVersion": 21 + } + ] + } +} diff --git a/tests/fixtures/fdroid/artwork-v2.json b/tests/fixtures/fdroid/artwork-v2.json new file mode 100644 index 0000000..ed7c821 --- /dev/null +++ b/tests/fixtures/fdroid/artwork-v2.json @@ -0,0 +1,143 @@ +{ + "repo": { + "name": { + "en-US": "Fixture" + } + }, + "packages": { + "org.example.app": { + "metadata": { + "name": { + "en-US": "Example" + }, + "summary": { + "en-US": "

Offline fixture & music.

\n

One\t line.

" + }, + "license": "MIT", + "authorName": "Example Developer", + "icon": { + "de": { + "name": "/org.example.app/de/icon.png" + }, + "en-US": { + "name": "/org.example.app/en-US/icon.png" + } + }, + "featureGraphic": { + "fr": { + "name": "/org.example.app/fr/featureGraphic.png" + } + }, + "screenshots": { + "phone": { + "de": [ + { + "name": "/org.example.app/de/phoneScreenshots/1.png" + } + ], + "en-US": [ + { + "name": "/org.example.app/en-US/phoneScreenshots/1.png" + }, + { + "name": "/org.example.app/en-US/phoneScreenshots/2.png" + }, + { + "name": "/org.example.app/en-US/phoneScreenshots/3.png" + }, + { + "name": "/org.example.app/en-US/phoneScreenshots/4.png" + } + ] + }, + "sevenInch": { + "fr": [ + { + "name": "/org.example.app/fr/sevenInchScreenshots/1.png" + }, + { + "name": "/org.example.app/fr/sevenInchScreenshots/2.png" + }, + { + "name": "/org.example.app/fr/sevenInchScreenshots/3.png" + }, + { + "name": "/org.example.app/fr/sevenInchScreenshots/4.png" + } + ] + } + } + }, + "versions": { + "1": { + "manifest": { + "versionName": "1", + "versionCode": 1, + "usesSdk": { + "minSdkVersion": 21 + }, + "nativecode": [] + }, + "file": { + "name": "/example1.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + }, + "2": { + "manifest": { + "versionName": "2", + "versionCode": 2, + "usesSdk": { + "minSdkVersion": 30 + }, + "nativecode": [ + "arm64-v8a" + ] + }, + "file": { + "name": "/example2.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + }, + "3": { + "manifest": { + "versionName": "3", + "versionCode": 3, + "usesSdk": { + "minSdkVersion": 31 + }, + "nativecode": [] + }, + "file": { + "name": "/example3.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + }, + "4": { + "manifest": { + "versionName": "4", + "versionCode": 4, + "usesSdk": { + "minSdkVersion": 21 + }, + "nativecode": [ + "x86_64" + ] + }, + "file": { + "name": "/example4.apk", + "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", + "size": 51 + }, + "added": 1700000000000 + } + } + } + } +} diff --git a/tests/fixtures/fdroid/entry.jar b/tests/fixtures/fdroid/entry.jar new file mode 100644 index 0000000..b73864a Binary files /dev/null and b/tests/fixtures/fdroid/entry.jar differ diff --git a/tests/fixtures/fdroid/example.apk b/tests/fixtures/fdroid/example.apk new file mode 100644 index 0000000..e53f5b7 --- /dev/null +++ b/tests/fixtures/fdroid/example.apk @@ -0,0 +1 @@ +Fixture APK payload, deliberately not installable. diff --git a/tests/fixtures/fdroid/fingerprint.txt b/tests/fixtures/fdroid/fingerprint.txt new file mode 100644 index 0000000..494e9b0 --- /dev/null +++ b/tests/fixtures/fdroid/fingerprint.txt @@ -0,0 +1 @@ +0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a diff --git a/tests/fixtures/fdroid/index-v1.jar b/tests/fixtures/fdroid/index-v1.jar new file mode 100644 index 0000000..6ea58d2 Binary files /dev/null and b/tests/fixtures/fdroid/index-v1.jar differ diff --git a/tests/fixtures/fdroid/index-v2.json b/tests/fixtures/fdroid/index-v2.json new file mode 100644 index 0000000..6d443c6 --- /dev/null +++ b/tests/fixtures/fdroid/index-v2.json @@ -0,0 +1 @@ +{"repo": {"name": {"en-US": "Fixture"}}, "packages": {"org.example.app": {"metadata": {"name": {"en-US": "Example"}, "summary": {"en-US": "Offline fixture"}, "license": "MIT"}, "versions": {"1": {"manifest": {"versionName": "1", "versionCode": 1, "usesSdk": {"minSdkVersion": 21}, "nativecode": []}, "file": {"name": "/example1.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "2": {"manifest": {"versionName": "2", "versionCode": 2, "usesSdk": {"minSdkVersion": 30}, "nativecode": ["arm64-v8a"]}, "file": {"name": "/example2.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "3": {"manifest": {"versionName": "3", "versionCode": 3, "usesSdk": {"minSdkVersion": 31}, "nativecode": []}, "file": {"name": "/example3.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "4": {"manifest": {"versionName": "4", "versionCode": 4, "usesSdk": {"minSdkVersion": 21}, "nativecode": ["x86_64"]}, "file": {"name": "/example4.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}}}}} \ No newline at end of file diff --git a/tests/fixtures/fdroid/izzy-entry.jar b/tests/fixtures/fdroid/izzy-entry.jar new file mode 100644 index 0000000..c518648 Binary files /dev/null and b/tests/fixtures/fdroid/izzy-entry.jar differ diff --git a/tests/fixtures/library/README.md b/tests/fixtures/library/README.md new file mode 100644 index 0000000..04d4f03 --- /dev/null +++ b/tests/fixtures/library/README.md @@ -0,0 +1,17 @@ +# Library fixtures + +`icon.png` is a synthetic 2×2 RGBA fixture with opaque, half-transparent and +transparent pixels. `steam-responses.json` includes the Open Saber Plus +shortcut ID/name and home path read from the Frame's existing metadata on +2026-09-28; the `configure` response is synthetic, matching our helper's +contract. Tests never contact the network. + +The existing fakeframe CEF shim models artwork and collection methods from +SteamTracking's `ClientExtracted/steamui/chunk~2dcc5aaf7.js` and +SteamDeckHomebrew/decky-frontend-lib's `src/globals/steam-client/App.ts`, read +2026-09-28. These methods were not captured from this headset: Steam's client +was unavailable. The Node-based test runs the actual generated JavaScript +against that fixture; it is skipped when Node is absent. + +`icon.jpg` is the same synthetic icon converted with macOS `sips` to exercise +JPEG SOF parsing. `sips` is not used by the product or tests. diff --git a/tests/fixtures/library/check-renderer.js b/tests/fixtures/library/check-renderer.js new file mode 100644 index 0000000..1e2e06f --- /dev/null +++ b/tests/fixtures/library/check-renderer.js @@ -0,0 +1,21 @@ +const fs=require('fs'),vm=require('vm'),assert=require('assert'); +const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub'); +(async()=>{ + const surface=stub.surface(),ctx=vm.createContext(surface); + vm.runInContext(fs.readFileSync('frame/android/library_artwork.js','utf8'),ctx); + const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}}); + assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']); + for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) { + assert.equal(result.images[slot][0],'png'); + const b=Buffer.from(result.images[slot][1],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]); + } + // A photo scene is JPEG (Steam's 12 MiB limit at hero size); the logo stays transparent PNG. + const photo=await ctx.renderLibraryArtwork({label:'Photo',images:{hero:['jpg','fixture'],banner:['jpg','fixture']}}); + for(const slot of ['wide','hero'])assert.equal(photo.images[slot][0],'jpg'); + for(const slot of ['grid','logo','icon'])assert.equal(photo.images[slot][0],'png'); + const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0); + const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0); + const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}}); + assert.equal(surface.canvases.slice(before).find(c=>c.width===3840).text.length,0); + console.log('five dimensions, textless hero, title logo: OK'); +})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/fixtures/library/icon.jpg b/tests/fixtures/library/icon.jpg new file mode 100644 index 0000000..ea4d67c Binary files /dev/null and b/tests/fixtures/library/icon.jpg differ diff --git a/tests/fixtures/library/icon.png b/tests/fixtures/library/icon.png new file mode 100644 index 0000000..2e2af85 Binary files /dev/null and b/tests/fixtures/library/icon.png differ diff --git a/tests/fixtures/library/steam-responses.json b/tests/fixtures/library/steam-responses.json new file mode 100644 index 0000000..8b2c2c7 --- /dev/null +++ b/tests/fixtures/library/steam-responses.json @@ -0,0 +1,12 @@ +{ + "home": "/home/steamos", + "shortcuts": [ + { + "appid": 3346865537, + "name": "Open Saber Plus" + } + ], + "configure": { + "warnings": [] + } +} diff --git a/tests/fixtures/more_sources/README.md b/tests/fixtures/more_sources/README.md new file mode 100644 index 0000000..8f33165 --- /dev/null +++ b/tests/fixtures/more_sources/README.md @@ -0,0 +1,23 @@ +Recorded 2026-09-28 from public publisher endpoints using FrameControl/0.1 or +`gh api`. JSON fixtures are reduced to fields consumed by the adapters; API +values are unchanged. No token, cookies or signed download URL is included. + +- `*-releases.json`: `/repos/{repo}/releases?per_page=10`, first two releases, + for KhronosGroup/OpenXR-SDK-Source, icosa-foundation/open-brush and + SgtBilko76/SuperTux-3D (one release). +- `topic.json`: `/search/repositories?q=topic:openxr+archived:false&sort=stars&per_page=3`. +- `itch-feed.txt`: `https://itch.io/games/free/platform-android/tag-openxr.xml`. +- `itch-robots.txt`: `https://itch.io/robots.txt`. +- `itch-author-robots.txt`: `https://godotvr.itch.io/robots.txt`. + +The synthetic ZIP in tests is only a transport/integrity fixture, not an +installable APK. Actual APK parsing was verified separately on the downloaded +Khronos Vulkan sample; see docs/apk-sources.md. + +Artwork follow-up: `topic.json` now retains `owner.avatar_url` from authenticated +repository API reads. `artwork-check.json` records HTTPS response status, +Content-Type and image magic checks for all curated URLs and three topic +results. All curated URLs returned real images; LWJGL's social preview returned +HTTP 429. This fixture is evidence of a point-in-time check, not an uptime test. +Open Brush screenshots came from the Steam appdetails response for app 1634870, +linked by its README. SuperTux's README links the recorded upstream screenshot. diff --git a/tests/fixtures/more_sources/artwork-check.json b/tests/fixtures/more_sources/artwork-check.json new file mode 100644 index 0000000..d16be22 --- /dev/null +++ b/tests/fixtures/more_sources/artwork-check.json @@ -0,0 +1,120 @@ +[ + { + "url": "https://avatars.githubusercontent.com/u/2757344?v=4", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://avatars.githubusercontent.com/u/784805?v=4", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://avatars.githubusercontent.com/u/94376830?v=4", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://opengraph.githubassets.com/1/KhronosGroup/OpenXR-SDK-Source", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://opengraph.githubassets.com/1/LWJGL/lwjgl3", + "error": "HTTP Error 429: Too Many Requests" + }, + { + "url": "https://opengraph.githubassets.com/1/bjornbytes/lovr", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://opengraph.githubassets.com/1/sahibzada-allahyar/YC-Killer", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/open-brush.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_0a9c208e26a43cf34879c2ca361d4c8f18af8cba.1920x1080.jpg", + "status": 200, + "content_type": "image/jpeg", + "image": true + }, + { + "url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_19b25b86ef55c0d8769a65135d60eaae8fa40553.1920x1080.jpg", + "status": 200, + "content_type": "image/jpeg", + "image": true + }, + { + "url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_785ea37d63378146dfe0f0ffa3f1d5c155ca978f.1920x1080.jpg", + "status": 200, + "content_type": "image/jpeg", + "image": true + }, + { + "url": "https://www.supertux.org/images/0_7_0/github_preview.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://opengraph.githubassets.com/1/arpruss/OpenSaberPlus", + "status": 200, + "content_type": "image/png", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif", + "status": 200, + "content_type": "image/gif", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_2.gif", + "status": 200, + "content_type": "image/gif", + "image": true + }, + { + "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_3.gif", + "status": 200, + "content_type": "image/gif", + "image": true + } +] \ No newline at end of file diff --git a/tests/fixtures/more_sources/brush-releases.json b/tests/fixtures/more_sources/brush-releases.json new file mode 100644 index 0000000..c17f800 --- /dev/null +++ b/tests/fixtures/more_sources/brush-releases.json @@ -0,0 +1,88 @@ +[ + { + "tag_name": "2.32.29", + "draft": false, + "prerelease": true, + "published_at": "2026-09-26T17:49:28Z", + "assets": [ + { + "id": 591143285, + "name": "OpenBrush_Android_2.32.29.apk", + "size": 314602794, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Android_2.32.29.apk", + "digest": "sha256:f20361b830803a2bf53e2af648bba59ee17bc4615bde534dbf6c4f0012bbd291" + }, + { + "id": 591143287, + "name": "OpenBrush_Desktop_2.32.29.zip", + "size": 380735034, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Desktop_2.32.29.zip", + "digest": "sha256:3b680b07ca0b8def579fe194916aa7db4d007c3094c4dea818124776098c9b9a" + }, + { + "id": 591143282, + "name": "OpenBrush_Linux_2.32.29.zip", + "size": 364906490, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Linux_2.32.29.zip", + "digest": "sha256:e380934f77d2b1441179424193a75f7b4b5793b34761c04cbf2d87bad9f8fc16" + }, + { + "id": 591143283, + "name": "OpenBrush_Mac_2.32.29.dmg", + "size": 373196471, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Mac_2.32.29.dmg", + "digest": "sha256:5d544d0a64bed1cae65173fcfa7ada069d4f81b42385e806e99cc4427ef3513c" + }, + { + "id": 591143286, + "name": "OpenBrush_Quest_2.32.29.apk", + "size": 314603546, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Quest_2.32.29.apk", + "digest": "sha256:57cd7b9067689060451494e55dc06276f934a992f5cbbd44d965069903937ba6" + } + ] + }, + { + "tag_name": "2.32.28", + "draft": false, + "prerelease": true, + "published_at": "2026-09-26T14:42:27Z", + "assets": [ + { + "id": 590837298, + "name": "OpenBrush_Android_2.32.28.apk", + "size": 314603450, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Android_2.32.28.apk", + "digest": "sha256:1a3af1a194c4348ef67c8ea61d9a8258e2490cdfe1f760cbc3c69f2978a6a082" + }, + { + "id": 590837301, + "name": "OpenBrush_Desktop_2.32.28.zip", + "size": 380738236, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Desktop_2.32.28.zip", + "digest": "sha256:c2de5424bc022951f0e0bdbd652ede549a1e60d9cfbf41c730ea43d16d97262f" + }, + { + "id": 590837297, + "name": "OpenBrush_Linux_2.32.28.zip", + "size": 364907046, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Linux_2.32.28.zip", + "digest": "sha256:29daf410347b3ca36e47808e31172270df8040ba7decc83be2bdcd51de895e06" + }, + { + "id": 590837296, + "name": "OpenBrush_Mac_2.32.28.dmg", + "size": 373195966, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Mac_2.32.28.dmg", + "digest": "sha256:2f352d766450c993fdcae8a8552878a6a976d32d675246b63c81bb1b326fd20d" + }, + { + "id": 590837295, + "name": "OpenBrush_Quest_2.32.28.apk", + "size": 314604234, + "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Quest_2.32.28.apk", + "digest": "sha256:9a3af6a6e838dd8bd201e549c5570f67db794df940e960390aeeae93235d702e" + } + ] + } +] diff --git a/tests/fixtures/more_sources/itch-author-robots.txt b/tests/fixtures/more_sources/itch-author-robots.txt new file mode 100644 index 0000000..5dacc48 --- /dev/null +++ b/tests/fixtures/more_sources/itch-author-robots.txt @@ -0,0 +1,12 @@ +User-agent: Mediapartners-Google +Disallow: + +User-agent: * +Disallow: /*/download/ +Disallow: /*/rh/ +Disallow: /*/rp/ +Disallow: /-/ + +Sitemap: https://itch.io/sitemap.xml + +# vim: set ft=robots: diff --git a/tests/fixtures/more_sources/itch-feed.txt b/tests/fixtures/more_sources/itch-feed.txt new file mode 100644 index 0000000..87a9e72 --- /dev/null +++ b/tests/fixtures/more_sources/itch-feed.txt @@ -0,0 +1,11 @@ +Top free games for Android tagged openxr - itch.iohttps://itch.io/games/free/platform-android/tag-openxrhttps://leandrodreamer.itch.io/open-saberOpen Saber [Free] [Rhythm] [Windows] [Linux] [Android]Open Saberhttps://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif$0.00USDhttps://leandrodreamer.itch.io/open-saber]]>Thu, 07 Sep 2023 02:11:50 GMTThu, 07 Sep 2023 02:11:50 GMTWed, 08 Jan 2025 02:24:29 GMTyeshttps://absyo.itch.io/off-nominalOff Nominal [Free] [Puzzle] [Windows] [Linux] [Android]Off Nominalhttps://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png$0.00USDhttps://absyo.itch.io/off-nominal]]>Fri, 25 Sep 2026 19:54:48 GMTFri, 25 Sep 2026 19:54:48 GMTSun, 27 Sep 2026 23:25:20 GMTyesyesyeshttps://somar-project.itch.io/somar-projectSomar-project [Free] [Educational] [Android]Somar-projecthttps://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png$0.00USDhttps://somar-project.itch.io/somar-project]]>Wed, 26 Mar 2025 17:17:58 GMTWed, 26 Mar 2025 17:17:58 GMTFri, 28 Mar 2025 15:52:59 GMTyeshttps://5imon.itch.io/buggenesisBug Genesis VR [Free] [Interactive Fiction] [Windows] [Android]Bug Genesis VRhttps://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg$0.00USDhttps://5imon.itch.io/buggenesis]]>Sun, 25 May 2025 20:22:49 GMTSun, 25 May 2025 20:22:49 GMTSun, 25 May 2025 20:37:39 GMTyesyeshttps://benmclean.itch.io/wolfsharpWolfSharp [Free] [Shooter] [Windows] [Linux] [Android]WolfSharphttps://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png$0.00USDhttps://benmclean.itch.io/wolfsharp]]>Sat, 25 Jul 2026 12:16:01 GMTSat, 25 Jul 2026 12:16:01 GMTSat, 25 Jul 2026 13:45:38 GMTyesyesyeshttps://mimekunst.itch.io/winter-solitude-vrWinter Solitude VR [Free] [Simulation] [Windows] [macOS] [Linux] [Android]Winter Solitude VRhttps://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png$0.00USDhttps://mimekunst.itch.io/winter-solitude-vr]]>Tue, 19 Dec 2023 19:19:59 GMTTue, 19 Dec 2023 19:19:59 GMTWed, 20 Dec 2023 22:49:23 GMTyesyesyesyeshttps://salmondev.itch.io/evil-miner-vrEVIL MINER VR [Free] [Other] [Windows] [Android]EVIL MINER VRhttps://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png$0.00USDhttps://salmondev.itch.io/evil-miner-vr]]>Fri, 13 Jun 2025 16:48:01 GMTFri, 13 Jun 2025 16:48:01 GMTSun, 15 Jun 2025 15:19:53 GMTyesyeshttps://robinhuud.itch.io/winter-challenge-north-pole-defenseNorth Pole Defense VR [Free] [Action] [Android]North Pole Defense VRhttps://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png$0.00USDhttps://robinhuud.itch.io/winter-challenge-north-pole-defense]]>Thu, 16 Dec 2021 01:33:33 GMTThu, 16 Dec 2021 01:33:33 GMTThu, 16 Dec 2021 01:51:29 GMTyeshttps://envemos.itch.io/ambly-native-xrAmbly Native XR [Free] [Linux] [Android]Ambly Native XRhttps://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg$0.00USDhttps://envemos.itch.io/ambly-native-xr]]>Wed, 22 Jul 2026 18:38:55 GMTWed, 22 Jul 2026 18:38:55 GMTFri, 07 Aug 2026 16:04:20 GMTyesyeshttps://andyman404.itch.io/glow-up-gardenGlow Up Garden (VR) [Free] [Action] [Windows] [Android]Glow Up Garden (VR)https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg$0.00USDhttps://andyman404.itch.io/glow-up-garden]]>Mon, 03 Jun 2024 20:36:53 GMTMon, 03 Jun 2024 20:36:53 GMTTue, 04 Jun 2024 04:20:37 GMTyesyes \ No newline at end of file diff --git a/tests/fixtures/more_sources/itch-robots.txt b/tests/fixtures/more_sources/itch-robots.txt new file mode 100644 index 0000000..3b8cbfc --- /dev/null +++ b/tests/fixtures/more_sources/itch-robots.txt @@ -0,0 +1,11 @@ +User-agent: * +Disallow: /embed/ +Disallow: /embed-upload/ +Disallow: /search +Disallow: /checkout/ +Disallow: /game/download/ +Disallow: /bundle/download/ +Disallow: /register-for-purchase/ +Disallow: /email-feedback/ + +Sitemap: https://itch.io/sitemap.xml diff --git a/tests/fixtures/more_sources/khronos-releases.json b/tests/fixtures/more_sources/khronos-releases.json new file mode 100644 index 0000000..5de075d --- /dev/null +++ b/tests/fixtures/more_sources/khronos-releases.json @@ -0,0 +1,410 @@ +[ + { + "tag_name": "release-1.1.63", + "draft": false, + "prerelease": false, + "published_at": "2026-09-02T21:22:32Z", + "assets": [ + { + "id": 541779948, + "name": "apilayer_api_dump-1.1.63.aar", + "size": 5120886, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar", + "digest": "sha256:9cab975cc8df3a99f6530f47a1fbabfa0527109a138f5a3ef5150672a658c61c" + }, + { + "id": 541779969, + "name": "apilayer_api_dump-1.1.63.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar.asc", + "digest": "sha256:419ec65d3f526c617176f272d8a481488181ade017cb05ef42205cb2c5a86f05" + }, + { + "id": 541779983, + "name": "apilayer_api_dump-1.1.63.pom", + "size": 1462, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom", + "digest": "sha256:9a5b3e470830ae471fe317bc63f43b33bc063458239238fe27e234232c45ff28" + }, + { + "id": 541780002, + "name": "apilayer_api_dump-1.1.63.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom.asc", + "digest": "sha256:7cbf9f1af504ca68fc36e0985dadb74d1fbf4d2bbdcde3e00bfe9ea6168fc4a8" + }, + { + "id": 541780126, + "name": "apilayer_best_practices_validation-1.1.63.aar", + "size": 484596, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar", + "digest": "sha256:0c56cb3dc0b093b28f4e5490820d3cb3f7b201b48444134f347455d4ee99abd2" + }, + { + "id": 541780150, + "name": "apilayer_best_practices_validation-1.1.63.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar.asc", + "digest": "sha256:7eb9ab3efe939c367e4661d5a75836c1d9e0799ab627e99211253546935defa7" + }, + { + "id": 541780162, + "name": "apilayer_best_practices_validation-1.1.63.pom", + "size": 1487, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom", + "digest": "sha256:97d410936f5f051ab2a73cdac196c744ac56b2dde6279a5e46e944ed61316147" + }, + { + "id": 541780192, + "name": "apilayer_best_practices_validation-1.1.63.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom.asc", + "digest": "sha256:7f9fa0cd33627c4ecc2a4410e53dedadb5731b3cddae59a3c0d4fcd467b3472d" + }, + { + "id": 541780025, + "name": "apilayer_core_validation-1.1.63.aar", + "size": 6386964, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar", + "digest": "sha256:9663ce94a5076b6707502cf5503bac456987ccf1f39a3f7c8f350d4521db8647" + }, + { + "id": 541780057, + "name": "apilayer_core_validation-1.1.63.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar.asc", + "digest": "sha256:c6e75df848ca6d436fe24f680bde73a9e69972b67eef46e49aba4b77dec366e9" + }, + { + "id": 541780090, + "name": "apilayer_core_validation-1.1.63.pom", + "size": 1455, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom", + "digest": "sha256:6aa9337f5e645baf489c05e562cd074dc696bad87db70a0cdd661dffc63b2c89" + }, + { + "id": 541780108, + "name": "apilayer_core_validation-1.1.63.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom.asc", + "digest": "sha256:0fe8ded9fdf0660bf28a544ae405b9b58682a8d9648dacedf4e4ceef2f827869" + }, + { + "id": 541777706, + "name": "hello_xr-OpenGLES-release-1.1.63.apk", + "size": 9557049, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-OpenGLES-release-1.1.63.apk", + "digest": "sha256:7c96022ac002cb0c72e3cd14c11a817767b7b5dbdf5a1d1c85d0c083b5719056" + }, + { + "id": 541777527, + "name": "hello_xr-Vulkan-release-1.1.63.apk", + "size": 9557441, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-Vulkan-release-1.1.63.apk", + "digest": "sha256:f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34" + }, + { + "id": 541800362, + "name": "OpenXR-SDK-Source-release-1.1.63.tar.gz", + "size": 4857593, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz", + "digest": "sha256:a3b97a36f11abe256a7ea1668a0a468aac9b738e94bea6b468f0ae31ad537a46" + }, + { + "id": 541800378, + "name": "OpenXR-SDK-Source-release-1.1.63.tar.gz.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz.asc", + "digest": "sha256:4f97028306ae219f599e9960adbf9bb072e8da2bfbedffd1f0de312516a61f87" + }, + { + "id": 541821806, + "name": "OpenXR.Loader.1.1.63.nupkg", + "size": 1916162, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg", + "digest": "sha256:4e5a50a8807ef66f25180ff224e7d8150b594aa8ee4b07590f9ade55a8e98703" + }, + { + "id": 541821827, + "name": "OpenXR.Loader.1.1.63.nupkg.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg.asc", + "digest": "sha256:9d66a6e958f7c2d5c4a0a4aef8df90a7beecab13547440c9fa2069979eab7ac7" + }, + { + "id": 541779892, + "name": "openxr_loader_for_android-1.1.63-sources.jar", + "size": 1141287, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar", + "digest": "sha256:6f964ad09c4afa3f42f451cada86e61503392b018660b22dd34b61dfbf71a555" + }, + { + "id": 541779920, + "name": "openxr_loader_for_android-1.1.63-sources.jar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar.asc", + "digest": "sha256:b98cba20f5c3b202b887307cb19196f4459f895413e55b68823a606f50d045fa" + }, + { + "id": 541779720, + "name": "openxr_loader_for_android-1.1.63.aar", + "size": 4170279, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar", + "digest": "sha256:622419d2f6741c3443a3beb4779af0764318edd01830de967f24c741ebcded73" + }, + { + "id": 541779762, + "name": "openxr_loader_for_android-1.1.63.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar.asc", + "digest": "sha256:3bb68b26d7def68b4fe8506bf09f302116290c2de9cf91fdfdba753978bff5ed" + }, + { + "id": 541779849, + "name": "openxr_loader_for_android-1.1.63.pom", + "size": 1598, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom", + "digest": "sha256:c98f38fa8acf4cf1bd8bcb40774f9815ae6ed9da94c8a7be2ed9db7815c85404" + }, + { + "id": 541779867, + "name": "openxr_loader_for_android-1.1.63.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom.asc", + "digest": "sha256:1c2625f5b889c7ed967c8a6010294ca94bbd96091d879b2fc989c6f40f9a6af1" + }, + { + "id": 541793000, + "name": "openxr_loader_macos-1.1.63.zip", + "size": 826298, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip", + "digest": "sha256:b243eebcdfa8683d17ccc8cfbfb9036be94b3f5a22b3eb73c39da0877694a3ab" + }, + { + "id": 541793027, + "name": "openxr_loader_macos-1.1.63.zip.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip.asc", + "digest": "sha256:3e95172aabc4bd2537a7125060abbb8efbdd0cee19bdf1bf30cbd9736b7dcbd2" + }, + { + "id": 541784717, + "name": "openxr_loader_windows-1.1.63.zip", + "size": 31961521, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip", + "digest": "sha256:01c631aeabbfe0879540f77ef833416c532a20746285b494630160c23588b771" + }, + { + "id": 541784760, + "name": "openxr_loader_windows-1.1.63.zip.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip.asc", + "digest": "sha256:e9384e2a94d82c5059d1d83c57d0da585056d95e393255048b0955b0ce69b3fc" + } + ] + }, + { + "tag_name": "release-1.1.62", + "draft": false, + "prerelease": false, + "published_at": "2026-08-01T01:32:30Z", + "assets": [ + { + "id": 500510340, + "name": "apilayer_api_dump-1.1.62.aar", + "size": 4800443, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar", + "digest": "sha256:2a7c2d1bb14d94dfed8c1aaf170a9dda649756477fbcba4a143c76d08a50bed8" + }, + { + "id": 500510366, + "name": "apilayer_api_dump-1.1.62.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar.asc", + "digest": "sha256:7ab53e3c1fcaabf49561737fe8ed5df9ad9a5a761615f05e1d5eed2799e85c5f" + }, + { + "id": 500510378, + "name": "apilayer_api_dump-1.1.62.pom", + "size": 1462, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom", + "digest": "sha256:fcd4358d7582ce0787b96aacb9840afc086a28499767a6aa7491dbb682bcc921" + }, + { + "id": 500510385, + "name": "apilayer_api_dump-1.1.62.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom.asc", + "digest": "sha256:4832ce5c8835d1880ae5adbc535b774d50f8c86f9870650a50fbf3b9993ec5fa" + }, + { + "id": 500510464, + "name": "apilayer_best_practices_validation-1.1.62.aar", + "size": 482607, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar", + "digest": "sha256:6d1a369ba367049aff23ae554b284ccc17cb3be8832869de0c1d151228a26502" + }, + { + "id": 500510477, + "name": "apilayer_best_practices_validation-1.1.62.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar.asc", + "digest": "sha256:658ecbb7f871e97ed0d659ed55b27ca6ff6cc6e1853699498ee7b1d5583d7313" + }, + { + "id": 500510480, + "name": "apilayer_best_practices_validation-1.1.62.pom", + "size": 1487, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom", + "digest": "sha256:571d7c5587075e83ca0a4d2382d87515de614ab8c34416a82a1b9b1a7eb5f9c0" + }, + { + "id": 500510489, + "name": "apilayer_best_practices_validation-1.1.62.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom.asc", + "digest": "sha256:342d0ed7080e92399dbc8648df4fe9378086718f1abc73f79f3a52de211ed36e" + }, + { + "id": 500510395, + "name": "apilayer_core_validation-1.1.62.aar", + "size": 5910024, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar", + "digest": "sha256:5692ccd5563a0963c4d842614af11d7c280960687a221643a46266ef968c4d70" + }, + { + "id": 500510422, + "name": "apilayer_core_validation-1.1.62.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar.asc", + "digest": "sha256:1e39ff48d4cd87231d931515968317c717a6811da84585650f0623c49329ec41" + }, + { + "id": 500510432, + "name": "apilayer_core_validation-1.1.62.pom", + "size": 1455, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom", + "digest": "sha256:1917e5cee9b979c9032c7819c386ea62e4498c30ffbbcf0c25578ebcd0c1e441" + }, + { + "id": 500510453, + "name": "apilayer_core_validation-1.1.62.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom.asc", + "digest": "sha256:8aed84009daa5e388b7d179ab90837e9537b4f762a1676aab922e03dc633ed18" + }, + { + "id": 497398718, + "name": "hello_xr-OpenGLES-release-1.1.62.apk", + "size": 9548745, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-OpenGLES-release-1.1.62.apk", + "digest": "sha256:da5e421795b801684cab50156c572422b73cd98fc8c75aeeb968962b43a2ab46" + }, + { + "id": 497398704, + "name": "hello_xr-Vulkan-release-1.1.62.apk", + "size": 9549137, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-Vulkan-release-1.1.62.apk", + "digest": "sha256:a154b2353983f8c0cb1827ddf51d7e80fc105085253fe524502f35c5ddac3284" + }, + { + "id": 500514717, + "name": "OpenXR-SDK-Source-release-1.1.62.tar.gz", + "size": 4834887, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz", + "digest": "sha256:977073d7f4c0d1af8ab975f57e4b6ffd1c4e9209be66075812b890576e0e1e5f" + }, + { + "id": 500514735, + "name": "OpenXR-SDK-Source-release-1.1.62.tar.gz.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz.asc", + "digest": "sha256:3ea4d6e47da6a8b3480931636af3e85eb2e0cddaf582153ee97973a8b05a5214" + }, + { + "id": 500514501, + "name": "OpenXR.Loader.1.1.62.nupkg", + "size": 1902954, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg", + "digest": "sha256:6bb16b4dbe3c11f29605def2def5b7d1177784c0403dc9a24bc2e13d7eb82604" + }, + { + "id": 500514512, + "name": "OpenXR.Loader.1.1.62.nupkg.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg.asc", + "digest": "sha256:386dfd33e9c2db9c9c37d881b77eec9b74d181fda394b47c473b2bce37fd7005" + }, + { + "id": 500510319, + "name": "openxr_loader_for_android-1.1.62-sources.jar", + "size": 1110593, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar", + "digest": "sha256:b83318394b30bb129b069dd854de2b1e21df4376dda1a7fa342aeaff17a71d3d" + }, + { + "id": 500510327, + "name": "openxr_loader_for_android-1.1.62-sources.jar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar.asc", + "digest": "sha256:838b5f5a23329eb7f0e13afde7a7d6ae73b439c7cbf6d3ec0af3e65efd7d5bd6" + }, + { + "id": 500510263, + "name": "openxr_loader_for_android-1.1.62.aar", + "size": 4158815, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar", + "digest": "sha256:c03c689fed9a48f9394af953660982c998b00f6d2d2d8d150bc3f890c75a7465" + }, + { + "id": 500510280, + "name": "openxr_loader_for_android-1.1.62.aar.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar.asc", + "digest": "sha256:883ccab775776c65ee35bf3120553f6a3f0f47de2dd661e074469e98d3caea46" + }, + { + "id": 500510292, + "name": "openxr_loader_for_android-1.1.62.pom", + "size": 1598, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom", + "digest": "sha256:9b1047158a416984fd6d60c472da09bb324aec74709f83a1516435917fa05064" + }, + { + "id": 500510305, + "name": "openxr_loader_for_android-1.1.62.pom.asc", + "size": 215, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom.asc", + "digest": "sha256:9dd7d3f79ad76a48f709f55d8c205892ae30f70b10a44c4afbd51f50603c4478" + }, + { + "id": 500514048, + "name": "openxr_loader_macos-1.1.62.zip", + "size": 817438, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip", + "digest": "sha256:400bf9ab932d04cf8a315fe8e63d5cd9824015b64ad2e5d72b2ffc086c54313c" + }, + { + "id": 500514061, + "name": "openxr_loader_macos-1.1.62.zip.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip.asc", + "digest": "sha256:034a3575bbee545926dc59558aa5d62ea9fc2de9e23c426ac640cbb68bd8db88" + }, + { + "id": 500513308, + "name": "openxr_loader_windows-1.1.62.zip", + "size": 30975472, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip", + "digest": "sha256:800ec772e2f9448a26ab9f579f4914d984346dd9d0d7c007841abe21d2c8ff2f" + }, + { + "id": 500513351, + "name": "openxr_loader_windows-1.1.62.zip.asc", + "size": 870, + "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip.asc", + "digest": "sha256:8117563bfc5092895e17112366cb954ca78f84964e5c09526dfd69656c1713fd" + } + ] + } +] diff --git a/tests/fixtures/more_sources/topic.json b/tests/fixtures/more_sources/topic.json new file mode 100644 index 0000000..2a87d01 --- /dev/null +++ b/tests/fixtures/more_sources/topic.json @@ -0,0 +1,28 @@ +{ + "items": [ + { + "full_name": "LWJGL/lwjgl3", + "name": "lwjgl3", + "description": "LWJGL is a Java library that enables cross-platform access to popular native APIs useful in the development of graphics (OpenGL, Vulkan, bgfx), audio (OpenAL, Opus), parallel computing (OpenCL, CUDA) and XR (OpenVR, LibOVR, OpenXR) applications.", + "owner": { + "avatar_url": "https://avatars.githubusercontent.com/u/2757344?v=4" + } + }, + { + "full_name": "sahibzada-allahyar/YC-Killer", + "name": "YC-Killer", + "description": "A library of enterprise-grade AI agents designed to democratize artificial intelligence and provide free, open-source alternatives to overvalued Y Combinator startups.", + "owner": { + "avatar_url": "https://avatars.githubusercontent.com/u/94376830?v=4" + } + }, + { + "full_name": "bjornbytes/lovr", + "name": "lovr", + "description": "Lua Virtual Reality Framework", + "owner": { + "avatar_url": "https://avatars.githubusercontent.com/u/784805?v=4" + } + } + ] +} diff --git a/tests/fixtures/more_sources/tux-releases.json b/tests/fixtures/more_sources/tux-releases.json new file mode 100644 index 0000000..2d482fa --- /dev/null +++ b/tests/fixtures/more_sources/tux-releases.json @@ -0,0 +1,17 @@ +[ + { + "tag_name": "Beta0.2", + "draft": false, + "prerelease": true, + "published_at": "2026-09-23T14:51:47Z", + "assets": [ + { + "id": 583977968, + "name": "SuperTux-Beta0.2-quest-pico-arm64-v8a.apk", + "size": 294152462, + "browser_download_url": "https://github.com/SgtBilko76/SuperTux-3D/releases/download/Beta0.2/SuperTux-Beta0.2-quest-pico-arm64-v8a.apk", + "digest": "sha256:63287e5d6f1866193e0d4730bf4a2ba87fd2fbdbe6317bd6bd24b96a8ddc9963" + } + ] + } +] diff --git a/tests/fixtures/sidequest-policy.json b/tests/fixtures/sidequest-policy.json new file mode 100644 index 0000000..dd08588 --- /dev/null +++ b/tests/fixtures/sidequest-policy.json @@ -0,0 +1,18 @@ +{ + "recorded": "2026-09-28", + "robots": { + "url": "https://sidequestvr.com/robots.txt", + "user_agent": "*", + "crawl_delay": 3, + "disallow": ["/search/", "/user/*", "/sideload/*"], + "sitemap": "https://sidequestvr.com/sitemap_index.xml" + }, + "api_robots": {"url": "https://api.sidequestvr.com/robots.txt", "status": 403}, + "terms": { + "url": "https://sidequestvr.com/terms", + "bundle": "https://sidequestvr.com/main-4MMXZRXL.js", + "prohibited_activities_i": "copy, distribute, or disclose any part of the Service in any medium, including without limitation by any automated or non-automated scraping", + "prohibited_activities_xi": "access any content on the Service through any technology or means other than those provided or authorized by the Service" + }, + "note": "Policy evidence, not a fabricated API response. No app metadata or download fixture was collected after discovering the restriction." +} diff --git a/tests/search_preview.py b/tests/search_preview.py new file mode 100644 index 0000000..cdd6bec --- /dev/null +++ b/tests/search_preview.py @@ -0,0 +1,58 @@ +"""Local store preview. No device access; installation progress is simulated. + +FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py +""" +import json +import os +from pathlib import Path +import sys +import tempfile +import time +from urllib.parse import urlparse +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import server +from apk_sources import _demo, _images, search + + +class Preview(server.Handler): + def do_GET(self): + path = urlparse(self.path).path + if path == '/api/android': + self.send_json({'apps': []}) + return + if path == '/api/android/reports': + self.send_json({'reports': [], 'shared': False}) + return + if path not in ('/', '/index.html', '/api/search', '/api/sources', '/api/sources/details', '/api/job', '/api/host') and not path.startswith('/source-image/'): + self.send_json({'error': 'Headset disconnected', 'offline': True}, 503) + return + super().do_GET() + + def do_POST(self): + if not self.local_request(): + return + if urlparse(self.path).path == '/api/sources/install': + body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0)))) + def work(report): + for percent in (12, 28, 43, 67, 89): + report('Downloading', percent) + time.sleep(2) + report('Installing', None) + time.sleep(3) + return {'package': 'org.preview.' + body['id'], 'message': 'Preview installation complete'} + self.send_json(server.start_job('Preview installation', work, progress=True)) + return + if urlparse(self.path).path == '/api/sources': + super().do_POST() + return + self.send_json({'error': 'Device access disabled in store preview'}, 403) + + +if __name__ == '__main__': + if os.environ.get('FRAME_APK_SEARCH_DEMO') != '1': + sys.exit('Set FRAME_APK_SEARCH_DEMO=1') + for name, url in json.loads((_demo.FIXTURES / 'artwork' / 'urls.json').read_text()).items(): + _images.remember(url, (_demo.FIXTURES / 'artwork' / name).read_bytes()) + with tempfile.TemporaryDirectory(prefix='frame-store-preview-') as tmp: + search.settings_path = lambda: Path(tmp) / 'enabled.json' + server.ThreadingHTTPServer(('127.0.0.1', 8795), Preview).serve_forever() diff --git a/tests/test_agent.py b/tests/test_agent.py index 46ec1db..931eccb 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -213,8 +213,9 @@ class ManagedBackend(unittest.TestCase): with mock.patch.object(server.frame_host, 'MUX', True), \ mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \ mock.patch.object(server.frame_host.os, 'getpid', return_value=123): - self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C') - self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C') + # Per headset (its tag), and per process for a private server. + self.assertEqual(server.frame_host.control_path('a1b2', private=False), '/tmp/frame-ui-501-a1b2-%C') + self.assertEqual(server.frame_host.control_path('a1b2', private=True), '/tmp/frame-ui-501-123-a1b2-%C') class ComputerState(unittest.TestCase): @@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase): if __name__ == '__main__': unittest.main() + + +class ScriptsFollowTheHeadset(unittest.TestCase): + """keep_awake and panel tools run scripts that ssh on their own: they must reach the + headset the server is routed to, not whatever `frame` means in ~/.ssh/config.""" + + @unittest.skipUnless(shutil.which('zsh'), 'needs zsh') + def test_scripts_get_the_routed_alias_and_options(self): + import shlex + fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui', + SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab']) + with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run: + agent.run_script(fake, 'keep-awake.sh', ['status']) + env = run.call_args.kwargs['env'] + self.assertEqual(env['FRAME_ALIAS'], 'frame-2') + self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:]) + # and the script turns that back into the same argv + out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'], + env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True) + self.assertEqual(out.stdout.splitlines(), fake.SSH[1:]) diff --git a/tests/test_apk_artwork.py b/tests/test_apk_artwork.py new file mode 100644 index 0000000..b036f90 --- /dev/null +++ b/tests/test_apk_artwork.py @@ -0,0 +1,101 @@ +import http.client +import json +from pathlib import Path +import socket +import sys +import threading +import unittest +from unittest.mock import patch, Mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +from apk_sources import _images, search, SourceError +import server + +PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').read_bytes() + + +class ArtworkTests(unittest.TestCase): + def setUp(self): + with _images._lock: + _images._urls.clear() + _images._cache.clear() + + def test_only_registered_source_images_are_fetchable(self): + with patch.object(_images, 'fetch') as fetch: + with self.assertRaisesRegex(SourceError, 'Unknown artwork'): + _images.image('https://example.com/arbitrary.png') + fetch.assert_not_called() + entry = {'images': {'icon': 'https://example.com/icon.png', 'banner': 'file:///tmp/private', + 'screenshots': ['https://example.com/shot.png', 'javascript:alert(1)']}} + art = _images.artwork(entry) + self.assertTrue(art['icon'].startswith('/source-image/')) + self.assertIsNone(art['banner']) + self.assertEqual(len(art['screenshots']), 1) + with patch.object(_images, 'fetch', return_value=(PNG, 'image/png')) as fetch: + self.assertEqual(_images.image(art['icon'].split('/')[-1]), (PNG, 'image/png')) + _images.image(art['icon'].split('/')[-1]) + fetch.assert_called_once_with('https://example.com/icon.png') + + def test_rejects_credentials_ports_and_non_http(self): + for url in ['file:///tmp/a.png', 'data:image/png;base64,AAAA', 'http://user:pass@example.com/a.png', + 'http://example.com:22/a.png', 'https://example.com:bad/a.png', '//example.com/a.png']: + self.assertIsNone(_images.register(url), url) + + def test_blocks_private_loopback_and_mixed_dns_answers(self): + for ip in ['127.0.0.1', '10.0.0.1', '169.254.169.254', '::1', '192.168.1.1']: + with patch.object(socket, 'getaddrinfo', return_value=[(2,1,6,'',(ip,443))]), \ + patch.object(socket, 'create_connection') as connect: + with self.assertRaisesRegex(SourceError, 'Private network'): + _images.fetch('https://example.com/private.png') + connect.assert_not_called() + + def test_redirect_to_private_network_is_rejected(self): + response = Mock(status=302) + response.getheader.return_value = 'http://127.0.0.1/secret' + conn = Mock() + conn.getresponse.return_value = response + public = [(2,1,6,'',('93.184.216.34',80))] + private = [(2,1,6,'',('127.0.0.1',80))] + with patch.object(socket, 'getaddrinfo', side_effect=[public,private]), \ + patch.object(socket, 'create_connection') as connect, \ + patch.object(http.client, 'HTTPConnection', return_value=conn): + with self.assertRaisesRegex(SourceError, 'Private network'): + _images.fetch('http://example.com/a.png') + connect.assert_called_once_with(('93.184.216.34',80),timeout=10) + + def test_non_images_and_oversized_images_are_rejected(self): + with self.assertRaises(SourceError): + _images.remember('https://example.com/a.svg', b'') + with self.assertRaisesRegex(SourceError, 'too large'): + _images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE) + + def test_handles_are_bounded(self): + for i in range(4100): + _images.register('https://example.com/%d.png' % i) + self.assertEqual(len(_images._urls),4096) + + def test_plain_language_verdict_is_evidence_based(self): + self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame') + self.assertEqual(search.verdict({'min_sdk':24,'abis':[]})['label'], 'Ready to try on the Frame') + self.assertEqual(search.verdict({'min_sdk':24,'abis':[],'frame_tested':True})['label'], 'Works on the Frame') + self.assertIn('newer Android', search.verdict({'min_sdk':31})['label']) + self.assertIn('Meta Quest services', search.verdict({'requires_meta_services':True})['label']) + self.assertEqual(search.verdict({'engine':'VrApi'})['tone'],'blocked') + self.assertNotEqual(search.verdict({'frame_tested':True,'min_sdk':31})['tone'],'works') + + def test_image_endpoint_does_not_allow_arbitrary_urls(self): + httpd = server.ThreadingHTTPServer(('127.0.0.1',0),server.Handler) + threading.Thread(target=httpd.serve_forever,daemon=True).start() + try: + path = _images.register('https://example.com/app.png') + _images.remember('https://example.com/app.png',PNG) + for url, expected in [(path,200),('/source-image/unknown',404)]: + c=http.client.HTTPConnection('127.0.0.1',httpd.server_port) + c.request('GET',url) + r=c.getresponse();data=r.read();c.close() + self.assertEqual(r.status,expected) + if expected==200: + self.assertEqual(data,PNG) + self.assertEqual(r.getheader('Content-Type'),'image/png') + finally: + httpd.shutdown();httpd.server_close() diff --git a/tests/test_apk_more_sources.py b/tests/test_apk_more_sources.py new file mode 100644 index 0000000..f1879b9 --- /dev/null +++ b/tests/test_apk_more_sources.py @@ -0,0 +1,235 @@ +import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +from apk_sources import SourceError, github, itch, _web + +FIX = Path(__file__).parent / 'fixtures' / 'more_sources' + + +class PublisherSources(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.cache = patch.object(_web, 'cache', return_value=self.tmp.name) + self.cache.start() + self.addCleanup(self.cache.stop) + self.network = patch('urllib.request.OpenerDirector.open', side_effect=AssertionError('network in test')) + self.network.start() + self.addCleanup(self.network.stop) + _web._limited.clear() + self.addCleanup(_web._limited.clear) + self.root = Path(self.tmp.name) / 'caches' / 'apk-sources' + roots = patch.object(_web.frame_host, 'cache_dir', lambda *p: self.root.parent.joinpath(*p)) + roots.start() + self.addCleanup(roots.stop) + + def test_curated_search_is_offline(self): + self.assertEqual(github.search(github.sources()[0], 'hello')[0]['id'], 'KhronosGroup/OpenXR-SDK-Source') + self.assertEqual(github.search(github.sources()[0], '', 0), []) + + def test_curated_artwork_has_recorded_image_evidence(self): + evidence = {r['url']: r for r in json.loads((FIX / 'artwork-check.json').read_text())} + entries = github.search(github.sources()[0], '') + self.assertEqual(len(entries), 4) + for entry in entries: + self.assertTrue(entry['summary']) + images = entry['images'] + self.assertEqual(entry['icon'], images['icon']) + for url in [u for u in [images['icon'], images['banner']] if u] + images['screenshots']: + self.assertTrue(url.startswith('https://')) + self.assertEqual(evidence[url]['status'], 200) + self.assertTrue(evidence[url]['image']) + self.assertTrue(entries[1]['images']['screenshots']) + self.assertTrue(entries[2]['images']['screenshots']) + + def test_topic_keeps_curated_artwork(self): + curated = github._curated()[1] + repo = {'full_name': curated['repo'], 'name': 'open-brush', + 'owner': {'avatar_url': 'https://avatars.githubusercontent.com/u/1'}} + with patch.object(github, '_api', return_value={'items': [repo]}): + entry = github.search(github.sources()[0], 'topic:openxr')[0] + self.assertEqual(entry['images'], curated['images']) + unknown = github.details(github.sources()[0], 'unknown/project') + self.assertEqual(unknown['icon'], 'https://github.com/unknown.png') + self.assertIsNone(unknown['images']['banner']) + + def test_real_releases(self): + for key, repo in [('khronos', 'KhronosGroup/OpenXR-SDK-Source'), + ('brush', 'icosa-foundation/open-brush'), ('tux', 'SgtBilko76/SuperTux-3D')]: + with patch.object(github, '_api', return_value=json.loads((FIX / (key + '-releases.json')).read_text())): + e = github.details(github.sources()[0], repo) + self.assertTrue(e['downloadable']) + self.assertTrue(e['versions'][0]['name'].endswith('.apk')) + self.assertIsNone(e['version_code']) + search_entry = github.search(github.sources()[0], repo)[0] + self.assertEqual(e['images'], search_entry['images']) + self.assertEqual(e['icon'], e['images']['icon']) + with self.assertRaises(SourceError): + github.download(github.sources()[0], repo, 123) + + def test_topic_results_need_approval(self): + data = json.loads((FIX / 'topic.json').read_text()) + with patch.object(github, '_api', return_value=data): + entries = github.search(github.sources()[0], 'topic:openxr') + self.assertTrue(entries) + self.assertTrue(any(not e['downloadable'] for e in entries)) + for entry, repo in zip(entries, data['items']): + self.assertEqual(entry['icon'], repo['owner']['avatar_url']) + self.assertEqual(entry['images']['icon'], entry['icon']) + self.assertIsNone(entry['images']['banner']) + self.assertEqual(entry['images']['screenshots'], []) + self.assertFalse(github.details(github.sources()[0], 'unknown/project')['downloadable']) + with self.assertRaises(SourceError): + github.search(github.sources()[0], 'topic:piracy') + + def test_feed_free_android_only_and_deduplicated(self): + with patch.object(_web, 'read', return_value=(FIX / 'itch-feed.txt').read_bytes()): + entries = itch.search(itch.sources()[0], '') + self.assertEqual(len(entries), 9) + e = itch.details(itch.sources()[0], entries[0]['id']) + self.assertTrue(e['vr']) + self.assertTrue(e['images']['banner']) + for item in entries: + self.assertEqual(item['icon'], item['images']['icon']) + self.assertEqual(item['icon'], item['images']['banner']) + self.assertEqual(item['images']['screenshots'], []) + self.assertFalse(e['downloadable']) + self.assertEqual(itch.search(itch.sources()[0], 'off nominal')[0]['name'], 'Off Nominal') + with self.assertRaises(SourceError): + itch.download(itch.sources()[0], entries[0]['id']) + with self.assertRaises(SourceError): + itch._parse(itch.sources()[0], b'not xml') + + def test_paid_and_unsafe_feed(self): + raw = (FIX / 'itch-feed.txt').read_bytes().replace(b'$0.00', b'$1.00') + self.assertEqual(itch._parse(itch.sources()[0], raw), []) + raw = (FIX / 'itch-feed.txt').read_bytes().replace(b'https://absyo.itch.io', b'http://localhost') + self.assertFalse(any(e['name'] == 'Off Nominal' for e in itch._parse(itch.sources()[0], raw))) + + def test_download_hash_and_cleanup(self): + b = io.BytesIO() + with zipfile.ZipFile(b, 'w') as z: + z.writestr('AndroidManifest.xml', b'fixture') + raw = b.getvalue() + digest = hashlib.sha256(raw).hexdigest() + with patch.object(_web, 'open_url', return_value=io.BytesIO(raw)): + result = _web.apk('https://github.com/owner/repo/file.apk', github.HOSTS, digest) + self.assertTrue(result['verified']) + self.assertEqual(Path(result['apk']).read_bytes(), raw) + with patch.object(_web, 'open_url', return_value=io.BytesIO(raw)): + self.assertFalse(_web.apk('https://github.com/file.apk', github.HOSTS)['verified']) + for content, expected in [(raw, '0' * 64), (b'html challenge', None)]: + with patch.object(_web, 'open_url', return_value=io.BytesIO(content)), self.assertRaises(SourceError): + _web.apk('https://github.com/file.apk', github.HOSTS, expected) + self.assertFalse(list(Path(self.tmp.name).glob('*.part'))) + + def test_origin_and_redirect(self): + for url in ['http://github.com/x', 'https://evil.test/x', 'https://user@github.com/x']: + with self.assertRaises(SourceError): + _web.checked_url(url, github.HOSTS) + req = urllib.request.Request('https://api.github.com/x', headers={'Authorization': 'Bearer secret'}) + handler = _web.Redirect(('api.github.com', 'github.com')) + redirected = handler.redirect_request(req, None, 302, '', {}, 'https://github.com/x') + self.assertFalse(redirected.has_header('Authorization')) + with self.assertRaises(SourceError): + handler.redirect_request(req, None, 302, '', {}, 'https://evil.test/x') + + def test_cache_rate_limit_and_invalid_json(self): + with patch.object(_web, 'open_url', return_value=io.BytesIO(b'index')) as op: + self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index') + self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index') + self.assertEqual(op.call_count, 1) + error = urllib.error.HTTPError('https://api.github.com/x', 403, 'limited', {}, None) + with patch.object(_web, 'open_url', side_effect=error), patch.dict(os.environ, {'FRAME_GITHUB_TOKEN': ''}), \ + self.assertRaisesRegex(SourceError, 'FRAME_GITHUB_TOKEN'): + github._api('/x') + with patch.object(_web, 'open_url', side_effect=error), patch.object(_web.time, 'time', return_value=1e12): + self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index') # throttled: stale copy + with patch.object(_web, 'read', return_value=b''), self.assertRaises(SourceError): + github._api('/x') + + def test_prune_caps_apks_by_age_and_removes_orphans(self): + now = 1e9 + self.root.mkdir(parents=True) + def make(folder, name, size, age): + path = Path(folder) / name + path.mkdir() if size is None else path.write_bytes(b'x' * size) + os.utime(str(path), (now - age, now - age)) + return path + pub = self.tmp.name + oldest = make(self.root, 'a.apk', 40, 9000) + old = make(pub, 'b.apk', 40, 8000) + kept = make(self.root, 'c.apk', 40, 7200) + recent = make(pub, 'd.apk', 40, 60) # just downloaded: never pruned + orphan, busy = make(self.root, 'x.part', 5, 90000), make(pub, 'y.part', 5, 60) + listing, fresh = make(pub, 'l.data', 5, 8 * 86400), make(pub, 'm.data', 5, 3600) + tmpdir = make(self.root, 'tmpabc', None, 90000) + with patch.object(_web, 'APK_CAP', 100), patch.object(_web.time, 'time', return_value=now): + _web.prune() + self.assertEqual([p.exists() for p in (oldest, old, kept, recent)], [False, False, True, True]) + self.assertEqual([p.exists() for p in (orphan, busy, listing, fresh, tmpdir)], [False, True, False, True, False]) + + def test_prune_rechecks_before_deleting_and_spares_apks_in_use(self): + self.root.mkdir(parents=True) + old = time.time() - 7200 + reused, claimed, stale = self.root / 'a.apk', self.root / 'b.apk', self.root / 'c.apk' + for path in (reused, claimed, stale): + path.write_bytes(b'x' * 40) + _web.claim(claimed) + self.addCleanup(_web.release, claimed) + for path in (reused, claimed, stale): + os.utime(str(path), (old, old)) + real = os.listdir + def listdir(folder): + if folder == self.tmp.name: # scanning the second folder: a download reuses a.apk meanwhile + self.assertTrue(_web.touch(reused)) + return real(folder) + with patch.object(_web, 'APK_CAP', 10), patch.object(_web.os, 'listdir', listdir): + _web.prune() + self.assertEqual([p.exists() for p in (reused, claimed, stale)], [True, True, False]) + _web.release(claimed) + with patch.object(_web, 'APK_CAP', 10): + _web.prune() + self.assertFalse(claimed.exists()) + self.assertFalse(_web.touch(stale)) # a pruned APK is reported gone, so it's downloaded again + + def test_backoff_honours_retry_after_per_host(self): + from apk_sources import SourceLimited + from email.utils import formatdate + now = [1e9] + clock = patch.object(_web.time, 'time', side_effect=lambda: now[0]) + clock.start() + self.addCleanup(clock.stop) + error = urllib.error.HTTPError('https://itch.io/a', 429, 'slow down', {'Retry-After': '120'}, None) + with patch.object(_web, 'open_url', side_effect=error) as op: + with self.assertRaisesRegex(SourceLimited, '^itch.io is limiting requests; try again in 2 minutes$'): + itch.search(itch.sources()[0], '') + with self.assertRaises(SourceLimited) as caught: # other URLs on the host wait too + _web.read('https://itch.io/b', ('itch.io',), name='itch.io') + self.assertEqual(op.call_count, 1) + self.assertAlmostEqual(caught.exception.retry_after, 120) + with self.assertRaises(SourceLimited): + _web.apk('https://itch.io/c.apk', ('itch.io',)) + self.assertEqual(op.call_count, 1) + with patch.object(_web, 'open_url', return_value=io.BytesIO(b'fresh')): + self.assertEqual(_web.read('https://api.github.com/x', ('api.github.com',)), b'fresh') # other hosts unaffected + now[0] += 121 + with patch.object(_web, 'open_url', return_value=io.BytesIO(b'feed')): + self.assertEqual(_web.read('https://itch.io/b', ('itch.io',)), b'feed') + cases = [({}, 600), ({'Retry-After': formatdate(now[0] + 300, usegmt=True)}, 300), + ({'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(now[0]) + 60)}, 60)] + for headers, expected in cases: + with self.subTest(headers=headers): + _web._limited.clear() + self.assertAlmostEqual(_web.throttle('https://h.test/x', headers), expected, delta=1) + self.assertAlmostEqual(_web.wait_time('https://h.test/y'), expected, delta=1) + error = urllib.error.HTTPError('https://api.github.com/x', 403, 'limited', {}, None) + with patch.object(_web, 'open_url', side_effect=error), patch.dict(os.environ, {'FRAME_GITHUB_TOKEN': ''}), \ + self.assertRaisesRegex(SourceLimited, '^GitHub is limiting requests; try again in 10 minutes .*TOKEN'): + github._api('/y') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py new file mode 100644 index 0000000..20cd57e --- /dev/null +++ b/tests/test_apk_search.py @@ -0,0 +1,350 @@ +import http.client +import json +from pathlib import Path +import sys +import tempfile +import threading +import time +import types +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +from apk_sources import search, SourceError +import server + +ENTRIES = json.loads((Path(__file__).parent / 'fixtures/apk-search/entries.json').read_text()) + + +def fake(source_id='one', fn=None): + return types.SimpleNamespace(KIND=source_id, sources=lambda: [dict(id=source_id, name=source_id, + enabled=True, trust='official', builtin=True)], + search=fn or (lambda s, q, limit=50: [e for e in ENTRIES if e['source'] == source_id]), + details=lambda s, i: ENTRIES[0], + download=Mock(return_value={'apk': '/fake.apk', 'obb': []})) + + +class SettingsTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json') + p.start() + self.addCleanup(p.stop) + for state in (search._running, search._pending, search._status, search._game_data): + state.clear() + from apk_sources import _web + self.claims = [] + for name in ('claim', 'release'): # fake downloads aren't real files + p = patch.object(_web, name, side_effect=lambda path, name=name: self.claims.append((name, path))) + p.start() + self.addCleanup(p.stop) + + +class SearchTests(SettingsTest): + def test_group_does_not_merge_distinct_or_unknown_packages(self): + result = search.group(ENTRIES) + self.assertEqual(len(result), 3) + self.assertEqual(sorted(len(a['offers']) for a in result), [1, 2, 2]) + same_name = dict(ENTRIES[0], package=None) + self.assertEqual(len(search.group(ENTRIES[:1] + [same_name])), 2) + + def test_rank_exact_and_installable_and_verified(self): + result = search.group(ENTRIES, 'Open Brush') + self.assertEqual(result[0]['package'], 'org.brush') + self.assertEqual(result[0]['offers'][0]['source'], 'one') + self.assertEqual(result[1]['package'], 'org.other') + self.assertEqual(len(search.group(ENTRIES, vr=False)), 1) + self.assertEqual(len(search.group(ENTRIES, installable=True)), 1) + + def test_unknown_vr_counts_as_flat(self): + entries = [dict(ENTRIES[3], id='a', name='Flat', vr=False), dict(ENTRIES[3], id='b', name='Unknown', vr=None), + dict(ENTRIES[3], id='c', name='Headset', vr=True)] + self.assertEqual(sorted(a['name'] for a in search.group(entries, vr=False)), ['Flat', 'Unknown']) + self.assertEqual([a['name'] for a in search.group(entries, vr=True)], ['Headset']) + + def test_browse_puts_unknown_fit_vr_first_and_blocked_last(self): + entries = [dict(source='s', id='flat', name='Flat', package='a.flat', vr=False, min_sdk=21, abis=[]), + dict(source='s', id='vr', name='Headset', package='a.vr', vr=True), + dict(source='s', id='bad', name='Blocked', package='a.bad', vr=True, min_sdk=34, abis=[])] + self.assertEqual([a['name'] for a in search.group(entries)], ['Headset', 'Flat', 'Blocked']) + + def test_fit_unknown_and_native_free_and_vr_hints(self): + self.assertIsNone(search.fit({})['installable']) + self.assertTrue(search.fit({'min_sdk': 23, 'abis': []})['installable']) + self.assertFalse(search.fit({'min_sdk': 23, 'abis': ['x86']})['installable']) + self.assertIn('Legacy VrApi', search.fit({'engine': 'VrApi'})['reasons'][0]) + + def test_timeout_and_failure_leave_other_results(self): + release = threading.Event() + calls = [] + def slow(s, q, limit=50): + calls.append(q) + release.wait(2) + return [] + mods = [fake(), fake('slow', slow), fake('broken', Mock(side_effect=SourceError('offline')))] + try: + with patch.object(search, 'modules', return_value=(mods, [])): + started = time.monotonic() + result = search.search(timeout=.03) + self.assertLess(time.monotonic() - started, .3) + self.assertTrue(result['apps']) + self.assertEqual([s['status'] for s in result['sources']], ['ok', 'loading', 'error']) + self.assertEqual(search.search('other', timeout=.03)['sources'][1]['status'], 'loading') + search.search('newest', timeout=.03) + self.assertEqual(calls, ['']) + queued = search._pending['slow'] + release.set() + self.assertTrue(queued['event'].wait(2)) + self.assertEqual(queued['entries'], []) + self.assertEqual(calls, ['', 'newest']) # 'other' was superseded, never run + finally: + release.set() + + def test_query_arriving_as_a_search_finishes_is_not_stranded(self): + mod = fake() + source = mod.sources()[0] + arrived = [] + + class Event(threading.Event): + def set(self): + if not arrived: # a request lands just as the first search completes + arrived.append(None) + t = threading.Thread(target=lambda: arrived.append(search._launch(mod, source, 'second', 50))) + t.start() + t.join(.3) # blocks on search._lock if completion is published atomically + super().set() + with patch.object(search, 'threading', types.SimpleNamespace(Event=Event, Thread=threading.Thread)): + search._launch(mod, source, 'first', 50) + for _ in range(200): + if len(arrived) == 2: + break + time.sleep(.01) + self.assertTrue(arrived[1]['event'].wait(2)) + self.assertEqual(arrived[1]['query'], ('second', 50)) + + def test_set_enabled_does_not_hold_search_lock_in_source(self): + free = [] + def set_enabled(source_id, enabled): + t = threading.Thread(target=lambda: free.append(search._lock.acquire(timeout=1) and not search._lock.release())) + t.start() + t.join() + mod = fake() + mod.set_enabled = set_enabled + with patch.object(search, 'modules', return_value=([mod], [])): + search.set_enabled('one', False) + self.assertEqual(free, [True]) + + def test_stale_source_status(self): + mod = fake() + mod.stale = lambda source: True + with patch.object(search, 'modules', return_value=([mod], [])): + status = search.search(timeout=1)['sources'][0] + self.assertEqual((status['status'], status['stale']), ('ok', True)) + + def test_limited_source_status(self): + from apk_sources import SourceLimited + mods = [fake('busy', Mock(side_effect=SourceLimited('busy is limiting requests', 60)))] + with patch.object(search, 'modules', return_value=(mods, [])): + status = search.search(timeout=1)['sources'][0] + self.assertEqual((status['status'], status['error']), ('limited', 'busy is limiting requests')) + + def test_disable_persists_and_prevents_queries_and_installs(self): + mod = fake() + with patch.object(search, 'modules', return_value=([mod], [])): + search.set_enabled('one', False) + self.assertFalse(search.sources()[0]['enabled']) + self.assertEqual(search.search()['apps'], []) + with self.assertRaisesRegex(SourceError, 'disabled'): + search.install('one', 'brush') + + def test_install_passes_metadata_artwork_and_obb(self): + mod = fake() + mod.download.return_value.update(obb=['main.obb'], artwork={'hero': '/hero.png'}, icon_png=b'png') + def install(apk, name=None, icon_png=None, source=None, artwork=None): + self.assertEqual((apk, name, icon_png, source, artwork), + ('/fake.apk', 'Open Brush', b'png', 'one', {'hero': '/hero.png'})) + return {'package': 'org.brush'} + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install_obb', create=True) as obb: + # An actual function exposes the future signature for inspection. + with patch.object(server.frame_android, 'install', install): + result = search.install('one', 'brush', 1) + # The app's instance isn't running right after install, so game data is a follow-up step. + obb.assert_not_called() + self.assertTrue(result['game_data']) + self.assertIn('Add game data', result['message']) + obb.return_value = {'package': 'org.brush', 'obb': []} + self.assertEqual(search.add_game_data('org.brush')['message'], 'Game data added') + obb.assert_called_once_with('org.brush', ['main.obb']) + with self.assertRaisesRegex(SourceError, 'install it again'): + search.add_game_data('org.brush') + mod.download.assert_called_once_with(mod.sources()[0] | {'status': 'not searched'}, 'brush', version_code=1) + + def test_install_uses_source_image_urls_as_steam_artwork(self): + mod = fake() + plain = mod.details + mod.details = lambda source, entry_id: dict(plain(source, entry_id), images={ + 'icon': 'https://img.example/icon.png', 'banner': 'https://img.example/banner.png', + 'screenshots': ['https://img.example/1.png', None]}) + seen = {} + def install(apk, name=None, icon_png=None, source=None, artwork=None): + seen['artwork'] = artwork + return {'package': 'org.brush'} + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install', install): + search.install('one', 'brush') + self.assertEqual(seen['artwork'], {'icon': 'https://img.example/icon.png', + 'banner': 'https://img.example/banner.png', + 'screenshots': ['https://img.example/1.png']}) + + def test_discovery_and_demo_are_opt_in(self): + module = fake() + with patch.object(search.pkgutil, 'iter_modules', return_value=[types.SimpleNamespace(name='example')]), \ + patch.object(search.importlib, 'import_module', return_value=module), \ + patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}): + self.assertEqual(search.modules(), ([module], [])) + with patch.object(search.pkgutil, 'iter_modules', return_value=[]), \ + patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}): + self.assertEqual(search.modules(), ([], [])) + + def test_newest_compatible_then_official_offer(self): + first = dict(ENTRIES[0], verified=False, trust='community') + newer = dict(first, source='new', version_code=2, updated='2026-01-01') + official = dict(newer, source='official', trust='official') + incompatible = dict(newer, source='blocked', verified=True, min_sdk=40) + offers = search.group([first, incompatible, newer, official])[0]['offers'] + self.assertEqual([e['source'] for e in offers], ['official', 'new', 'one', 'blocked']) + + def test_missing_obb_support_stops_before_install(self): + mod = fake() + mod.download.return_value['obb'] = ['main.obb'] + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install') as install, \ + patch.dict(server.frame_android.__dict__): + server.frame_android.__dict__.pop('install_obb', None) + with self.assertRaisesRegex(SourceError, 'OBB'): + search.install('one', 'brush') + install.assert_not_called() + + def test_downloaded_apk_is_protected_from_pruning_while_installing(self): + mod = fake() + def install(apk, **kwargs): + self.assertEqual(self.claims, [('claim', '/fake.apk')]) + raise server.frame_android.FrameError('adb failed') + with patch.object(search, 'modules', return_value=([mod], [])), \ + patch.object(server.frame_android, 'install', install): + with self.assertRaises(server.frame_android.FrameError): + search.install('one', 'brush') + self.assertEqual(self.claims, [('claim', '/fake.apk'), ('release', '/fake.apk')]) + + def test_listing_cannot_download(self): + mod = fake() + mod.details = lambda s, i: dict(ENTRIES[0], downloadable=False) + with patch.object(search, 'modules', return_value=([mod], [])): + with self.assertRaisesRegex(SourceError, 'developer page'): + search.install('one', 'brush') + mod.download.assert_not_called() + + +class EndpointTests(SettingsTest): + def setUp(self): + super().setUp() + self.mod = fake() + p = patch.object(search, 'modules', return_value=([self.mod], [])) + p.start() + self.addCleanup(p.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() + result = r.status, json.loads(r.read()) + c.close() + return result + + def test_http_search_and_validation(self): + self.assertEqual(self.request('GET', '/api/sources')[1]['sources'][0]['id'], 'one') + self.assertTrue(self.request('GET', '/api/search?q=Brush&vr=true')[1]['apps']) + self.assertEqual(self.request('GET', '/api/search?vr=invalid')[0], 400) + self.assertEqual(self.request('GET', '/api/search?source=missing')[0], 400) + for body in ({'source': 'one'}, {'source': 'one', 'id': 'brush', 'version_code': True}): + self.assertEqual(self.request('POST', '/api/sources/install', body)[0], 400) + + def test_http_install_background_job(self): + with patch.object(server.frame_android, 'install', return_value={'package': 'org.brush'}) as install: + status, reply = self.request('POST', '/api/sources/install', {'source': 'one', 'id': 'brush'}) + self.assertEqual(status, 200) + for _ in range(100): + job = self.request('GET', '/api/job?id=' + reply['job'])[1] + if job['done']: + break + time.sleep(.01) + self.assertTrue(job['done']) + self.assertIsNone(job['error']) + install.assert_called_once_with('/fake.apk', name='Open Brush', icon_png=None, source='one') + + def test_details_endpoint_and_real_install_stages(self): + code, entry = self.request('GET', '/api/sources/details?source=one&id=brush') + self.assertEqual(code, 200) + self.assertEqual(entry['name'], 'Open Brush') + self.assertEqual(entry['verdict']['label'], 'Ready to try on the Frame') + self.assertIn('artwork', entry) + self.assertEqual(self.request('GET', '/api/sources/details?source=one')[0], 400) + stages = [] + with patch.object(server.frame_android, 'install', return_value={'package':'org.brush'}): + search.install('one', 'brush', progress=lambda stage, percent: stages.append((stage,percent))) + self.assertEqual(stages, [('Downloading',None),('Installing',None)]) + + def test_http_repository_management(self): + self.assertEqual(self.request('POST', '/api/sources', {'action': 'enable', 'source': 'one', 'enabled': False})[0], 200) + code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'}) + self.assertEqual(code, 400) + self.assertIn('not available', reply['error']) + mod = fake('fdroid') + added = {'id': 'fdroid-user-1', 'name': 'repo.example', 'fingerprint': 'ab' * 32, 'trust_on_first_use': True} + mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(return_value=added), Mock(), Mock() + with patch.object(search, 'modules', return_value=([mod], [])): + code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'}) + self.assertEqual(code, 200) + job = self.wait(reply['job']) + self.assertEqual(job['message'], 'Added repo.example. Trusted on first use: ' + 'AB' * 32) + self.assertEqual(job['result']['source']['fingerprint'], 'ab' * 32) + mod.add_repo.assert_called_once_with(url='https://repo.example/repo', fingerprint=None, name=None) + link = 'fdroidrepos://repo.example/repo?fingerprint=' + 'ab' * 32 + mod.add_repo.return_value = dict(added, trust_on_first_use=False) + job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job']) + self.assertEqual(job['message'], 'Added repo.example') + mod.add_repo.assert_called_with(url=link, fingerprint=None, name=None) + 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/'): + 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') + + def test_http_add_game_data_job(self): + search._game_data['org.brush'] = ['/cache/main.1.org.brush.obb'] + self.addCleanup(search._game_data.clear) + with patch.object(server.frame_android, 'install_obb', create=True, + side_effect=server.frame_android.FrameError('start this app instance before installing OBB data')): + job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job']) + self.assertEqual(job['error'], 'start this app instance before installing OBB data') + with patch.object(server.frame_android, 'install_obb', create=True, return_value={'package': 'org.brush'}) as obb: + job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job']) + self.assertEqual(job['message'], 'Game data added') + obb.assert_called_once_with('org.brush', ['/cache/main.1.org.brush.obb']) + + def wait(self, job_id): + for _ in range(200): + job = self.request('GET', '/api/job?id=' + job_id)[1] + if job['done']: + return job + time.sleep(.01) + self.fail('job did not finish') diff --git a/tests/test_comfort.py b/tests/test_comfort.py new file mode 100644 index 0000000..0b3af36 --- /dev/null +++ b/tests/test_comfort.py @@ -0,0 +1,257 @@ +"""Fake-Frame session clock/actions plus real helper serialization and sensor probes.""" +import os +import shutil +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import frame_comfort as comfort +import frame_status as status + +OPTIONS = {'action': 'start', 'minutes': 3, 'breakMinutes': 1, 'stillMinutes': 1, + 'batteryAlert': True, 'heatAlert': True} + + +class SessionTests(unittest.TestCase): + def setUp(self): + self.s = comfort.new_session(OPTIONS, 0, 'boot-one') + self.warn, self.home = Mock(), Mock() + + def step(self, now, **sample): + comfort.tick(self.s, now, sample, self.warn, self.home, read_clock=lambda: now) + + def test_warning_then_home_never_closes_a_game(self): + self.step(119) + self.warn.assert_not_called() + self.step(120) + self.warn.assert_called_once() + self.step(179) + self.home.assert_not_called() + self.step(180) + self.home.assert_called_once() + self.assertFalse(self.s['active']) + self.step(181) + self.home.assert_called_once() + + def test_late_wakeup_always_gets_a_full_warning_minute(self): + self.step(400) + self.home.assert_not_called() + self.step(459) + self.home.assert_not_called() + self.step(460) + self.home.assert_called_once() + + def test_slow_warning_still_leaves_a_full_minute(self): + comfort.tick(self.s, 120, {}, self.warn, self.home, read_clock=lambda: 140) + self.assertEqual(self.s['warned'], 140) + self.step(180) + self.home.assert_not_called() + self.step(199) + self.home.assert_not_called() + self.step(200) + self.home.assert_called_once() + + def test_failed_warning_never_stops_session(self): + self.warn.side_effect = RuntimeError('offline') + with self.assertRaises(RuntimeError): + self.step(200) + self.assertIsNone(self.s['warned']) + self.home.assert_not_called() + self.warn.side_effect = None + self.step(300) + self.step(359) + self.home.assert_not_called() + self.step(360) + self.home.assert_called_once() + + def test_failed_home_stays_active_and_retries(self): + self.step(120) + self.home.side_effect = RuntimeError('Steam offline') + with self.assertRaises(RuntimeError): + self.step(180) + self.assertTrue(self.s['active']) + self.home.side_effect = None + self.step(185) + self.assertFalse(self.s['active']) + + def test_cancel_prevents_all_actions(self): + self.s['active'] = False + self.step(999, battery={'percent': 1, 'status': 'Discharging'}) + self.warn.assert_not_called() + self.home.assert_not_called() + self.assertEqual(self.s['events'], []) + + def test_breaks_and_checkin_require_measured_activity(self): + self.step(20, activity=1) + self.step(40, activity=2) + self.step(60, activity=1) + self.assertEqual([e['kind'] for e in self.s['events']], ['break', 'still']) + self.step(70, activity=1) + self.assertEqual(len(self.s['events']), 2) + self.step(75, activity=3) + self.assertEqual(self.s['used'], 0) + self.assertFalse(self.s['stillSent']) + + def test_unknown_activity_and_gaps_do_not_count_as_wear(self): + self.step(25) + self.assertEqual(self.s['used'], 0) + self.assertEqual(self.s['unavailable'], ['battery', 'temperature', 'activity']) + self.step(100, activity=1) + self.assertEqual(self.s['used'], 30) + + def test_alerts_latch_and_rearm_without_battery_chatter(self): + low = {'percent': 10, 'status': 'Discharging'} + self.step(1, battery=low, thermal=['cpu']) + self.step(2, battery=low, thermal=['cpu']) + self.step(3) + self.assertEqual(len(self.s['events']), 2) + self.step(4, battery={'percent': 16, 'status': 'Discharging'}, thermal=[]) + self.step(5, battery=low, thermal=[]) + self.assertEqual(len(self.s['events']), 2) + self.step(6, battery={'percent': 22, 'status': 'Discharging'}, thermal=[]) + self.step(7, battery=low, thermal=['cpu']) + self.assertEqual([e['kind'] for e in self.s['events']], ['battery', 'heat', 'battery', 'heat']) + + def test_disabled_alerts_and_charging(self): + self.s['options']['heatAlert'] = False + self.step(1, battery={'percent': 2, 'status': 'Charging'}, thermal=['cpu']) + self.assertEqual(self.s['events'], []) + + def test_invalid_options(self): + for key, value in [('minutes', 0), ('minutes', 241), ('minutes', True), ('minutes', 2.5), + ('breakMinutes', -1), ('stillMinutes', '1'), ('heatAlert', 1)]: + with self.subTest(key=key, value=value), self.assertRaises(ValueError): + comfort.validate({**OPTIONS, key: value}) + for value in (None, [], {'action': 'shutdown'}): + with self.assertRaises(ValueError): + comfort.validate(value) + + def test_restart_invalidates_session_and_stale_worker_is_explicit(self): + with patch.object(comfort, 'boot', return_value='boot-one'), patch.object(comfort.time, 'time', return_value=999): + current = comfort.current(self.s, 100) + self.assertIn('not responding', current['error']) + self.assertEqual(current['time'], 999) + with patch.object(comfort, 'boot', return_value='boot-two'): + result = comfort.current(self.s, 100) + self.assertFalse(result['active']) + self.assertIn('restarted', result['error']) + + @unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock") + def test_real_state_commands_share_one_session_and_cancel(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \ + patch.object(comfort, 'boot', return_value='boot-one'), \ + patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen') as spawn: + started = comfort.command(OPTIONS) + self.assertEqual(comfort.command({'action': 'status'})['id'], started['id']) + with self.assertRaises(ValueError): + comfort.command(OPTIONS) + self.assertFalse(comfort.command({'action': 'cancel'})['active']) + spawn.assert_called_once() + self.assertEqual((Path(tmp) / 'session.json').stat().st_mode & 0o777, 0o600) + + @unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock") + def test_cancel_clears_stale_worker_error(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \ + patch.object(comfort, 'boot', return_value='boot-one'), \ + patch.object(comfort, 'clock', return_value=200): + with comfort.locked(): + comfort.save(self.s) + self.assertIn('not responding', comfort.command({'action': 'status'})['error']) + cancelled = comfort.command({'action': 'cancel'}) + self.assertFalse(cancelled['active']) + self.assertIsNone(cancelled['error']) + self.assertIsNone(comfort.command({'action': 'status'})['error']) + + @unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock") + def test_failed_spawn_leaves_session_inactive_and_retryable(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \ + patch.object(comfort, 'boot', return_value='boot-one'), \ + patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen') as spawn: + spawn.side_effect = OSError('process limit') + with self.assertRaises(OSError): + comfort.command(OPTIONS) + failed = comfort.command({'action': 'status'}) + self.assertFalse(failed['active']) + self.assertIn('Could not start', failed['error']) + spawn.side_effect = None + self.assertTrue(comfort.command(OPTIONS)['active']) + + @unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock") + def test_unreadable_state_is_preserved_and_can_be_replaced(self): + for contents in (b'{broken', b'\xff', b'null', b'[]', b'42', b'"x"'): + with self.subTest(contents=contents): + with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \ + patch.object(comfort, 'boot', return_value='boot-one'), \ + patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen'): + (Path(tmp) / 'session.json').write_bytes(contents) + failed = comfort.command({'action': 'status'}) + self.assertFalse(failed['active']) + self.assertIn('unreadable', failed['error']) + backups = list(Path(tmp).glob('session-unreadable-*.json')) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_bytes(), contents) + self.assertTrue(comfort.command(OPTIONS)['active']) + + def test_home_has_total_process_deadline_and_propagates_timeout(self): + with patch.object(comfort.subprocess, 'run', side_effect=subprocess.TimeoutExpired('home', 15)) as run: + with self.assertRaises(subprocess.TimeoutExpired): + comfort.home() + self.assertEqual(run.call_args.kwargs['timeout'], 15) + self.assertEqual(run.call_args.args[0][-1], '--home') + + def test_native_warning_reports_failures_and_quotes_as_one_argument(self): + with patch.object(comfort.subprocess, 'run') as run: + run.return_value = subprocess.CompletedProcess([], 0, 'Notification succeeded', '') + comfort.notify('Save "now"; $(nothing)') + args = run.call_args.args[0] + self.assertEqual(args, [comfort.VRCMD, '--notify', 'Frame Control: Save "now"; $(nothing)']) + run.return_value.stdout = 'Notification failed with error 1' + with self.assertRaises(RuntimeError): + comfort.notify('test') + + @unittest.skipUnless(shutil.which("node"), "Node exercises the fake Steam JS context") + def test_home_javascript_against_fake_steam_preserves_game(self): + # Same JS runs in Steam CDP. This fake records navigation and refuses any + # unexpected API call; it offers no shutdown or terminate-game primitive. + js = '''let running = [123], path = '/routes/library/app/123', visible = false; +const location = {get pathname() {return path;}}; +const SteamUIStore = {Navigate(p) {path = '/routes' + p;}}; +const SteamClient = {OpenVR: {VROverlay: { + async ShowDashboard(key) {if (key !== 'valve.steam.gamepadui.main') throw Error(key); visible = true;}, + async IsDashboardVisible() {return visible;} +}}}; +''' + js += comfort.HOME_JS + '.then(result => console.log(JSON.stringify({result, running, visible})));' + r = subprocess.run(['node', '-e', js], capture_output=True, text=True, check=True) + result = json.loads(r.stdout) + self.assertEqual(result['running'], [123]) + self.assertTrue(result['visible']) + self.assertEqual(result['result']['path'], '/routes/library/home') + + +class SensorTests(unittest.TestCase): + def test_hot_trip_uses_its_own_zone_not_hottest_unrelated_chip(self): + values = {'/z/a/temp': '90000', '/z/a/trip_point_0_type': 'hot', '/z/a/trip_point_0_temp': '110000', + '/z/b/temp': '45000', '/z/b/trip_point_0_type': 'hot', '/z/b/trip_point_0_temp': '44000', '/z/b/type': 'battery'} + def glob(pattern): + if pattern.endswith('thermal_zone*'): + return ['/z/a', '/z/b'] + return [pattern.replace('*', '0')] + with patch.object(status.glob, 'glob', side_effect=glob), patch.object(status, 'read', side_effect=values.get): + self.assertEqual(status.thermal_alerts(), [{'zone': 'battery', 'tempC': 45, 'limitC': 44}]) + + def test_missing_thermal_and_activity_are_unknown(self): + with patch.object(status.glob, 'glob', return_value=[]): + self.assertIsNone(status.thermal_alerts()) + with patch.object(status, 'run', return_value='unavailable'): + self.assertIsNone(status.activity_level()) + for malformed in ('{}', '[null, 42, "bad"]'): + with patch.object(status, 'run', return_value=malformed): + self.assertIsNone(status.activity_level()) + with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'): + self.assertEqual(status.activity_level(), 3) diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py new file mode 100644 index 0000000..2d75b36 --- /dev/null +++ b/tests/test_comfort_ui.py @@ -0,0 +1,60 @@ +"""Run the actual shared page's comfort renderer against a minimal DOM/bridge.""" +import pathlib +import shutil +import subprocess +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +@unittest.skipUnless(shutil.which('node'), 'Node exercises the shared page JS') +class ComfortUI(unittest.TestCase): + def test_notification_failure_survives_poll_until_success(self): + 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 => { + if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, type: 'number'}); + return elements.get(id); +}; +let denied = 0; +const window = {frameApp:{notify:async()=>{denied++;throw Error('permission denied');}}}; +const log = ()=>{}, toast = ()=>{}; +''' + checks = r''' +(async()=>{ + const active = {id:'session-one',active:true,time:100,remaining:120, + options:{minutes:2,breakMinutes:1,stillMinutes:1,batteryAlert:true,heatAlert:true}, + events:[{id:'event-one',kind:'battery',time:99,message:'Low battery'}]}; + renderComfort(active); // initial history must not replay even a fresh event + assert.equal(denied,0); + assert.equal($('comfortAnnouncement').textContent,''); + active.events.push({id:'event-two',kind:'break',time:100,message:'Take a break'}); + renderComfort(active); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(denied,1); + assert.equal($('comfortAnnouncement').textContent,'Take a break'); + assert.equal($('comfortNotificationStatus').hidden,false); + assert.match($('comfortNotificationStatus').textContent,/notification settings/); + renderComfort({...active,time:105}); // the next normal poll must not erase failure + assert.equal($('comfortNotificationStatus').hidden,false); + assert.equal($('sessionStart').disabled,true); + assert.equal($('sessionMinutes').disabled,true); + assert.equal($('sessionCancel').disabled,false); + let requests=0; + window.frameApp.notify=async()=>{requests++;}; + renderComfort({...active,time:106}); + assert.equal($('comfortAnnouncement').textContent,'Take a break'); + assert.equal(requests,0); // polling does not replay an already-seen event + await localNotification('test',true); + assert.equal($('comfortNotificationStatus').hidden,true); + renderComfort({...active,active:false}); + assert.equal($('sessionStart').disabled,false); + 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) diff --git a/tests/test_contact.py b/tests/test_contact.py new file mode 100644 index 0000000..8c2234a --- /dev/null +++ b/tests/test_contact.py @@ -0,0 +1,280 @@ +"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately, +withdrawn when removed, never lost offline, and the one-time prompt stays dismissed. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import sys +import threading +import time +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import frame_compat_db as db # noqa: E402 +import frame_contact as fc # noqa: E402 +import frame_report as fr # noqa: E402 +import frame_telemetry as tm # noqa: E402 +from test_telemetry import Base, ReportProblem # noqa: E402 + +REPORT = {"title": "RDP not working", "message": "It never connects on Windows."} + + +class Contact(Base): + """Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file.""" + serve = ReportProblem.serve + + def setUp(self): + super().setUp() + self.addCleanup(fc._removed.clear) + for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")): + p = mock.patch.object(fc, name, value) + p.start() + self.addCleanup(p.stop) + self.got = self.serve() + + def events(self): + return [body["batch"][0] for _, body in self.got] + + def offline(self): + return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog")) + + # ---- storage and consent flags + + def test_nothing_is_kept_or_sent_until_chosen(self): + s = fc.state() + self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False)) + self.assertFalse(fc.FILE.exists()) + self.assertEqual(self.got, []) + + def test_an_address_needs_a_choice_and_a_real_address(self): + with self.assertRaisesRegex(ValueError, "tick"): + fc.save({"email": "me@example.com"}) + with self.assertRaisesRegex(ValueError, "email address"): + fc.save({"email": "not an address", "updates": True}) + self.assertEqual(fc.load()["email"], "") + self.assertEqual(self.got, []) + + def test_each_choice_is_sent_privately_on_its_own(self): + fc.save({"email": " me@example.com ", "updates": True}) + fc.save({"email": "me@example.com", "updates": False, "followup": True}) + first, second = self.events() + self.assertEqual(first["event"], "contact_consent") + self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")}, + {"email": "me@example.com", "updates": True, "followup": False, "action": "set"}) + self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True)) + self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins + self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id + self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]), + (False, True)) + self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2) + + def test_sent_whatever_the_analytics_settings(self): + tm.update_settings({"usage": False}) + fc.save({"email": "me@example.com", "followup": True}) + self.assertEqual(len(self.got), 1) + + def test_saving_the_same_choice_again_sends_nothing(self): + fc.save({"email": "me@example.com", "updates": True}) + fc.save({"email": "me@example.com", "updates": True}) + self.assertEqual(len(self.got), 1) + + # ---- withdrawal + + def test_removing_the_address_sends_a_withdrawal_without_it(self): + fc.save({"email": "me@example.com", "updates": True, "followup": True}) + s = fc.save({"email": "", "updates": True, "followup": True}) + self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False)) + withdrawal = self.events()[-1]["properties"] + self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]), + ("withdraw", "", False, False)) + self.assertNotIn("me@example.com", fc.FILE.read_text()) + + def test_an_address_still_waiting_is_withdrawn_too(self): + with self.offline(): + fc.save({"email": "me@example.com", "updates": True}) # may already be on its way + with mock.patch.object(tm, "post") as post: + fc.save({"email": ""}) + self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"]) + self.assertFalse(fc.state()["waiting"]) + + def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self): + fc.save({"email": "me@example.com", "updates": True}) + with self.offline(): + s = fc.save({"email": ""}) + self.assertTrue(s["waiting"]) + self.assertFalse(fc._send_pending()) + self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw") + self.assertTrue(fc._send_pending()) + self.assertFalse(fc.state()["waiting"]) + self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"]) + + def test_removing_the_address_wipes_it_from_the_sent_log_too(self): + fc.save({"email": "me@example.com", "followup": True}) + fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) + self.assertIn("me@example.com", tm.SENT.read_text()) + fc.save({"email": ""}) + self.assertNotIn("me@example.com", tm.SENT.read_text()) + self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT) + if e["event"] == "contact_consent"], ["set", "withdraw"]) + + def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self): + fc.save({"email": "me@example.com", "updates": True}) + fc.save({"email": "new@example.com", "updates": True}) + fc.save({"email": ""}) + self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3]) + + def test_a_withdrawal_during_a_send_goes_after_it(self): + started, release, order = threading.Event(), threading.Event(), [] + real = tm.post + + def slow(batch, timeout=20): + order.append(batch[0]["properties"]["action"]) + if len(order) == 1: + started.set() + release.wait(5) + real(batch, timeout) + + with mock.patch.object(tm, "post", side_effect=slow): + t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},)) + t.start() + self.assertTrue(started.wait(5)) + w = threading.Thread(target=fc.save, args=({"email": ""},)) + w.start() + for _ in range(500): # the withdrawal is saved while the first send is still out + if fc.load()["rev"] == 2: + break + time.sleep(0.01) + self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw") + release.set() + t.join(5) + w.join(5) + self.assertEqual(order, ["set", "withdraw"]) + self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"]) + self.assertFalse(fc.state()["waiting"]) + self.assertNotIn("me@example.com", tm.SENT.read_text()) + + def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self): + fc.save({"email": "me@example.com", "followup": True}) + real = tm.post + + def remove_meanwhile(batch, timeout=20): + real(batch, timeout) + fc.save({"email": ""}) # removed while the report is on its way, before it's logged + + with mock.patch.object(tm, "post", side_effect=remove_meanwhile): + fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) + self.assertNotIn("me@example.com", tm.SENT.read_text()) + fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) + self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent + + def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self): + fc._removed["me@example.com"] = 1790000000.3 + event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}} + before, after = event(), event() # the same whole second as the removal + fc.redact_removed(before, 1790000000.1) + fc.redact_removed(after, 1790000000.6) + self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]), + ("", "me@example.com")) + + def test_saving_during_a_slow_send_returns_at_once(self): + busy = fc._send_lock + busy.acquire() + try: + s = fc.save({"email": "me@example.com", "updates": True}) + finally: + busy.release() + self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take + self.assertEqual(self.got, []) + self.assertTrue(fc._send_pending()) + self.assertEqual(len(self.got), 1) + + def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self): + real = fc._send_lock + + class Lock: # a Save lands after the sender found nothing waiting, before it lets go + saved = False + + def acquire(self, blocking=True): + return real.acquire(blocking) + + def release(self): + if not Lock.saved: + Lock.saved = True + s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},)) + s.start() + s.join(5) + assert not s.is_alive() # the change is saved while the sender still holds the lock + real.release() + + with mock.patch.object(fc, "_send_lock", Lock()): + self.assertTrue(fc._send_pending()) + self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"]) + self.assertFalse(fc.state()["waiting"]) + + # ---- the one-time prompt + + def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self): + self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet + tm.frame_seen("20260901.1", "3.8") + self.assertTrue(fc.state()["showPrompt"]) + fc.prompt({"prompt": "dismissed"}) + fc.prompt({"prompt": "shown"}) # a later session can't bring it back + self.assertEqual(fc.load()["prompt"], "dismissed") + self.assertFalse(fc.state()["showPrompt"]) + self.assertEqual(self.got, []) # No thanks sends nothing + with self.assertRaises(ValueError): + fc.prompt({"prompt": "reset"}) + + def test_the_prompt_is_shown_once_and_saving_answers_it(self): + tm.frame_seen("20260901.1", "3.8") + fc.prompt({"prompt": "shown"}) + self.assertFalse(fc.state()["showPrompt"]) + fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True}) + self.assertEqual(fc.load()["prompt"], "answered") + + # ---- reports and the maintainer's list + + def test_a_report_carries_the_address_only_with_follow_up_consent(self): + fr.send({**REPORT, "contact": "me@example.com"}) + fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True}) + without, with_ = (e["properties"] for e in self.events()) + self.assertEqual((without["contact"], without["contact_followup"]), ("", False)) + self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True)) + with self.assertRaisesRegex(ValueError, "email address"): + fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True}) + + def test_contacts_lists_the_newest_choice_per_copy_by_consent(self): + rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"], + ["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"], + ["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn + ["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]] + with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q: + found = fr.contacts() + self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))", + q.call_args.args[0]) + self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")], + "followup": [("both@example.com", "2026-09-01")]}) + with mock.patch.object(fr, "contacts", return_value=found), \ + mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \ + mock.patch("builtins.print") as out: + fr.main() + printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args) + self.assertIn("both@example.com", printed) + self.assertNotIn("news@example.com", printed) + + def test_the_page_can_reach_it(self): + import server + self.assertIs(server.POST["/api/contact"], fc.save) + self.assertIs(server.POST["/api/contact/prompt"], fc.prompt) + + +# Run these once, in test_telemetry, not again through the import above. +del Base, ReportProblem + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_devices.py b/tests/test_devices.py new file mode 100644 index 0000000..31f3c61 --- /dev/null +++ b/tests/test_devices.py @@ -0,0 +1,400 @@ +"""frame_devices: the headset registry, importing ~/.ssh/config, address order, pinned +host keys and input checks. Everything works in temporary folders. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_devices as fd # noqa: E402 + +CONFIG = """Host lxso1 + HostName 192.168.1.109 + +# >>> steam-frame (frame) >>> +Host frame + HostName frame.tail1234.ts.net + User steamos + IdentityFile ~/.ssh/id_ed25519_frame + IdentityFile ~/.ssh/id_rsa_frame_devkit + IdentitiesOnly yes + ServerAliveInterval 30 +Host * +# <<< steam-frame (frame) <<< +# >>> steam-frame (frame-2) >>> +Host frame-2 + HostName 192.168.1.60 + Port 2222 + User deck + IdentityFile ~/.ssh/id_ed25519_frame +Host * +# <<< steam-frame (frame-2) <<< +Host * + ServerAliveInterval 60 +""" +KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIID6kdLfZZmdTqS1snKfTESTKEYTESTKEYTESTKEYTESTKE" + + +class Base(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp(prefix="frame-devices-")) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + self.ssh = self.dir / "ssh" + self.ssh.mkdir() + (self.ssh / "config").write_text(CONFIG) + old = os.environ.get("FRAME_CONTROL_SSH_DIR") + os.environ["FRAME_CONTROL_SSH_DIR"] = str(self.ssh) + self.addCleanup(lambda: os.environ.__setitem__("FRAME_CONTROL_SSH_DIR", old) if old + else os.environ.pop("FRAME_CONTROL_SSH_DIR", None)) + self.reg = fd.Registry(self.dir / "devices.json") + + +class Validation(unittest.TestCase): + def test_hosts(self): + for good in ("frame.local", "192.168.1.40", "fd7a:115c:a1e0::1234:5678", "fe80::1%en0", "frame-2.tail1234.ts.net"): + self.assertEqual(fd.check_host(good), good) + for bad in ("", " ", "-oProxyCommand=sh", "a b", "frame;id", "frame\nHost *", "frame..local", "$(id)", + "frame%en0", "x" * 300, None, 5, "frame/../x"): + with self.assertRaises(fd.DeviceError, msg=repr(bad)): + fd.check_host(bad) + + def test_names(self): + self.assertEqual(fd.check_alias("frame-2"), "frame-2") + for bad in ("", "-F", "frame 2", "frame\n", "a" * 65, None): + with self.assertRaises(fd.DeviceError): + fd.check_alias(bad) + with self.assertRaises(fd.DeviceError): + fd.check_user(bad) + for bad in ("0", "65536", "x", None, "22; id"): + with self.assertRaises(fd.DeviceError): + fd.check_port(bad) + self.assertEqual(fd.check_port("2222"), 2222) + with self.assertRaises(fd.DeviceError): + fd.check_text("line\nbreak", "label") + with self.assertRaises(fd.DeviceError): + fd.check_kind("wifi") + + def test_ipv6_zone_is_escaped_for_ssh(self): + self.assertEqual(fd.ssh_host("fe80::1%en0"), "fe80::1%%en0") + + +class Migration(Base): + def test_blocks_are_parsed(self): + blocks = fd.parse_blocks(CONFIG) + self.assertEqual([b["alias"] for b in blocks], ["frame", "frame-2"]) + self.assertEqual(blocks[0]["hostname"], "frame.tail1234.ts.net") + self.assertEqual(blocks[0]["identity_files"], ["~/.ssh/id_ed25519_frame", "~/.ssh/id_rsa_frame_devkit"]) + self.assertEqual((blocks[1]["port"], blocks[1]["user"]), (2222, "deck")) + + def test_existing_headsets_are_imported_once(self): + self.assertTrue(self.reg.sync_from_config(seed=False)) + devices = self.reg.devices() + self.assertEqual([d["alias"] for d in devices], ["frame", "frame-2"]) + frame, second = devices + self.assertEqual(frame["name"], "Steam Frame") + self.assertEqual(frame["addresses"][0]["host"], "frame.tail1234.ts.net") + self.assertEqual(frame["addresses"][0]["kind"], "tailscale") + self.assertEqual((second["user"], second["port"]), ("deck", 2222)) + self.assertEqual(self.reg.active(), frame["id"]) + self.assertFalse(self.reg.sync_from_config(seed=False)) # nothing new + # It's all on disk, in the documented shape. + data = json.loads((self.dir / "devices.json").read_text()) + self.assertEqual(data["version"], 1) + self.assertEqual(len(data["devices"]), 2) + self.assertEqual(fd.Registry(self.dir / "devices.json").devices(), self.reg.devices()) + + def test_first_import_keeps_using_frame(self): + # Set Up Connection puts each new block first; the app used `frame` before. + blocks = CONFIG.split("# >>> steam-frame (frame-2) >>>") + head, first = blocks[0].split("# >>> steam-frame (frame) >>>") + second, tail = blocks[1].split("# <<< steam-frame (frame-2) <<<") + (self.ssh / "config").write_text(head + "# >>> steam-frame (frame-2) >>>" + second + "# <<< steam-frame (frame-2) <<<\n" + + "# >>> steam-frame (frame) >>>" + first + tail) + self.reg.sync_from_config(seed=False) + self.assertEqual([d["alias"] for d in self.reg.devices()], ["frame-2", "frame"]) + self.assertEqual(self.reg.active(), self.reg.by_alias("frame")["id"]) + + @unittest.skipUnless(shutil.which("ssh"), "needs ssh") + def test_a_port_inherited_from_another_host_entry_is_kept(self): + (self.ssh / "config").write_text(CONFIG.replace("Host *\n ServerAliveInterval 60", "Host *\n Port 2222")) + self.reg.sync_from_config(seed=False) + self.assertEqual(self.reg.by_alias("frame")["port"], 2222) # what ssh itself would use + self.assertEqual(self.reg.by_alias("frame-2")["port"], 2222) # its own Port line + # Saving 22 must then say so in the block, or ssh would go on inheriting 2222. + self.assertTrue(fd.rewrite_block("frame", port=22)) + self.assertEqual(fd.effective_port("frame", self.ssh / "config"), 22) + self.assertFalse(fd.rewrite_block("frame", port=22)) # and only once + + def test_setup_finding_a_new_address_adds_it(self): + self.reg.sync_from_config(seed=False) + (self.ssh / "config").write_text(CONFIG.replace("HostName frame.tail1234.ts.net", "HostName 192.168.1.237")) + self.assertTrue(self.reg.sync_from_config(seed=False)) + hosts = [a["host"] for a in self.reg.by_alias("frame")["addresses"]] + self.assertEqual(hosts, ["192.168.1.237", "frame.tail1234.ts.net"]) # the new one first + + def test_setup_changing_the_login_updates_the_headset(self): + self.reg.sync_from_config(seed=False) + (self.ssh / "config").write_text(CONFIG.replace(" User steamos\n", " User deck\n Port 2200\n", 1)) + self.assertTrue(self.reg.sync_from_config(seed=False)) + d = self.reg.by_alias("frame") + self.assertEqual((d["user"], d["port"]), ("deck", 2200)) + + def test_removed_headset_stays_removed_until_setup_changes_it(self): + self.reg.sync_from_config(seed=False) + second = self.reg.by_alias("frame-2") + self.reg.remove_device(second["id"]) + self.reg.sync_from_config(seed=False) + self.assertIsNone(self.reg.by_alias("frame-2")) + self.reg.undismiss("frame-2") # Set Up Connection run for it from the Devices tab + self.reg.sync_from_config(seed=False) + self.assertIsNotNone(self.reg.by_alias("frame-2")) + + def test_corrupt_registry_is_ignored(self): + (self.dir / "bad.json").write_text("{not json") + self.assertEqual(fd.Registry(self.dir / "bad.json").devices(), []) + (self.dir / "evil.json").write_text(json.dumps({"devices": [ + {"id": "x1", "alias": "-oProxyCommand=id", "addresses": []}, + {"id": "x2", "alias": "ok", "addresses": [{"host": "a b", "kind": "lan"}, {"host": "frame.local", "kind": "mdns"}]}]})) + devices = fd.Registry(self.dir / "evil.json").devices() + self.assertEqual([d["alias"] for d in devices], ["ok"]) + self.assertEqual([a["host"] for a in devices[0]["addresses"]], ["frame.local"]) + + + +class SharedFile(Base): + """The desktop app and a standalone server can share devices.json.""" + + def test_one_server_never_saves_over_anothers_change(self): + self.reg.sync_from_config(seed=False) + other = fd.Registry(self.dir / "devices.json") # a second server, loaded now + d = self.reg.by_alias("frame") + self.reg.add_address(d["id"], "100.101.1.2", "tailscale") + other.record_success(d["id"], d["addresses"][0]["host"], "net-1", 12) # works from its older copy + hosts = [a["host"] for a in fd.Registry(self.dir / "devices.json").get(d["id"])["addresses"]] + self.assertIn("100.101.1.2", hosts) + self.assertIn("100.101.1.2", [a["host"] for a in other.get(d["id"])["addresses"]]) # and it sees it + + def test_another_servers_choice_of_headset_doesnt_move_this_one(self): + self.reg.sync_from_config(seed=False) + other = fd.Registry(self.dir / "devices.json") + mine, theirs = self.reg.by_alias("frame")["id"], self.reg.by_alias("frame-2")["id"] + self.reg.set_active(mine) + other.set_active(theirs) + self.assertEqual(self.reg.active(), mine) # after a refresh + self.reg.add_address(mine, "192.0.2.9") # and after a change reloads the file + self.assertEqual(self.reg.active(), mine) + self.assertEqual(fd.Registry(self.dir / "devices.json").active(), mine) # last to save: next start + +class ConfigRewrite(Base): + def test_hostname_user_and_port_change_only_inside_the_block(self): + cfg = self.ssh / "config" + self.assertTrue(fd.rewrite_block("frame", hostname="192.168.1.237")) + text = cfg.read_text() + self.assertIn(" HostName 192.168.1.237\n", text) + self.assertEqual(text.replace("192.168.1.237", "frame.tail1234.ts.net"), CONFIG) # nothing else moved + self.assertFalse(fd.rewrite_block("frame", hostname="192.168.1.237")) # no change, no write + self.assertTrue(fd.rewrite_block("frame", port=2200, user="deck")) + block = fd.parse_blocks(cfg.read_text())[0] + self.assertEqual((block["port"], block["user"], block["hostname"]), (2200, "deck", "192.168.1.237")) + self.assertTrue(fd.rewrite_block("frame-2", port=22)) # back to the default: said explicitly + self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22) + self.assertIn(" Port 22\n", cfg.read_text()) + self.assertIn("HostName 192.168.1.109", cfg.read_text()) # other hosts untouched + if os.name != "nt": + self.assertEqual(cfg.stat().st_mode & 0o777, 0o600) + + def test_concurrent_edits_all_land(self): + import threading + def edit(alias, prefix): + for n in range(15): + fd.rewrite_block(alias, hostname=f"{prefix}.{n}") + threads = [threading.Thread(target=edit, args=("frame", "10.0.0")), + threading.Thread(target=edit, args=("frame-2", "10.0.1"))] + for t in threads: + t.start() + for t in threads: + t.join() + blocks = fd.parse_blocks((self.ssh / "config").read_text()) + self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"]) + self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left + + def test_learning_skips_a_block_someone_changed(self): + # The connector learned an address, but Set Up Connection moved the block meanwhile. + self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9", + expect={"hostname": "old.example", "user": None, "port": None})) + self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text()) + self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9", + expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22})) + + def test_zone_is_escaped_and_read_back(self): + fd.rewrite_block("frame", hostname="fe80::1%en0") + self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text()) + self.assertEqual(fd.parse_blocks((self.ssh / "config").read_text())[0]["hostname"], "fe80::1%en0") + + def test_missing_block_is_left_alone(self): + self.assertFalse(fd.rewrite_block("frame-9", hostname="10.0.0.1")) + self.assertFalse(fd.remove_block("frame-9")) + self.assertTrue(fd.remove_block("frame-2")) + self.assertEqual([b["alias"] for b in fd.parse_blocks((self.ssh / "config").read_text())], ["frame"]) + + +@unittest.skipUnless(shutil.which("ssh-keygen"), "needs ssh-keygen") +class Pins(Base): + def test_seed_copies_the_trusted_key_under_the_device_alias(self): + (self.ssh / "known_hosts").write_text(f"frame.tail1234.ts.net {KEY}\nother.example {KEY}X\n") + self.assertFalse(fd.pinned("d1")) + self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) + self.assertTrue(fd.pinned("d1")) + self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {KEY}\n") + self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent + self.assertEqual(fd.known_hosts("d1").read_text().count("\n"), 1) + self.assertFalse(fd.seed_pin("d2", ["never-seen.example"])) + self.assertTrue(fd.forget_pin("d1")) + self.assertFalse(fd.pinned("d1")) + self.assertFalse(fd.forget_pin("d1")) + + def test_each_headset_has_its_own_file(self): + (self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n") + fd.seed_pin("da", ["a.local"]) + fd.seed_pin("db", ["b.local"]) + fd.forget_pin("da") + self.assertTrue(fd.pinned("db")) # forgetting one can't touch another + self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db")) + + def test_hashed_and_non_default_port_entries(self): + kh = self.ssh / "known_hosts" + kh.write_text(f"[frame.local]:2222 {KEY}\n") + subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True) + self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry + self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222)) + self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text()) + + def test_hashed_pins_are_found_and_forgotten(self): + target = fd.known_hosts("d4") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(f"frame-control-d4 {KEY}\n") + subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True) + self.assertNotIn("frame-control-d4", target.read_text()) + self.assertTrue(fd.pinned("d4")) + self.assertTrue(fd.forget_pin("d4")) + self.assertFalse(fd.pinned("d4")) + + def test_known_hosts_option_uses_the_override(self): + self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5")) + os.environ.pop("FRAME_CONTROL_SSH_DIR") + self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on + + +class Registry(Base): + 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") + self.assertEqual(a["kind"], "mdns") + self.reg.add_address(d["id"], "100.100.1.1", kind="tailscale", label="Tailscale") + with self.assertRaises(fd.DeviceError): + self.reg.add_address(d["id"], "frame-3.local") # already there + with self.assertRaises(fd.DeviceError): + self.reg.add_address(d["id"], "frame-3.local; id") + self.reg.move_address(d["id"], "100.100.1.1", -1) + self.reg.move_address(d["id"], "100.100.1.1", -1) + self.reg.move_address(d["id"], "100.100.1.1", -1) # already first: stays + hosts = lambda: [x["host"] for x in self.reg.get(d["id"])["addresses"]] + self.assertEqual(hosts(), ["100.100.1.1", "192.168.1.40", "frame-3.local"]) + self.reg.record_success(d["id"], "192.168.1.40", "n-home", 3.2) + self.reg.update_address(d["id"], "192.168.1.40", label="Home") + self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # a label keeps what it learned + with self.assertRaises(fd.DeviceError): + self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41", label="bad\nlabel") + self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # rejected: unchanged + self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41") + moved = self.reg.get(d["id"])["addresses"][1] + self.assertEqual((moved["host"], moved["networks"], moved["last_ok"]), ("192.168.1.41", [], None)) + self.reg.remove_address(d["id"], "192.168.1.41") + self.assertEqual(hosts(), ["100.100.1.1", "frame-3.local"]) + with self.assertRaises(fd.DeviceError): + self.reg.remove_address(d["id"], "nope") + + def test_devices(self): + a = self.reg.add_device("frame") + b = self.reg.add_device("frame-2", name="Office") + self.assertEqual(self.reg.active(), a["id"]) + with self.assertRaises(fd.DeviceError): + self.reg.add_device("frame") + self.reg.set_active(b["id"]) + self.assertEqual(self.reg.update_device(b["id"], name="Desk", user="deck", port="2222")["port"], 2222) + with self.assertRaises(fd.DeviceError): + self.reg.update_device(b["id"], user="bad user") + with self.assertRaises(fd.DeviceError): + self.reg.update_device(b["id"], user="steam", port="bad") + self.assertEqual(self.reg.get(b["id"])["user"], "deck") # a rejected edit changes nothing + self.reg.remove_device(b["id"]) + self.assertEqual(self.reg.active(), a["id"]) + self.assertFalse(self.reg.emptied()) + self.reg.remove_device(a["id"]) + self.assertTrue(self.reg.emptied()) # the connector then uses no headset at all + self.reg.add_device("frame-4") + self.assertFalse(self.reg.emptied()) + with self.assertRaises(fd.DeviceError): + self.reg.get(b["id"]) + + def test_networks_get_names(self): + net = {"id": "n-1", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "ssid": None, "wifi": True} + self.assertEqual(self.reg.network_name(net), "Wi-Fi via 192.168.1.1") + self.reg.record_network(net) + self.reg.name_network("n-1", "Home Wi-Fi") + self.assertEqual(self.reg.network_name(net), "Home Wi-Fi") + self.assertEqual(self.reg.network_name(dict(net, id="n-2", ssid="Cafe")), "Cafe") + self.assertEqual(self.reg.network_name(None), "No network") + with self.assertRaises(fd.DeviceError): + self.reg.name_network("n-unknown", "x") + + +class Order(unittest.TestCase): + def addr(self, host, kind, networks=()): + return {"host": host, "kind": kind, "networks": list(networks)} + + def test_known_here_then_mdns_then_tailscale_then_the_rest(self): + addrs = [self.addr("10.1.1.5", "lan", ["n-office"]), self.addr("192.168.1.40", "lan"), + self.addr("100.64.1.2", "tailscale"), self.addr("frame.local", "mdns"), + self.addr("192.168.1.237", "lan", ["n-home"])] + order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)] + self.assertEqual(order, ["192.168.1.237", "frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5"]) + # Tailscale off: its addresses go last. + order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", False)] + self.assertEqual(order[-1], "100.64.1.2") + # On an unknown network nothing has worked yet; the user's order breaks ties. + ranked = fd.order_addresses(addrs, None, True) + self.assertEqual([a["host"] for a, _ in ranked], ["frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5", "192.168.1.237"]) + self.assertEqual(ranked[0][1], "mDNS name") + + +if __name__ == "__main__": + unittest.main() + + +class RoutingLongLivedSsh(unittest.TestCase): + """The keyboard agent and the Mac view keep their own ssh open: switching headset + must end or retarget them, or input would go on reaching the old headset.""" + + def test_switching_headset_stops_input_and_retargets_the_mac_view(self): + import server + from unittest import mock + before = (server.FRAME, list(server.HOST_OPTS)) + self.addCleanup(lambda: server.route(*before)) + with mock.patch.object(server._input, "stop") as stop, \ + mock.patch.object(server.macview, "retarget") as retarget: + server.route(server.FRAME, ["-o", "HostName=192.0.2.9"]) # same headset, new address + stop.assert_not_called() + server.route("frame-2", ["-o", "HostName=192.0.2.2"]) + stop.assert_called_once() + retarget.assert_called_with("frame-2", ["-o", "HostName=192.0.2.2"]) diff --git a/tests/test_fdroid_sources.py b/tests/test_fdroid_sources.py new file mode 100644 index 0000000..4285721 --- /dev/null +++ b/tests/test_fdroid_sources.py @@ -0,0 +1,537 @@ +"""Offline authenticated repository fixtures; no tests contact a server.""" +import io +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import urllib.error +import zipfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +from apk_sources import SourceError, SourceLimited, _web, fdroid + +FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid' +PIN = (FIXTURES / 'fingerprint.txt').read_text().strip() +URL = 'https://example.org/repo/' +_KEY = [] + + +def signed_jar(member, content, digest='sha256'): + """A JAR signed like fdroidserver's (no CMS signed attributes) with a throwaway test key.""" + import base64, hashlib + from frame_apk_sign import certificate, der, integer, sequence, signing_key + if not _KEY: + with tempfile.TemporaryDirectory() as tmp: + _KEY.append(signing_key(Path(tmp) / 'key.json')) + key = _KEY[0] + label = 'SHA1' if digest == 'sha1' else 'SHA-256' + b64 = lambda data: base64.b64encode(hashlib.new(digest, data).digest()).decode() + manifest = ('Manifest-Version: 1.0\r\n\r\nName: %s\r\n%s-Digest: %s\r\n\r\n' % (member, label, b64(content))).encode() + sf = ('Signature-Version: 1.0\r\n%s-Digest-Manifest: %s\r\n\r\n' % (label, b64(manifest))).encode() + oid, prefix = next((bytes.fromhex(o), bytes.fromhex(p)) for o, (d, p) in fdroid._DIGESTS.items() if d == digest) + alg = sequence(der(6, oid), der(5, b'')) + size = (key['n'].bit_length() + 7) // 8 + value = prefix + hashlib.new(digest, sf).digest() + padded = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value + signature = pow(int.from_bytes(padded, 'big'), key['d'], key['n']).to_bytes(size, 'big') + cert = certificate(key) + issuer = fdroid._der_parts(fdroid._der_parts(fdroid._der_parts(cert)[0][1])[0][1])[3][2] + signer = sequence(integer(1), sequence(issuer, integer(1)), alg, + sequence(der(6, bytes.fromhex('2a864886f70d010101')), der(5, b'')), der(4, signature)) + signed = sequence(integer(1), der(0x31, alg), sequence(der(6, bytes.fromhex('2a864886f70d010701'))), + der(0xa0, cert), der(0x31, signer)) + block = sequence(der(6, bytes.fromhex('2a864886f70d010702')), der(0xa0, signed)) + stream = io.BytesIO() + with zipfile.ZipFile(stream, 'w') as z: + for name, data in (('META-INF/MANIFEST.MF', manifest), ('META-INF/TEST.SF', sf), + ('META-INF/TEST.RSA', block), (member, content)): + z.writestr(name, data) + return stream.getvalue(), fdroid.hashlib.sha256(cert).hexdigest() + + +def entry_jar(timestamp, digest='sha256'): + entry = json.loads(zipfile.ZipFile(FIXTURES / 'entry.jar').read('entry.json')) + return signed_jar('entry.json', json.dumps(dict(entry, timestamp=timestamp)).encode(), digest) + + +class Repositories(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) + net = patch.object(fdroid.urllib.request, 'build_opener', side_effect=AssertionError('network forbidden')) + net.start() + self.addCleanup(net.stop) + mock = self.fetch_patch = patch.object(fdroid, '_fetch', side_effect=self.fetch) + self.fetch_mock = mock.start() + self.addCleanup(mock.stop) + self.v1 = False + self.corrupt = None + self.files = {} + for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing): + state.clear() + self.addCleanup(state.clear) + + def fetch(self, url, path, maximum): + name = url.rsplit('/', 1)[-1] + if self.v1 and name == 'entry.jar': + raise urllib.error.HTTPError(url, 404, 'missing', None, None) + payload = self.files.get(name) or (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes() + if name == self.corrupt: + payload += b'tampered' + Path(path).write_bytes(payload) + + def add(self): + return fdroid.add_repo(URL, PIN) + + def test_add_search_details_download_cache(self): + source = self.add() + self.assertEqual(source['fingerprint'], PIN) + self.assertFalse(source['trust_on_first_use']) + result = fdroid.search(source, 'example offline') + self.assertEqual(len(result), 1) + self.assertNotIn('versions', result[0]) + self.assertEqual(result[0]['version_code'], 2) + self.assertEqual([v['version_code'] for v in fdroid.details(source, 'org.example.app')['versions']], [2, 1]) + downloaded = fdroid.download(source, 'org.example.app', 1) + self.assertTrue(downloaded['verified']) + self.assertEqual(Path(downloaded['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes()) + count = self.fetch_mock.call_count + os.utime(downloaded['apk'], (1, 1)) + fdroid.download(source, 'org.example.app', 1) + self.assertEqual(self.fetch_mock.call_count, count) + self.assertGreater(Path(downloaded['apk']).stat().st_mtime, 1) # reuse counts as recent use + + def test_wrong_pin_is_not_saved(self): + with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'): + fdroid.add_repo(URL, '0' * 64) + self.assertEqual(fdroid.user_repos(), []) + + def test_tampered_index_is_not_saved(self): + self.corrupt = 'index-v2.json' + with self.assertRaisesRegex(SourceError, 'SHA-256'): + self.add() + self.assertEqual(fdroid.user_repos(), []) + + def test_tampered_apk_is_not_cached(self): + source = self.add() + self.corrupt = 'example2.apk' + with self.assertRaisesRegex(SourceError, 'APK SHA-256'): + fdroid.download(source, 'org.example.app') + self.assertEqual(list(self.root.rglob('*.apk')), []) + self.assertEqual(list(self.root.rglob('*.part')), []) + + def test_v1_fallback(self): + self.v1 = True + source = self.add() + self.assertEqual(fdroid.search(source, 'Example')[0]['version_code'], 1) + self.assertTrue(fdroid.download(source, 'org.example.app')['verified']) + + def test_bad_v2_never_downgrades(self): + self.corrupt = 'index-v2.json' + with self.assertRaises(SourceError): + self.add() + self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list)) + + def test_transient_error_never_downgrades(self): + self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None) + with self.assertRaises(SourceError): + self.add() + self.assertEqual(self.fetch_mock.call_count, 1) + + def test_tofu_preserves_pin_and_settings(self): + source = fdroid.add_repo('fdroidrepos://example.org/repo') + self.assertTrue(source['trust_on_first_use']) + self.assertEqual(source['fingerprint'], PIN) + fdroid.add_repo(URL) + self.assertEqual(len(fdroid.user_repos()), 1) + with self.assertRaisesRegex(SourceError, 'different pinned'): + fdroid.add_repo(URL, '0' * 64) + fdroid.set_enabled(source['id'], False) + self.assertEqual(fdroid.search(fdroid.user_repos()[0], ''), []) + with self.assertRaisesRegex(SourceError, 'disabled'): + fdroid.download(fdroid.user_repos()[0], 'org.example.app') + fdroid.set_enabled('fdroid', False) + self.assertFalse(fdroid.sources()[0]['enabled']) + fdroid.remove_repo(source['id']) + self.assertEqual(fdroid.user_repos(), []) + with self.assertRaises(SourceError): + fdroid.remove_repo('fdroid') + + def test_urls(self): + self.assertEqual(fdroid._url(URL + '?fingerprint=' + PIN.upper()), (URL, PIN)) + for url in ['http://example.org/repo', 'fdroidrepo://example.org', 'https://u:p@example.org', URL+'?other=x']: + with self.subTest(url=url), self.assertRaises(SourceError): + fdroid._url(url) + with self.assertRaisesRegex(SourceError, 'conflicting'): + fdroid._url(URL + '?fingerprint=' + PIN, '0' * 64) + self.assertEqual(fdroid._child(URL, '/app/en-US/phoneScreenshots/#0 a.png'), + URL + 'app/en-US/phoneScreenshots/%230%20a.png') # real F-Droid screenshot name + for name in ['../x.apk', '%2e%2e/x.apk', 'https://evil.org/a.apk', '//evil.org/../x', 'x?token=y', 'x\\y']: + with self.subTest(name=name), self.assertRaises(SourceError): + fdroid._child(URL, name) + + def test_recorded_real_signature(self): + content, fingerprint = fdroid._jar(FIXTURES / 'izzy-entry.jar', 'entry.json', fdroid.IZZY_PIN, strong=True) + self.assertEqual(fingerprint, fdroid.IZZY_PIN) + self.assertIn('index', json.loads(content)) + + def test_tampering_each_signature_layer(self): + for member in ['entry.json', 'META-INF/MANIFEST.MF', 'META-INF/TEST.SF', 'META-INF/TEST.RSA']: + stream = io.BytesIO() + with zipfile.ZipFile(FIXTURES / 'entry.jar') as src, zipfile.ZipFile(stream, 'w') as dst: + for item in src.infolist(): + data = src.read(item.filename) + if item.filename == member: + data = data[:-1] + bytes([data[-1] ^ 1]) + dst.writestr(item.filename, data) + with self.subTest(member=member), self.assertRaises(SourceError): + fdroid._jar(io.BytesIO(stream.getvalue()), 'entry.json', PIN) + + def test_duplicate_jar_member_rejected(self): + stream = io.BytesIO((FIXTURES / 'entry.jar').read_bytes()) + import warnings + with warnings.catch_warnings(): + warnings.simplefilter('ignore', UserWarning) + with zipfile.ZipFile(stream, 'a') as z: + z.writestr('entry.json', '{}') + stream.seek(0) + with self.assertRaisesRegex(SourceError, 'duplicate'): + fdroid._jar(stream, 'entry.json', PIN) + + def test_corrupt_cache_refetches_verified_index(self): + source = self.add() + fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json').write_text('{') + self.assertEqual(len(fdroid.search(source, 'example')), 1) + self.assertEqual(self.fetch_mock.call_count, 4) + + def test_artwork_v1_and_v2_survives_source_cache(self): + source = self.add() + for version in ('v1', 'v2'): + with self.subTest(version=version): + raw = FIXTURES / ('artwork-' + version + '.json') + if version == 'v1': + normalized = self.root / 'normalized.json' + fdroid._v1(raw.read_bytes(), normalized) + raw = normalized + apps = fdroid._reduce(raw, source) + cache = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json') + fdroid._write(cache, {'version': fdroid.CACHE_VERSION, 'url': URL, + 'fingerprint': PIN, 'apps': apps}) + before = self.fetch_mock.call_count + result = fdroid.search(source, 'example offline')[0] + self.assertEqual(result['developer'], 'Example Developer') + self.assertEqual(result['summary'], 'Offline fixture & music. One line.') + self.assertEqual(result['icon'], URL + 'org.example.app/en-US/icon.png') + self.assertEqual(result['images'], { + 'icon': result['icon'], + 'banner': URL + 'org.example.app/fr/featureGraphic.png', + 'screenshots': [URL + 'org.example.app/en-US/phoneScreenshots/' + str(i) + '.png' for i in range(1, 5)] + + [URL + 'org.example.app/fr/sevenInchScreenshots/' + str(i) + '.png' for i in range(1, 3)]}) + self.assertEqual(fdroid.details(source, result['id'])['images'], result['images']) + self.assertEqual(self.fetch_mock.call_count, before) + + def test_missing_artwork_is_not_invented(self): + result = fdroid.search(self.add(), 'example')[0] + self.assertEqual(result['images'], {'icon': None, 'banner': None, 'screenshots': []}) + self.assertIsNone(result['icon']) + self.assertIsNone(result['developer']) + + def test_v1_legacy_icon_and_tablet_fallback(self): + index = json.loads((FIXTURES / 'artwork-v1.json').read_text()) + app = index['apps'][0] + app['localized'] = {'fr': {'sevenInchScreenshots': ['tablet.png']}} + app['icon'] = 'legacy.1.png' + raw = self.root / 'legacy.json' + fdroid._v1(json.dumps(index).encode(), raw) + result = fdroid._reduce(raw, {'id': 'test', 'url': URL})['org.example.app'] + self.assertEqual(result['icon'], URL + 'icons/legacy.1.png') + self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png']) + + def test_v2_legacy_screenshot_keys_and_limit(self): + meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]}, + 'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}} + images = fdroid._images(meta, URL) + self.assertEqual(images['screenshots'], [URL + 'phone/' + str(i) + '.png' for i in range(6)]) + meta.pop('phoneScreenshots') + self.assertEqual(fdroid._images(meta, URL)['screenshots'], [URL + 'tablet.png']) + + def test_old_cache_refreshes_for_artwork(self): + source = self.add() + path = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json') + saved = json.loads(path.read_text()) + saved.pop('version') + for app in saved['apps'].values(): + app.pop('images') + fdroid._write(path, saved) + self.assertIn('images', fdroid.search(source, 'example')[0]) + self.assertEqual(self.fetch_mock.call_count, 4) + + def test_slow_download_blocks_neither_settings_nor_other_repos(self): + import threading + source = self.add() + other = dict(source, id='other-repo') + started, release = threading.Event(), threading.Event() + def fetch(url, path, maximum): + if threading.current_thread().name == 'slow': + started.set() + release.wait(5) + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = fetch + slow = threading.Thread(target=fdroid._load, args=(other, True), name='slow') + slow.start() + try: + self.assertTrue(started.wait(2)) + results = [] + # The settings lock is free and another repo still loads while this one downloads. + check = threading.Thread(target=lambda: results.append( + (fdroid.set_enabled('fdroid', False), len(fdroid._load(source, force=True)[0])))) + check.start() + check.join(2) + self.assertEqual(results, [(None, 1)]) + finally: + release.set() + slow.join() + + def test_rollback_to_older_index_is_refused(self): + self.files['entry.jar'], pin = entry_jar(2000) + source = fdroid.add_repo(URL) + self.assertEqual(source['fingerprint'], pin) + self.files['entry.jar'], _ = entry_jar(1000) + with self.assertRaisesRegex(SourceError, 'older'): + fdroid._load(source, force=True) + for timestamp in (2000, 3000): # unchanged and newer indexes are fine + self.files['entry.jar'], _ = entry_jar(timestamp) + self.assertEqual(len(fdroid._load(source, force=True)[0]), 1) + self.files['entry.jar'], _ = entry_jar(2000) + with self.assertRaisesRegex(SourceError, 'older'): + fdroid._load(source, force=True) + fdroid.remove_repo(source['id']) # a deliberate re-add starts over + self.assertEqual(fdroid.add_repo(URL)['fingerprint'], pin) + + def test_concurrent_processes_cannot_publish_an_older_index_last(self): + # Threads with their own in-memory locks, as separate processes would have; each + # _state_file_lock() opens its own file description, so flock contends for real. + import threading + 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): + name = threading.current_thread().name + if name in jars and url.endswith('entry.jar'): + Path(path).write_bytes(jars[name]) + else: + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = fetch + inside, go, order = threading.Event(), threading.Event(), [] + real_write = fdroid._write + def write(path, value): + if path.name.endswith('.json') and 'apps' in value and threading.current_thread().name == 'older': + inside.set() # the older load has passed its locked recheck; hold it there + go.wait(5) + order.append(threading.current_thread().name) + real_write(path, value) + errors = {} + def load(): + try: + fdroid._load(source, force=True) + except SourceError as e: + errors[threading.current_thread().name] = str(e) + with patch.object(fdroid, '_source_lock', lambda source_id: threading.Lock()), \ + patch.object(fdroid, '_write', write): + older = threading.Thread(target=load, name='older') + older.start() + self.assertTrue(inside.wait(5)) + newer = threading.Thread(target=load, name='newer') + newer.start() + newer.join(.5) + self.assertTrue(newer.is_alive()) # blocked on the file lock, not publishing + go.set() + older.join(5) + newer.join(5) + self.assertEqual(errors, {}) + self.assertEqual(order, ['older', 'older', 'newer', 'newer']) # cache+state, one load at a time + self.assertEqual(fdroid._state(source)['timestamp'], 200) + + def test_overlapping_v1_load_cannot_replace_accepted_v2(self): + import threading + v1 = json.loads(zipfile.ZipFile(FIXTURES / 'index-v1.jar').read('index-v1.json')) + v1['repo'] = {'timestamp': 100} + self.files['index-v1.jar'], pin = signed_jar('index-v1.json', json.dumps(v1).encode()) + self.files['entry.jar'], _ = entry_jar(100) # the same timestamp as the v1 index + source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None) + self.v1 = True + inner = [] + def fetch(url, path, maximum): + 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 + t = threading.Thread(target=lambda: inner.append(fdroid._load(source, force=True))) + with patch.object(fdroid, '_source_lock', lambda source_id: threading.Lock()): + t.start() + t.join(5) + self.v1 = True + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = fetch + with self.assertRaisesRegex(SourceError, 'v2'): + fdroid._load(source, force=True) + self.assertEqual(inner[1][1], pin) + self.assertTrue(fdroid._state(source)['v2']) + self.v1 = False + count = self.fetch_mock.call_count + apps, _ = fdroid._load(dict(source, fingerprint=pin)) + self.assertEqual((apps['org.example.app']['version_code'], self.fetch_mock.call_count), (2, count)) # v2 cache stayed + + def test_apk_removed_during_cache_check_is_downloaded_again(self): + source = self.add() + first = fdroid.download(source, 'org.example.app', 1) + count = self.fetch_mock.call_count + real = fdroid._sha256 + def removed_first(path): + if str(path) == first['apk'] and not hashed: + hashed.append(1) + os.remove(first['apk']) # deleted between the existence check and the open + return real(path) + hashed = [] + with patch.object(fdroid, '_sha256', removed_first): + again = fdroid.download(source, 'org.example.app', 1) + self.assertEqual(self.fetch_mock.call_count, count + 1) + self.assertEqual(Path(again['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes()) + + def test_cached_apk_is_touched_before_hashing(self): + source = self.add() + first = fdroid.download(source, 'org.example.app', 1) + os.utime(first['apk'], (1, 1)) + count = self.fetch_mock.call_count + real = fdroid._sha256 + def prune_first(path): + if str(path) == first['apk']: + with patch.object(_web, 'APK_CAP', 0): + _web.prune() # a pruner running now sees a just-used APK + return real(path) + with patch.object(fdroid, '_sha256', prune_first): + fdroid.download(source, 'org.example.app', 1) + self.assertEqual(self.fetch_mock.call_count, count) + self.assertTrue(Path(first['apk']).exists()) + + def test_cli_search_waits_for_background_refresh(self): + source = self.add() + self.expire(source) + before = self.fetch_mock.call_count + out = io.StringIO() + with patch.object(sys, 'argv', ['fdroid.py', 'search', source['id'], 'example']), \ + patch('sys.stdout', out): + fdroid.main() + self.assertEqual(json.loads(out.getvalue())[0]['id'], 'org.example.app') + self.assertEqual(self.fetch_mock.call_count, before + 2) # the refresh finished before exit + self.assertFalse(fdroid.stale(source)) + self.assertNotIn(source['id'], fdroid._refreshing) + + def test_cli_error_still_waits_for_background_refresh(self): + import threading + source = self.add() + self.expire(source) + release = threading.Event() + def slow(url, path, maximum): + release.wait(5) + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = slow + threading.Timer(.3, release.set).start() + with patch.object(sys, 'argv', ['fdroid.py', 'download', source['id'], 'org.missing']), \ + patch('sys.stderr', io.StringIO()) as err, self.assertRaises(SystemExit): + fdroid.main() + self.assertIn('no Lepton-compatible version', err.getvalue()) + self.assertTrue(release.is_set()) + self.assertEqual(fdroid._refreshing, {}) # joined before exiting + self.assertFalse(fdroid.stale(source)) + + def test_no_v1_fallback_once_v2_accepted(self): + source = self.add() + self.v1 = True + with self.assertRaisesRegex(SourceError, 'v2'): + fdroid._load(source, force=True) + self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list)) + + def test_v1_then_v2_upgrade_is_allowed(self): + self.v1 = True + source = self.add() + self.v1 = False + self.assertEqual(fdroid._load(source, force=True)[0]['org.example.app']['version_code'], 2) + + def test_sha1_only_entry_jar_rejected(self): + self.files['entry.jar'], _ = entry_jar(1, 'sha1') + with self.assertRaisesRegex(SourceError, 'SHA-1'): + fdroid.add_repo(URL) + self.assertEqual(fdroid.user_repos(), []) + stream = io.BytesIO(self.files['entry.jar']) + self.assertIn(b'index', fdroid._jar(stream, 'entry.json', None)[0]) # index-v1.jar may still use SHA-1 + + def test_rate_limited_host_backs_off(self): + source = dict(self.add(), name='My repo') + self.fetch_patch.stop() + error = urllib.error.HTTPError(URL, 429, 'slow down', {'Retry-After': '300'}, None) + with patch.object(fdroid.urllib.request, 'build_opener') as opener: + opener.return_value.open.side_effect = error + with self.assertRaisesRegex(SourceLimited, '^My repo is limiting requests; try again in 5 minutes$'): + fdroid._load(source, force=True) + with self.assertRaisesRegex(SourceLimited, 'My repo'): + fdroid.download(source, 'org.example.app', 1) + self.assertEqual(opener.return_value.open.call_count, 1) + self.fetch_mock = self.fetch_patch.start() + + def expire(self, source): + cache = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json') + old = cache.stat().st_mtime - fdroid.MAX_AGE - 1 + fdroid.os.utime(str(cache), (old, old)) + + def test_expired_index_served_stale_while_refreshing(self): + source = self.add() + self.expire(source) + before = self.fetch_mock.call_count + release = __import__('threading').Event() + def slow(url, path, maximum): + release.wait(5) + self.fetch(url, path, maximum) + self.fetch_mock.side_effect = slow + self.assertEqual(len(fdroid.search(source, 'example')), 1) # immediately, from the old index + self.assertTrue(fdroid.stale(source)) + refresh = fdroid._refreshing[source['id']] + fdroid.search(source, 'example') + self.assertIs(fdroid._refreshing.get(source['id']), refresh) # one refresh at a time + release.set() + refresh.join(5) + self.assertEqual(self.fetch_mock.call_count, before + 2) + self.assertFalse(fdroid.stale(source)) + + def test_failed_refresh_keeps_serving_stale_index(self): + source = self.add() + self.expire(source) + self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None) + self.assertEqual(len(fdroid.search(source, 'example')), 1) + # A fast failed refresh may already have removed itself from the registry. + refresh = fdroid._refreshing.get(source['id']) + if refresh is not None: + refresh.join(5) + calls = self.fetch_mock.call_count + self.assertEqual(fdroid.details(source, 'org.example.app')['version_code'], 2) + self.assertTrue(fdroid.stale(source)) + self.assertNotIn(source['id'], fdroid._refreshing) # failed refresh waits before retrying + self.assertEqual(self.fetch_mock.call_count, calls) + + def test_cached_index_does_not_cross_pins(self): + source = self.add() + source['fingerprint'] = '0' * 64 + with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'): + fdroid.search(source, '') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_frame_android_data.py b/tests/test_frame_android_data.py new file mode 100644 index 0000000..175762c --- /dev/null +++ b/tests/test_frame_android_data.py @@ -0,0 +1,293 @@ +import io +import json +import os +from pathlib import Path +import runpy +import shlex +import shutil +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android +import frame_android_data as data + +REMOTE = runpy.run_path(str(data.REMOTE)) +PKG = 'org.example.game' +META = {'package': PKG, 'instance': 2800000001} + + +class ObbTests(unittest.TestCase): + def test_invalid_files_never_contact_frame(self): + with tempfile.TemporaryDirectory() as tmp, patch.object(android, 'ssh') as ssh: + for name in ('game.obb', 'main.1.org.other.game.obb', 'main.x.' + PKG + '.obb'): + path = Path(tmp) / name + path.write_bytes(b'content') + with self.assertRaises(android.FrameError): + data.install_obb(PKG, [path]) + with self.assertRaises(android.FrameError): + data.install_obb('../game', []) + with self.assertRaises(android.FrameError): + data.install_obb(PKG, []) + ssh.assert_not_called() + + def test_streams_to_correct_instance_and_checks_hash_before_rename(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ('main.7.' + PKG + '.obb') + path.write_bytes(b'expansion payload') + calls = [] + def stream(command, src=None, dst=None): + calls.append(command) + self.assertEqual(src.read(), b'expansion payload') + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001\n'), \ + patch.object(data, '_stream', side_effect=stream): + result = data.install_obb(PKG, [path]) + self.assertTrue(result['verified']) + self.assertIn('podman exec -i lepton-steamlaunch-2800000001', calls[0]) + self.assertIn('/sdcard/Android/obb/' + PKG, calls[0]) + self.assertLess(calls[0].index('sha256sum'), calls[0].index('; mv')) + self.assertIn(result['obb'][0]['sha256'], calls[0]) + + def test_stopped_instance_and_failed_transfer(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / ('patch.7.' + PKG + '.obb') + path.write_bytes(b'patch') + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(android, 'ssh', return_value=''), patch.object(data, '_stream') as stream: + with self.assertRaisesRegex(android.FrameError, 'start this app'): + data.install_obb(PKG, [path]) + stream.assert_not_called() + with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')): + with self.assertRaisesRegex(android.FrameError, 'bad hash'): + data._stream('command') + + + @unittest.skipUnless(os.name == 'posix' and shutil.which("sh") and shutil.which("shasum"), + "the OBB script runs on the Frame (Linux shell)") + def test_android_shell_publish_and_hash_failure(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / ('main.7.' + PKG + '.obb') + source.write_bytes(b'good expansion') + output = Path(tmp) / 'sdcard/Android/obb' / PKG / source.name + tools_dir = Path(tmp) / 'bin' + tools_dir.mkdir() + checksum = tools_dir / 'sha256sum' + checksum.write_text('#!/bin/sh\nexec shasum -a 256 "$@"\n') + checksum.chmod(0o700) + corrupt = False + def stream(command, src=None, dst=None): + script = shlex.split(command)[-1].replace('/sdcard/', tmp + '/sdcard/') + if corrupt: + source.write_bytes(b'corrupt expansion') + result = subprocess.run(['sh', '-c', script], stdin=src, capture_output=True, + env=dict(os.environ, PATH=str(tools_dir) + ':' + os.environ['PATH'])) + if result.returncode: + raise android.FrameError('checksum failed') + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001'), \ + patch.object(data, '_stream', side_effect=stream): + data.install_obb(PKG, [source]) + self.assertEqual(output.read_bytes(), b'good expansion') + corrupt = True + with self.assertRaises(android.FrameError): + data.install_obb(PKG, [source]) + self.assertEqual(output.read_bytes(), b'good expansion') + self.assertEqual(list(output.parent.glob('*.part')), []) + + +@unittest.skipUnless(os.name == 'posix', 'app-data backups run on the Frame (Linux ownership and modes)') +class BackupTests(unittest.TestCase): + def test_roundtrip_and_retains_previous_data(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / PKG + (source / 'files').mkdir(parents=True) + (source / 'files/save').write_bytes(b'original save') + archive = io.BytesIO() + REMOTE['backup'](root, PKG, META['instance'], archive) + (source / 'files/save').write_bytes(b'new save') + archive.seek(0) + # Current user's uid/gid in this local test; no elevated execution. + result = REMOTE['restore'](root, PKG, META['instance'], archive) + self.assertEqual((source / 'files/save').read_bytes(), b'original save') + self.assertEqual((Path(result['previous']) / 'files/save').read_bytes(), b'new save') + + def test_symlinks_skipped_and_recorded_hardlinks_copied(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / PKG + (source / 'files').mkdir(parents=True) + (source / 'files/save').write_bytes(b'save') + os.link(str(source / 'files/save'), str(source / 'files/save-link')) + os.symlink('/data/app/lib', str(source / 'lib')) + os.symlink('save', str(source / 'files/alias')) + archive = root / 'backup.tar.gz' + with archive.open('wb') as output: + REMOTE['backup'](root, PKG, META['instance'], output) + result = REMOTE['inspect_archive'](archive, PKG, META['instance']) + self.assertEqual(result['skipped_links'], 2) + with tarfile.open(archive) as tar: + manifest = json.load(tar.extractfile('manifest.json')) + self.assertEqual(tar.extractfile('data/files/save-link').read(), b'save') + self.assertEqual(sorted((l['path'], l['target']) for l in manifest['skipped_links']), + [('data/files/alias', 'save'), ('data/lib', '/data/app/lib')]) + with archive.open('rb') as src: + REMOTE['restore'](root, PKG, META['instance'], src) + self.assertFalse((source / 'lib').exists() or (source / 'lib').is_symlink()) + self.assertEqual((source / 'files/save-link').read_bytes(), b'save') + + @unittest.skipUnless(os.name == 'posix', 'restores run on the Frame (Linux flock)') + def test_overlapping_restores_keep_a_recovery_copy(self): + import threading + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / PKG).mkdir() + (root / PKG / 'save').write_bytes(b'backup') + archive = io.BytesIO() + REMOTE['backup'](root, PKG, META['instance'], archive) + (root / PKG / 'save').write_bytes(b'current') + REMOTE['restore'](root, PKG, META['instance'], io.BytesIO(archive.getvalue())) # an old copy to clean up + restore = REMOTE['restore'] + real_rmtree, inside, go = shutil.rmtree, threading.Event(), threading.Event() + def rmtree(path, **kwargs): + if threading.current_thread().name == 'first': + inside.set() # swapped, now cleaning up; hold it here + go.wait(5) + real_rmtree(path, **kwargs) + results = {} + def run(): + results[threading.current_thread().name] = restore( + root, PKG, META['instance'], io.BytesIO(archive.getvalue()))['previous'] + with patch.dict(restore.__globals__, {'shutil': type('S', (), {'rmtree': staticmethod(rmtree), + 'copyfileobj': shutil.copyfileobj})}): + first = threading.Thread(target=run, name='first') + first.start() + self.assertTrue(inside.wait(5)) + second = threading.Thread(target=run, name='second') + second.start() + second.join(.5) + self.assertTrue(second.is_alive()) # can't swap or clean up during the first's cleanup + go.set() + first.join(5) + second.join(5) + copies = sorted(root.glob('.' + PKG + '.before-restore-*')) + self.assertEqual(copies, [Path(results['second'])]) # the second restore's recovery copy survives + self.assertEqual((copies[0] / 'save').read_bytes(), b'backup') + + def test_restore_keeps_only_latest_previous_copy(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / PKG).mkdir() + (root / PKG / 'save').write_bytes(b'one') + other = root / '.org.example.gameplus.before-restore-1' # another package's copy is left alone + other.mkdir() + archive = io.BytesIO() + REMOTE['backup'](root, PKG, META['instance'], archive) + previous = [] + for _ in range(3): + archive.seek(0) + previous.append(REMOTE['restore'](root, PKG, META['instance'], archive)['previous']) + self.assertEqual(sorted(root.glob('.' + PKG + '.before-restore-*')), [Path(previous[-1])]) + self.assertTrue(other.exists()) + + def make_archive(self, path, members, package=PKG): + with tarfile.open(path, 'w:gz') as archive: + payload = json.dumps({'format': 1, 'package': package, 'instance': META['instance']}).encode() + member = tarfile.TarInfo('manifest.json') + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + root = tarfile.TarInfo('data') + root.type = tarfile.DIRTYPE + archive.addfile(root) + for name, kind in members: + member = tarfile.TarInfo(name) + member.type = kind + member.linkname = '/tmp/escape' + archive.addfile(member) + + def test_rejects_wrong_package_traversal_links_devices_duplicates(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'bad.tar.gz' + cases = [('../escape', tarfile.REGTYPE), ('/absolute', tarfile.REGTYPE), + ('data/link', tarfile.SYMTYPE), ('data/link', tarfile.LNKTYPE), + ('data/device', tarfile.CHRTYPE), ('data', tarfile.DIRTYPE), + ('other/file', tarfile.REGTYPE), ('data/../escape', tarfile.REGTYPE)] + for member in cases: + self.make_archive(path, [member]) + with self.assertRaises(ValueError, msg=str(member)): + REMOTE['inspect_archive'](path, PKG, META['instance']) + self.make_archive(path, [], package='org.other.game') + with self.assertRaisesRegex(ValueError, 'does not match'): + REMOTE['inspect_archive'](path, PKG, META['instance']) + + def test_failed_backup_leaves_no_archive_and_existing_is_preserved(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'backup.tar.gz' + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(data, '_stream', side_effect=android.FrameError('offline')): + with self.assertRaises(android.FrameError): + data.backup_data(PKG, path) + self.assertEqual(list(Path(tmp).iterdir()), []) + path.write_bytes(b'keep') + with self.assertRaisesRegex(android.FrameError, 'already exists'): + data.backup_data(PKG, path) + self.assertEqual(path.read_bytes(), b'keep') + + def test_guard_does_not_hide_podman_failure(self): + command = data._data_command('backup', META) + self.assertIn('|| exit 1', command) + self.assertIn('stop the app', command) + self.assertIn('podman unshare python3', command) + self.assertNotIn('|| true', command) + + def test_bad_restore_is_rejected_before_transfer(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / 'bad.tar.gz' + self.make_archive(path, [('../escape', tarfile.REGTYPE)]) + with patch.object(android, '_meta_or_fail', return_value=META), patch.object(data, '_stream') as stream: + with self.assertRaises(android.FrameError): + data.restore_data(PKG, path) + stream.assert_not_called() + + + def test_successful_backup_is_private_and_inspectable(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / PKG).mkdir() + (root / PKG / 'save').write_bytes(b'checkpoint') + destination = root / 'backup.tar.gz' + def stream(command, src=None, dst=None): + REMOTE['backup'](root, PKG, META['instance'], dst) + with patch.object(android, '_meta_or_fail', return_value=META), \ + patch.object(data, '_stream', side_effect=stream): + result = data.backup_data(PKG, destination) + self.assertEqual(destination.stat().st_mode & 0o777, 0o600) + self.assertEqual(result['sha256'], data._sha256(destination)) + self.assertEqual(result['files'], 2) + + def test_rejected_restore_keeps_existing_data(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / PKG).mkdir() + (root / PKG / 'save').write_bytes(b'keep') + archive = root / 'bad.tar.gz' + self.make_archive(archive, [('data/link', tarfile.SYMTYPE)]) + with archive.open('rb') as source, self.assertRaises(ValueError): + REMOTE['restore'](root, PKG, META['instance'], source) + self.assertEqual((root / PKG / 'save').read_bytes(), b'keep') + self.assertFalse(list(root.glob('.frame-restore-*'))) + self.assertFalse(list(root.glob('.*.before-restore-*'))) + + def test_archive_root_must_be_a_directory(self): + with tempfile.TemporaryDirectory() as tmp: + archive = Path(tmp) / 'bad.tar.gz' + with tarfile.open(archive, 'w:gz') as target: + target.addfile(tarfile.TarInfo('data')) + with self.assertRaisesRegex(ValueError, 'directory'): + REMOTE['inspect_archive'](archive, PKG, META['instance']) diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py new file mode 100644 index 0000000..cdf51c0 --- /dev/null +++ b/tests/test_frame_android_library.py @@ -0,0 +1,644 @@ +"""Offline artwork, Steam API and launcher supervision regressions.""" +import importlib.util +import json +import os +from pathlib import Path +import signal +import struct +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android +import frame_artwork as art + +spec = importlib.util.spec_from_file_location('steam_shortcuts', ROOT / 'frame/android/steam_shortcuts.py') +shortcuts = importlib.util.module_from_spec(spec) +spec.loader.exec_module(shortcuts) + + +@unittest.skipIf(os.name == 'nt', 'POSIX launcher') +class LauncherTests(unittest.TestCase): + def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False): + with tempfile.TemporaryDirectory() as tmp: + d = Path(tmp) + app = d / 'Applications/Android/org.test.app' + app.mkdir(parents=True) + (app / 'launch.sh').write_bytes((ROOT / 'frame/android/lepton-app.sh').read_bytes()) + (app / 'app.apk').touch() + (app / 'instance.id').write_text('2800000001') + (app / 'shortcut.id').write_text('3346865537') + bin_dir = d / 'bin' + bin_dir.mkdir() + lepton = d / '.local/share/Steam/steamapps/common/Lepton/lepton' + lepton.parent.mkdir(parents=True) + def script(path, body): + path.write_text('#!' + sys.executable + '\n' + body) + path.chmod(0o755) + script(lepton, 'import os,time\nfrom pathlib import Path\n' + 'assert os.environ["SteamAppId"] == "2800000001"\n' + 'assert os.environ["LEPTON_ENV_SteamAppId"] == "3346865537"\n' + 'Path(os.environ["HOME"],"started").write_text(str(os.getpid()))\n' + 'try:\n os.fstat(9); Path(os.environ["HOME"],"inherited-lock").touch()\nexcept OSError: pass\n' + + ('time.sleep(30)\n' if terminate else 'raise SystemExit(23)\n')) + script(bin_dir / 'setsid', 'import os,sys\nos.setsid()\nos.execv(sys.argv[2],sys.argv[2:])\n') + script(bin_dir / 'flock', 'import os\nraise SystemExit(1 if os.environ.get("TEST_LOCKED") else 0)\n') # lock semantics belong to Linux; no flock on macOS + script(bin_dir / 'podman', 'import os,sys\nfrom pathlib import Path\n' + 'p=Path(os.environ["HOME"],"podman-calls")\n' + 'with p.open("a") as f: f.write(" ".join(sys.argv[1:])+"\\n")\n' + 'if sys.argv[1:2]==["inspect"] and os.environ.get("TEST_RUNNING"): print("true")\n') + env = {**os.environ, 'HOME': str(d), 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH']} + if blocked: + env['TEST_' + blocked] = '1' + if orphan: + env['TEST_RUNNING'] = '1' + saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save' + saved.parent.mkdir(parents=True) + saved.write_text('saved game') + proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + if blocked: + proc.communicate(timeout=5) + self.assertEqual(proc.returncode, 1) + self.assertFalse((d / 'started').exists()) + calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else '' + self.assertNotIn('stop ', calls) + return + deadline = time.monotonic() + 5 + while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline: + time.sleep(.02) + self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton') + self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock') + if terminate: + self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session') + proc.send_signal(sig) + _, err = proc.communicate(timeout=5) + calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else '' + self.assertIn('stop -t 5 lepton-steamlaunch-2800000001', calls, err.decode()) + self.assertEqual(calls.count('stop -t 5'), 2 if orphan else 1) + self.assertEqual(proc.returncode, 128 + sig if terminate else 23) + self.assertEqual(saved.read_text(), 'saved game') + self.assertTrue((app / 'app.apk').exists()) + finally: + if proc.poll() is None: + proc.kill() + proc.communicate() + if (d / 'started').exists(): + try: + os.kill(int((d / 'started').read_text()), signal.SIGKILL) + except ProcessLookupError: + pass + + def test_steam_stop_cleans_container(self): + self.exercise(True) + + def test_hangup_and_interrupt_cleanup(self): + # macOS's stock bash 3.2 doesn't run a SIGINT trap while blocked in `wait`; + # the Frame's bash (5.x) does, and that's where the launcher runs. + major = subprocess.run(['bash', '-c', 'echo ${BASH_VERSINFO[0]}'], capture_output=True, text=True).stdout.strip() + sigs = (signal.SIGHUP, signal.SIGINT) if major.isdigit() and int(major) >= 4 else (signal.SIGHUP,) + for sig in sigs: + with self.subTest(sig=sig): + self.exercise(True, sig) + + def test_duplicate_launch_leaves_existing_session_alone(self): + self.exercise(False, blocked='LOCKED') + + def test_orphaned_container_is_stopped_and_play_proceeds(self): + # Container running but the lock free: its launcher was SIGKILLed. + self.exercise(False, orphan=True) + + def test_normal_exit_cleans_container_and_keeps_exit_code(self): + self.exercise(False) + + +FIXTURES = ROOT / 'tests/fixtures/library' + + +class ArtworkTests(unittest.TestCase): + def test_source_inputs_and_url(self): + from apk_sources import _images + data = (FIXTURES / 'icon.png').read_bytes() + with patch('frame_steamgriddb.lookup', return_value=({}, [])), \ + patch.object(_images, 'fetch', return_value=(data, 'image/png')) as fetch: + images, warnings = art.prepare('Game', artwork={'banner': data, 'icon': 'https://example.org/icon.png'}) + self.assertEqual(images['banner'], ('png', data)) + self.assertEqual(images['icon'], ('png', data)) + self.assertEqual(warnings, []) + self.assertEqual(fetch.call_args.args[0], 'https://example.org/icon.png') + self.assertIsNotNone(fetch.call_args.kwargs['deadline']) + + def test_provider_precedence_and_bad_source_fallback(self): + data = (FIXTURES / 'icon.png').read_bytes() + jpg = (FIXTURES / 'icon.jpg').read_bytes() + with patch('frame_steamgriddb.lookup', return_value=({'hero': jpg}, [])): + images, warnings = art.prepare('Game', data, {'hero': data, 'wide': b'bad', 'screenshots': [b'bad', data]}) + self.assertEqual(images['hero'], ('jpg', jpg)) + self.assertEqual(images['icon'], ('png', data)) + self.assertEqual(images['screenshot'], ('png', data)) + self.assertNotIn('wide', images) + self.assertEqual(warnings, ['Source wide unavailable; using fallback art']) # one per slot, not per candidate + + def test_any_source_failure_falls_back_to_generated_art(self): + import http.client + from apk_sources import _images + data = (FIXTURES / 'icon.png').read_bytes() + for error in (http.client.RemoteDisconnected('gone'), http.client.IncompleteRead(b''), AttributeError('x')): + with self.subTest(error=type(error).__name__), \ + patch.object(_images, 'fetch', side_effect=error), \ + patch('frame_steamgriddb.lookup', side_effect=error): + images, warnings = art.prepare('Game', data, {'banner': 'https://example.org/b.png'}) + self.assertEqual(set(images), {'icon'}) + self.assertEqual(len(warnings), 2) + + def test_url_fetch_refuses_private_hosts_and_honours_deadline(self): + from apk_sources import _images, SourceError + local = [(2, 1, 6, '', ('127.0.0.1', 443))] + with patch.object(_images.socket, 'getaddrinfo', return_value=local), \ + self.assertRaisesRegex(SourceError, 'Private'): + art.fetch('https://example.org/icon.png') + public = [(2, 1, 6, '', ('93.184.216.34', 443))] + with patch.object(_images.socket, 'getaddrinfo', return_value=public), \ + patch.object(_images.socket, 'create_connection') as connect, \ + self.assertRaisesRegex(SourceError, 'too long'): + art.fetch('https://example.org/icon.png', deadline=time.monotonic() - 1) + connect.assert_not_called() + with self.assertRaises(SourceError): + art.fetch('file:///etc/passwd') + + def trickle(self, head, seconds): + # A server that answers one byte every 20 ms, over a socketpair standing in for the network. + import socket + import threading + from apk_sources import _images + client, server = socket.socketpair() + def serve(): + try: + server.recv(65536) + for byte in head + b'x' * 1000: + server.sendall(bytes([byte])) + time.sleep(0.02) + except OSError: + pass + finally: + server.close() + threading.Thread(target=serve, daemon=True).start() + public = [(2, 1, 6, '', ('93.184.216.34', 80))] + with patch.object(_images.socket, 'getaddrinfo', return_value=public), \ + patch.object(_images.socket, 'create_connection', return_value=client): + start = time.monotonic() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('http://example.org/a.png', deadline=start + seconds) + return time.monotonic() - start + + def test_deadline_bounds_trickling_headers_and_body(self): + self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n', 0.15), 0.4) + self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\n', 0.15), 0.4) # headers never finish + + def test_deadline_covers_a_stalled_tls_handshake(self): + import socket + from apk_sources import _images + client, server = socket.socketpair() + public = [(2, 1, 6, '', ('93.184.216.34', 443))] + try: + with patch.object(_images.socket, 'getaddrinfo', return_value=public), \ + patch.object(_images.socket, 'create_connection', return_value=client): + start = time.monotonic() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('https://example.org/a.png', deadline=start + 0.25) # server never answers + self.assertLess(time.monotonic() - start, 0.45) + finally: + server.close() + + def test_timed_out_lookups_are_capped(self): + import threading + from apk_sources import _images + gate = threading.Event() + try: + with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []): + errors = [] + for _ in range(6): + try: + _images.get('https://example.org/a.png', deadline=time.monotonic() + 0.05) + except _images.SourceError as e: + errors.append(str(e)) + self.assertEqual(sum('too long' in e for e in errors), 4) + 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 + + def test_resolver_slot_released_when_thread_cannot_start(self): + from apk_sources import _images + with patch.object(_images.threading.Thread, 'start', side_effect=RuntimeError("can't start new thread")): + 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() + + def test_deadline_covers_name_resolution(self): + import threading + from apk_sources import _images + gate = threading.Event() + with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []): + start = time.monotonic() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('https://example.org/a.png', deadline=start + 0.1) + self.assertLess(time.monotonic() - start, 0.4) + gate.set() + with self.assertRaisesRegex(_images.SourceError, 'too long'): + _images.get('https://example.org/a.png', deadline=time.monotonic() - 1) + + def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self): + import frame_steamgriddb as sgdb + from apk_sources import _images + with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get: + self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1]) + self.assertEqual(get.call_args.kwargs['redirects'], 0) + self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret') + self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5) + + def test_supplied_jpeg(self): + data = (FIXTURES / 'icon.jpg').read_bytes() + self.assertEqual(art.image_type(data), 'jpg') + self.assertEqual(art.image_type(data + b'\0' * 64), 'jpg') # trailing padding after EOI + with self.assertRaises(ValueError): + art.image_type(data[:30]) + + FRAME = b'\x21\xf9\x04\x01\x00\x00\x00\x00' + b'\x2c' + struct.pack('IIBBBBB', w, h, depth, color, 0, 0, interlace)) + \ + art.chunk(b'IEND', b'') + self.assertEqual(art.image_type(png(3840, 1240, 16, 6, 0)), 'png') + self.assertEqual(art.image_type(png(3840, 2160, 8, 2, 1)), 'png') + for bad, message in ((png(10000, 10, 8, 6, 0), 'dimensions'), (png(5000, 5000, 8, 6, 0), 'dimensions'), + (png(10, 10, 3, 6, 0), 'encoding'), (png(10, 10, 8, 5, 0), 'encoding')): + with self.subTest(message=message), self.assertRaisesRegex(ValueError, message): + art.image_type(bad) + broken = bytearray(png(10, 10, 8, 6, 0)) + broken[20] ^= 1 + with self.assertRaisesRegex(ValueError, 'checksum'): + art.image_type(bytes(broken)) + with self.assertRaises(ValueError): + art.image_type(art.PNG + b'junk') + + def test_bad_artwork_arguments(self): + for value in ({'bad': b'bad'}, ['hero']): + with self.subTest(value=value), self.assertRaises(ValueError): + art.prepare('Game', artwork=value) + + def test_godot_project_icon(self): + import io + import zipfile + data = (FIXTURES / 'icon.png').read_bytes() + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w') as archive: + archive.writestr('assets/icon.png', data) + with zipfile.ZipFile(buffer) as archive: + self.assertEqual(android.frame_apk._icon_png(archive, set(archive.namelist()), []), data) + + +class InstallTests(unittest.TestCase): + def setUp(self): + self.responses = json.loads((FIXTURES / 'steam-responses.json').read_text()) + self.info = {'package': 'org.test.vr', 'label': 'VR', 'version': '1', 'icon_png': None, + 'vr': True, 'launchable': True, 'repairable': False, 'abis': [], 'min_sdk': 24} + self.images = {slot: ('png', b'PNG ' + slot.encode()) for slot in art.SLOTS} + self.existing = {'package': 'org.test.vr', 'instance': 2800000001, + 'shortcut': 3346865537, 'label': 'Old name'} + + def install(self, existing, tool=None): + def shortcut(*args, **kwargs): + if args[0] == 'add': + return '3346865537' + if args[0] == 'render': + return json.dumps({'paths': {slot: '/home/steamos/Applications/Android/org.test.vr/artwork/' + slot + '.png' for slot in art.SLOTS}}) + if args[0] == 'list': + return json.dumps(self.responses['shortcuts']) + return json.dumps(self.responses['configure']) + with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \ + patch.object(android, 'read_meta', return_value=existing), \ + patch.object(android, '_copy') as copy, \ + patch.object(android, 'ssh', return_value=self.responses['home']) as ssh, \ + patch.object(android, 'shortcut_tool', side_effect=tool or shortcut) as api, \ + patch.object(android, '_write_meta') as meta: + result = android._install('game.apk', self.info, self.info['package'], False, 'New name', 'test') + return result, ssh, api, meta + + def test_existing_shortcut_refreshes_name_vr_and_every_slot(self): + result, ssh, api, meta = self.install(self.existing) + calls = [c.args for c in api.call_args_list] + self.assertNotIn('add', [c[0] for c in calls]) + configure = next(c for c in calls if c[0] == 'configure') + self.assertEqual(configure[1:3], ('3346865537', 'New name')) + self.assertEqual(configure[6], '1') + self.assertEqual(set(json.loads(configure[7])), set(art.SLOTS)) + self.assertTrue(configure[5].endswith('/org.test.vr/artwork/icon.png')) + self.assertEqual(result['shortcut'], self.existing['shortcut']) + self.assertEqual(result['label'], 'New name') + self.assertEqual(result['library_warnings'], []) + self.assertEqual(len([c for c in ssh.call_args_list if isinstance(c.kwargs.get('input'), bytes)]), 5) + meta.assert_called_once() + + def test_first_install_adds_shortcut(self): + _, _, api, _ = self.install(None) + self.assertEqual([c.args[0] for c in api.call_args_list], ['add', 'render', 'configure']) + + def test_artwork_forwarded_through_patch(self): + artwork = {'hero': b'provided'} + with patch.object(android, 'apk_info', return_value={**self.info, 'repairable': True}), \ + patch.object(android, 'xr_compat_files', return_value={}), \ + patch.object(android, 'patch', return_value={'patched': ['launcher']}), \ + patch.object(android, '_install', return_value={}) as install: + android.install('x.apk', artwork=artwork) + self.assertIs(install.call_args.args[-1], artwork) + + def test_failed_new_install_removes_shortcut(self): + def tool(*args, **kwargs): + if args[0] == 'add': + return '3346865537' + if args[0] == 'render': + return json.dumps({'paths': {slot: '/tmp/' + slot + '.png' for slot in art.SLOTS}}) + if args[0] == 'configure': + raise android.FrameError('write failed') + return '{}' + with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \ + patch.object(android, 'read_meta', return_value=None), \ + patch.object(android, '_copy'), patch.object(android, 'ssh', return_value='/home/steamos') as ssh, \ + patch.object(android, 'shortcut_tool', side_effect=tool) as api: + with self.assertRaisesRegex(android.FrameError, 'write failed'): + android._install('x.apk', self.info, 'org.test.vr', False, None, None) + self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list]) + self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list)) + + def test_remove_keeps_data_when_requested_and_survives_steam_failure(self): + with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \ + patch.object(android, 'stop'), \ + patch.object(android, 'shortcut_tool', return_value='{"warnings": []}') as api, \ + patch.object(android, 'ssh') as ssh: + android.remove('org.test.vr', keep_data=True) + api.assert_called_once_with('remove', '3346865537') + ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr') + api.side_effect = android.FrameError('SharedJSContext not found: is the Steam client running?') + ssh.reset_mock() + result = android.remove('org.test.vr') + ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr ' + '.local/share/Steam/steamapps/compatdata/2800000001 ' + '.local/share/Steam/steamapps/shadercache/2800000001') + self.assertIn('Steam client running', result['library_warnings'][0]) + + def test_remove_waits_for_refresh_and_is_never_undone(self): + import threading + state = {'meta': dict(self.existing, flatscreen=False), 'shortcuts': {3346865537}} + in_refresh, release, added = threading.Event(), threading.Event(), [] + def meta(pkg): + if not state['meta']: + raise android.FrameError(pkg + ' is not installed') + return dict(state['meta']) + def ssh(cmd, input=None, **kw): + if cmd.startswith('rm -rf Applications/Android/org.test.vr'): + state['meta'] = None + return json.dumps({'icon_png': ''}) if cmd == 'python3 -' else '/home/steamos' + def tool(*args, **kw): + if args[0] == 'list': return json.dumps([{'appid': a} for a in state['shortcuts']]) + if args[0] == 'remove': state['shortcuts'].discard(int(args[1])); return '{"warnings": []}' + if args[0] == 'add': added.append(args); return '99' + if args[0] == 'render': return json.dumps({'paths': {s: '/tmp/' + s + '.png' for s in art.SLOTS}}) + return '{"warnings": []}' + def prepare(*args, **kw): + in_refresh.set(); release.wait(5) + return self.images, [] + with patch.object(android, '_meta_or_fail', side_effect=meta), patch.object(android, 'ssh', side_effect=ssh), \ + patch.object(android, 'shortcut_tool', side_effect=tool), patch.object(android, 'stop'), \ + patch.object(android, '_write_meta', side_effect=lambda d, m: state.__setitem__('meta', m)), \ + patch.object(android.frame_artwork, 'prepare', side_effect=prepare): + refresh = threading.Thread(target=android.refresh_art, args=('org.test.vr',), kwargs={'fill_only': True}) + refresh.start() + self.assertTrue(in_refresh.wait(5)) + remove = threading.Thread(target=android.remove, args=('org.test.vr',)) + remove.start() + remove.join(0.3) + self.assertTrue(remove.is_alive(), 'remove ran while a refresh was writing') + release.set(); refresh.join(5); remove.join(5) + self.assertIsNone(state['meta']); self.assertEqual(state['shortcuts'], set()) + with self.assertRaisesRegex(android.FrameError, 'not installed'): + android.refresh_art('org.test.vr', fill_only=True) # a queued backfill after removal + self.assertEqual(added, []) + + def test_stop_requests_steam_and_has_container_fallback(self): + with patch.object(android, '_meta_or_fail', return_value=self.existing), \ + patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \ + patch.object(android, 'ssh') as ssh: + android.stop('org.test.vr') + api.assert_called_once_with('stop', '3346865537') + self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0]) + + +class SteamAPITests(unittest.TestCase): + def test_artwork_api_enums_and_safe_serialization(self): + with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate: + shortcuts.configure(42, 'A "name"\n', '/path', '/start', '/icon', True, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) + js = evaluate.call_args.args[0] + self.assertIn('SetShortcutIsVR(id, true)', js) + self.assertIn('SetShortcutName(id, "A \\"name\\"\\n")', js) + self.assertIn('SetCustomArtworkForApp(id, data, ext, type)', js) + self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('SetCustomArtworkForApp(id, data, ext, type)')) + self.assertEqual(shortcuts.ASSETS, {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}) + self.assertIn('NewUnsavedCollection(name, undefined, [app])', js) + + def test_remove_clears_every_slot_before_shortcut(self): + with patch.object(shortcuts, 'evaluate', return_value={}) as evaluate: + shortcuts.remove(42) + js = evaluate.call_args.args[0] + self.assertIn('[0, 1, 2, 3]', js) + self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)')) + self.assertIn('const wanted = []', js) + self.assertNotIn('throw', js) # tidy-up failures are warnings; RemoveShortcut always runs + + def test_devkit_configure_leaves_vr_flag_and_uses_sideloaded(self): + slots = {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS} + with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate: + shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'}) + js = evaluate.call_args.args[0] + self.assertIn('if (null !== null && !fill)', js) + self.assertIn('const wanted = ["Sideloaded"]', js) + with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '', + json.dumps(slots), '{}']), \ + patch.object(shortcuts, 'configure', return_value={}) as configure, patch('builtins.print'): + shortcuts.main() + self.assertIsNone(configure.call_args.args[5]) + + def test_render_writes_jpeg_and_retries_oversized_photo_with_generated_art(self): + import base64 + png = base64.b64encode((FIXTURES / 'icon.png').read_bytes()).decode() + jpg = base64.b64encode((FIXTURES / 'icon.jpg').read_bytes()).decode() + huge = base64.b64encode(b'\xff\xd8\xff' + b'\0' * (12 * 1024 * 1024)).decode() + calls = [] + def evaluate(js, timeout=20): + calls.append((json.loads(js[js.rindex('renderLibraryArtwork(') + 21:-1]), timeout)) + hero = ['jpg', huge] if len(calls) == 1 else ['png', png] + return {'images': {'grid': ['jpg', jpg], 'wide': ['jpg', jpg], 'hero': hero, + 'logo': ['png', png], 'icon': ['png', png]}, 'warnings': []} + with tempfile.TemporaryDirectory() as tmp: + for name in ('icon.png', 'hero.jpg'): + Path(tmp, 'source-' + name).write_bytes((FIXTURES / ('icon.jpg' if name.endswith('jpg') else 'icon.png')).read_bytes()) + Path(tmp, 'hero.png').write_bytes(b'stale') + plan = Path(tmp, 'input.json') + plan.write_text(json.dumps({'label': 'Game', 'images': {'icon': str(Path(tmp, 'source-icon.png')), + 'hero': str(Path(tmp, 'source-hero.jpg'))}})) + with patch.object(shortcuts, 'evaluate', side_effect=evaluate): + result = shortcuts.render(str(plan)) + self.assertEqual(set(calls[0][0]['images']), {'icon', 'hero'}) + self.assertEqual(set(calls[1][0]['images']), {'icon'}) + self.assertEqual([c[1] for c in calls], [75, 75]) + self.assertTrue(result['paths']['grid'].endswith('grid.jpg')) + self.assertTrue(result['paths']['hero'].endswith('hero.png')) + self.assertIn('generated art used', result['warnings'][0]) + self.assertFalse(Path(tmp, 'hero.jpg').exists()) + self.assertEqual(Path(tmp, 'grid.jpg').read_bytes(), (FIXTURES / 'icon.jpg').read_bytes()) + + def test_stop_uses_exact_64_bit_game_id_string(self): + with patch.object(sys, 'argv', ['steam_shortcuts.py', 'stop', '3346865537']), \ + patch.object(shortcuts, 'evaluate') as evaluate, patch('builtins.print'): + shortcuts.main() + self.assertEqual(evaluate.call_args.args[0], 'SteamClient.Apps.TerminateApp("14374678025558032384", false)') + + +class SteamContextTests(unittest.TestCase): + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') + def test_collection_lifecycle_and_native_artwork_calls(self): + steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Before'}], 'compat_tools': {}, + 'collections': [{'name': 'Android', 'apps': [999]}]} + def evaluate(expression, timeout=20): + nonlocal steam + proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')], + input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True, + 'steam': steam}) + '\n', + text=True, capture_output=True, timeout=10, check=True) + reply = json.loads(proc.stdout) + self.assertNotIn('exceptionDetails', reply['result']) + steam = reply['steam'] + return reply['result']['result'].get('value') + with patch.object(shortcuts, 'evaluate', side_effect=evaluate): + result = shortcuts.configure(42, 'Game', '/exe', '/dir', '/icon', True, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) + self.assertEqual(result['warnings'], []) + self.assertTrue(steam['shortcuts'][0]['vr']) + self.assertEqual(set(steam['shortcuts'][0]['artwork']), {'0', '1', '2', '3'}) + self.assertEqual(steam['collections'], [{'name': 'Android', 'apps': [999, 42]}, + {'name': 'Android VR', 'apps': [42]}]) + shortcuts.configure(42, 'Renamed', '/exe', '/dir', '/icon', False, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}) + self.assertEqual(steam['shortcuts'][0]['name'], 'Renamed') + self.assertEqual(steam['collections'][1]['apps'], []) + with patch.object(sys, 'argv', ['steam_shortcuts.py', 'list']), patch('builtins.print') as out: + shortcuts.main() + self.assertEqual(json.loads(out.call_args.args[0]), + [{'appid': 42, 'name': 'Renamed', 'exe': '/exe', 'start_dir': '/dir'}]) + shortcuts.remove(42) + self.assertEqual(steam['shortcuts'], []) + self.assertEqual(steam['collections'][0]['apps'], [999]) + + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') + def test_fill_only_keeps_customised_name_icon_and_art(self): + custom = {'0': {'data': 'mine-grid', 'ext': 'png'}, '1': {'data': 'mine-hero', 'ext': 'jpg'}} + steam = {'apps': [], 'compat_tools': {}, 'collections': [], + 'shortcuts': [{'appid': 42, 'name': 'My Name', 'icon': '/mine.png', 'vr': True, 'artwork': dict(custom)}]} + def evaluate(expression, timeout=20): + nonlocal steam + proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')], + input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True, + 'steam': steam}) + '\n', + text=True, capture_output=True, timeout=10, check=True) + reply = json.loads(proc.stdout) + self.assertNotIn('exceptionDetails', reply['result']) + steam = reply['steam'] + return reply['result']['result'].get('value') + with tempfile.TemporaryDirectory() as home: + grid = Path(home, '.local/share/Steam/userdata/1/config/grid') + grid.mkdir(parents=True) + (grid / '42p.png').write_bytes(b'mine') + (grid / '42_hero.jpg').write_bytes(b'mine') + with patch.dict(os.environ, {'HOME': home, 'USERPROFILE': home}), patch.object(shortcuts, 'evaluate', side_effect=evaluate): + self.assertEqual(shortcuts.custom_art(42), {0, 1}) + shortcuts.configure(42, 'Generated', '/exe', '/dir', '/generated.png', False, + {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}, + {'category': 'Sideloaded', 'fill_only': True}) + s = steam['shortcuts'][0] + self.assertEqual((s['name'], s['icon'], s['vr']), ('My Name', '/mine.png', True)) + self.assertEqual({k: s['artwork'][k] for k in ('0', '1')}, custom) + self.assertEqual(set(s['artwork']), {'0', '1', '2', '3'}) + + @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node') + def test_remove_without_collections_or_artwork_api_still_removes(self): + steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Game', 'exe': '"/home/steamos/devkit-game/G/g"', + 'start_dir': '/home/steamos/devkit-game/G'}], 'compat_tools': {}} + def evaluate(expression, timeout=20): + nonlocal steam + expression = ('delete globalThis.collectionStore;' + 'SteamClient.Apps.ClearCustomArtworkForApp = async () => { throw Error("busy"); };' + expression) + proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')], + input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True, + 'steam': steam}) + '\n', + text=True, capture_output=True, timeout=10, check=True) + reply = json.loads(proc.stdout) + self.assertNotIn('exceptionDetails', reply['result']) + steam = reply['steam'] + return reply['result']['result'].get('value') + with patch.object(shortcuts, 'evaluate', side_effect=evaluate): + result = shortcuts.remove(42) + self.assertEqual(steam['shortcuts'], []) + self.assertEqual(len(result['warnings']), 5) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_library_entrypoints.py b/tests/test_library_entrypoints.py new file mode 100644 index 0000000..2796353 --- /dev/null +++ b/tests/test_library_entrypoints.py @@ -0,0 +1,368 @@ +"""Every public sideload entry point reaches the mandatory five-slot writer.""" +import contextlib +import io +import json +import shutil +import subprocess +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android +import frame_artwork as artwork +import frame_catalog as catalog +import frame_apk_versions as versions +import frame_titles as titles +import frame_steamgriddb as sgdb +import server +import frame_webinstall as webinstall + + +class EntryPoints(unittest.TestCase): + def setUp(self): + self.stack = contextlib.ExitStack() + self.addCleanup(self.stack.close) + self.info = {'package':'org.example.game', 'label':'Example', 'version':'1', 'version_code':1, + 'vr':False, 'repairable':False, 'launchable':True, 'min_sdk':24, 'abis':[], 'icon_png':None} + self.stack.enter_context(patch.object(android, 'apk_info', return_value=self.info)) + self.stack.enter_context(patch.object(android, 'read_meta', return_value=None)) + self.stack.enter_context(patch.object(android, '_copy')) + self.stack.enter_context(patch.object(android, 'ssh', return_value='/home/steamos')) + self.stack.enter_context(patch.object(artwork, 'prepare', return_value=({}, []))) + self.api = self.stack.enter_context(patch.object(android, 'shortcut_tool', side_effect=self.steam)) + + def steam(self, *args, **kwargs): + if args[0] == 'add': return '3346865537' + if args[0] == 'render': return json.dumps({'paths': {s:'/tmp/'+s+'.png' for s in artwork.SLOTS}}) + if args[0] == 'list': return json.dumps([{'appid':3346865537,'name':'Example','devkit_gameid':'Example'}]) + return '{"warnings":[]}' + + def assert_art(self): + calls = [c.args for c in self.api.call_args_list] + self.assertEqual(sum(c[0] == 'render' for c in calls), 1) + configs = [c for c in calls if c[0] == 'configure'] + self.assertEqual(len(configs), 1) + self.assertEqual(set(json.loads(configs[0][7])), set(artwork.SLOTS)) + + def test_cli_install(self): + with patch.object(sys, 'argv', ['frame_android.py', 'install', 'game.apk']), patch('builtins.print'): + android.main() + self.assert_art() + + def test_file_upload(self): + class Request: + headers = {'X-Filename':'game.apk','X-Mode':'apk','Content-Length':'3'} + rfile = io.BytesIO(b'apk') + with patch.object(server, 'ensure_master'): + result = server.Handler.upload(Request()) + self.assertEqual(result['app']['package'], self.info['package']) + self.assert_art() + + def test_catalogue_install(self): + with patch.object(catalog, 'app', return_value={'r':'yes','t':False,'n':'Example'}), \ + patch.object(catalog, 'fetch_apk', return_value='game.apk'), \ + patch.object(catalog, 'fetch_icon', return_value=None): + catalog.install(self.info['package']) + self.assert_art() + + def test_version_finder_install(self): + record = {'url':'https://example.org/app.apk','sha256':'fixture','version_code':1,'source':'F-Droid'} + with patch.object(versions, '_versions', return_value=([record], [])), \ + patch.object(catalog, 'fetch_apk', return_value='game.apk'): + versions.install(self.info['package'], record['url']) + self.assert_art() + + def test_web_download_install(self): + result = webinstall.dispatch('game.apk', source='https://example.org/game.apk') + self.assertEqual(result['kind'], 'apk') + self.assert_art() + + def test_refresh_api_covers_android_apps_and_titles(self): + with patch.object(server, 'ensure_master'), \ + patch.object(server, 'start_job', side_effect=lambda label, work: work()), \ + patch.object(android, 'refresh_art', return_value=[]) as refresh, \ + patch.object(titles, 'refresh_art', return_value=[{'id':'G','error':'x'}]) as title_refresh: + self.assertEqual(server.android({'action':'refresh-art', 'all':True}), + {'apps':[], 'titles':[{'id':'G','error':'x'}]}) + refresh.assert_called_once_with(); title_refresh.assert_called_once_with() + server.titles({'action':'refresh-art', 'id':'G'}) + title_refresh.assert_called_with('G') + + def test_backfill_fills_only_entries_pending_since_install(self): + import threading + ran = threading.Event() + with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \ + patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \ + patch.object(titles, 'refresh_art', side_effect=lambda gid, **kw: ran.set()) as title_refresh: + apps = [{'package':'org.a.x','art_pending':True}, {'package':'org.b.x','art_pending':True}, + {'package':'org.c.x','art_missing':True}] # legacy: no record of art, but not pending + with contextlib.redirect_stderr(io.StringIO()): + self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_pending':True}])) + self.assertTrue(ran.wait(5)) + self.assertFalse(server.backfill_art(apps=apps)) # throttled + self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)]) + self.assertTrue(all(c.kwargs == {'fill_only': True} for c in refresh.call_args_list)) + title_refresh.assert_called_once_with('G', fill_only=True) + + def test_bulk_fill_only_refresh_keeps_names_and_art(self): + meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False} + with patch.object(android, 'list_apps', return_value=[{'package':self.info['package']}]), \ + patch.object(android, '_meta_or_fail', return_value=meta), \ + patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \ + patch.object(android, '_write_meta'): + android.refresh_art(fill_only=True) + options = json.loads(next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')[8]) + self.assertTrue(options['fill_only']) + self.api.reset_mock() + with patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'Game','frame_control':True}]), \ + patch.object(titles, '_check_id', side_effect=lambda gid: gid), \ + patch.object(titles, '_library_shortcut', return_value=7), \ + patch.object(titles, 'ssh', side_effect=lambda cmd, **kw: '{"name":"Game"}' if cmd.startswith('cat devkit') + else '{"artwork":{},"icon":""}' if cmd == 'python3 -' else '/home/steamos'): + titles.refresh_art(fill_only=True) + options = json.loads(next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')[8]) + self.assertTrue(options['fill_only']) + + def test_upgrade_leaves_legacy_customised_titles_alone(self): + # A title installed before art_pending existed, whose art the user has customised in Steam. + legacy = [{'id':'Game','settings':{'compat_tool':'proton-experimental'},'argv':['game.exe'], + 'meta':{'name':'Game','target':'game.exe','source':'game.zip'}}] + with patch.object(titles, 'ssh', return_value=json.dumps(legacy)): + listed = titles.list_titles() + self.assertEqual((listed[0]['art_pending'], listed[0]['art_missing']), (False, False)) + with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \ + patch.object(titles, 'refresh_art') as refresh, patch.object(android, 'refresh_art') as app_refresh: + self.assertFalse(server.backfill_art(apps=[{'package':'org.old.app','library_version':1}], titles=listed)) + refresh.assert_not_called(); app_refresh.assert_not_called() + self.api.assert_not_called() + + def test_art_missing_flags(self): + self.assertTrue(android.art_missing({'artwork': {}})) + self.assertTrue(android.art_missing({'artwork': {'grid': 'x'}})) + self.assertFalse(android.art_missing({'artwork': {s: 'x' for s in artwork.SLOTS}})) + + def test_source_search_shared_installer_contract(self): + # Source workers hand their download and optional images to this public seam. + android.install('game.apk', name='Example', source='source-search', artwork={'banner': b'fixture'}) + self.assert_art() + + def test_native_title_install(self): + with tempfile.TemporaryDirectory() as root: + plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe', + 'runtime':'proton-experimental','source':'example.zip'} + def ssh(cmd, **kwargs): + if 'test -d' in cmd: return '' + if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}' + if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}' + return '' + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True): + result = titles._install(plan, lambda *args: None) + self.assertEqual(result['shortcut'], 3346865537) + self.assert_art() + config = next(c.args for c in self.api.call_args_list if c.args[0] == 'configure') + self.assertEqual(json.loads(config[8])['category'], 'Sideloaded') + self.assertEqual(config[3:5], ('','')) # Never replace devkit's executable/runtime wiring. + self.assertEqual(config[6], '') # nor the VR flag the title declares + self.assertEqual(set(result['artwork']), set(artwork.SLOTS)) + + def test_native_renamed_shortcut_uses_saved_identity(self): + self.api.side_effect = lambda *args, **kw: '[{"appid":42,"name":"Renamed"}]' + with patch.object(titles, 'ssh', return_value='{"shortcut":42}'): + self.assertEqual(titles._library_shortcut('Original', '/home/steamos/devkit-game/Original'), 42) + + def test_native_shortcut_never_matched_by_name_alone(self): + d = '/home/steamos/devkit-game/Game' + shortcuts = [{'appid':1,'name':'Game','exe':'"/home/steamos/.local/bin/game"','start_dir':'/home/steamos'}, + {'appid':2,'name':'Other','exe':'"/home/steamos/devkit-game/Game2/g.exe"','start_dir':''}] + self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts) + with patch.object(titles, 'ssh', return_value=''): + self.assertIsNone(titles._library_shortcut('Game', d)) + shortcuts.append({'appid':3,'name':'Renamed','exe':'"/home/steamos/devkit-game/Game/bin/g.exe"','start_dir':''}) + self.assertEqual(titles._library_shortcut('Game', d), 3) + shortcuts.append({'appid':4,'name':'Copy','exe':'','start_dir':d}) + with self.assertRaisesRegex(android.FrameError, 'ambiguous'): + titles._library_shortcut('Game', d) + + def test_native_cleanup_failure_keeps_original_error(self): + with tempfile.TemporaryDirectory() as root: + plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe', + 'runtime':'proton-experimental','source':'example.zip'} + def ssh(cmd, **kwargs): + if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}' + if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}' + return '' + def steam(*args, **kwargs): + if args[0] == 'remove': raise android.FrameError('Steam went away') + return self.steam(*args) + self.api.side_effect = steam + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \ + patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')): + with self.assertRaisesRegex(android.FrameError, 'render failed'): + titles._install(plan, lambda *args: None) + + def test_native_remove_survives_steam_being_down(self): + cmds = [] + def ssh(cmd, **kwargs): + cmds.append(cmd) + return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else '' + self.api.side_effect = android.FrameError('SharedJSContext not found') + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'): + titles.remove('Game') + self.assertTrue(any('steamos-delete --delete-title Game' in c for c in cmds)) + + def test_native_remove_deletes_before_tidying_the_shortcut(self): + # steamos-delete finds the Proton prefix through the shortcut, so the shortcut must still exist. + order = [] + def ssh(cmd, **kwargs): + if 'steamos-delete' in cmd: + order.append('delete') + return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else '' + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_library_shortcut', return_value=42), \ + patch.object(titles.frame_android, 'shortcut_tool', side_effect=lambda *a: order.append(a)): + titles.remove('Game') + self.assertEqual(order, ['delete', ('remove', '42')]) + + def test_native_refresh_art_backfills_registered_title(self): + meta = {'id':'Game','name':'My Game','source':'game.zip'} + writes = [] + def ssh(cmd, input=None, **kwargs): + if 'test -d' in cmd: return 'yes' + if 'HOME' in cmd: return '/home/steamos' + if cmd.startswith('cat devkit-game/Game-framecontrol.json'): return json.dumps(meta) + if cmd == 'python3 -': + self.assertIn("/home/steamos/devkit-game/Game/.frame-artwork", input) + return json.dumps({'artwork': {'banner': 'YmFubmVy'}, 'icon': ''}) + if cmd.startswith('cat > devkit-game/Game-framecontrol.json'): writes.append(json.loads(input)) + return '' + shortcuts = [{'appid':7,'name':'My Game','exe':'','start_dir':'/home/steamos/devkit-game/Game'}] + self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts) if args[0] == 'list' else self.steam(*args) + with patch.object(titles, 'ssh', side_effect=ssh), \ + patch.object(artwork, 'prepare', return_value=({}, [])) as prepare: + result = titles.refresh_art('Game') + self.assertEqual(prepare.call_args.args[2], {'banner': b'banner'}) + self.assertEqual(result['shortcut'], 7) + self.assertEqual(set(writes[-1]['artwork']), set(artwork.SLOTS)) + self.assert_art() + shortcuts.clear() + with patch.object(titles, 'ssh', side_effect=ssh), \ + patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'My Game','frame_control':True}, + {'id':'Valve','name':'V','frame_control':False}]): + results = titles.refresh_art() + self.assertEqual(len(results), 1) + self.assertIn("hasn't registered", results[0]['error']) + + def test_native_failure_removes_new_blank_shortcut(self): + with tempfile.TemporaryDirectory() as root: + plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe', + 'runtime':'proton-experimental','source':'example.zip'} + def ssh(cmd, **kwargs): + if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}' + if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}' + return '' + with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \ + patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \ + patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')): + with self.assertRaisesRegex(android.FrameError, 'render failed'): + titles._install(plan, lambda *args: None) + self.assertIn(('remove', '3346865537'), [c.args for c in self.api.call_args_list]) + + def test_refresh_does_not_reinstall_or_stop(self): + meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False} + with patch.object(android, '_meta_or_fail', return_value=meta), \ + patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \ + patch.object(android, 'stop') as stop, patch.object(android, '_copy') as copy: + android.refresh_art(self.info['package']) + stop.assert_not_called(); copy.assert_not_called(); self.assert_art() + + def test_all_refresh_reports_partial_failures(self): + import http.client + with patch.object(android, 'list_apps', return_value=[{'package':'org.a.game'},{'package':'org.b.game'}]), \ + patch.object(android, '_meta_or_fail', side_effect=[http.client.RemoteDisconnected('gone'), + AttributeError('odd')]): + result=android.refresh_art() + self.assertEqual(len(result),2) + self.assertTrue(all('error' in a for a in result)) + + def test_render_failure_cannot_report_success(self): + self.api.side_effect = lambda *args, **kw: '3346865537' if args[0]=='add' else '{"paths":{}}' + with self.assertRaisesRegex(android.FrameError,'every slot'): + android.install('game.apk') + + +class Renderer(unittest.TestCase): + @unittest.skipUnless(shutil.which('node'), 'Node is needed for the canvas contract fixture') + def test_canvas_slots_and_title_placement(self): + subprocess.run(['node', str(ROOT / 'tests/fixtures/library/check-renderer.js')], + cwd=str(ROOT), check=True, capture_output=True, timeout=30) + + def test_desktop_packages_include_renderer_and_settings(self): + config = json.loads((ROOT / 'app/package.json').read_text()) + # Single-file resources (licenses/notices) do not need a filter. + resources = {r['from']: r.get('filter', ['**/*']) for r in config['build']['extraResources']} + self.assertIn('*.js', resources['../ui']) + self.assertIn('*.js', resources['../frame/android']) + + +class SteamGridDB(unittest.TestCase): + def test_no_key_is_silent_and_offline(self): + with patch.object(sgdb,'api_key',return_value=''), patch.object(sgdb,'_get') as get: + self.assertEqual(sgdb.lookup('Game'),({},[])) + get.assert_not_called() + + def test_exact_match_and_top_votes_per_slot(self): + calls=[] + def get(path,key,deadline=None): + calls.append(path) + if 'search' in path: return [{'id':1,'name':'Other Game'},{'id':2,'name':'Game'}] + dims=(600,900) if '600x900' in path else (920,430) + return [{'url':'https://example.org/low.png','score':2,'width':dims[0],'height':dims[1]}, + {'url':'https://example.org/top.png','score':20,'width':dims[0],'height':dims[1]}, + {'url':'https://example.org/nsfw.png','score':99,'nsfw':True,'width':dims[0],'height':dims[1]}] + with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=get): + images,warnings=sgdb.lookup('Game VR') + self.assertEqual(set(images),set(artwork.SLOTS));self.assertEqual(warnings,[]) + self.assertTrue(all(url.endswith('/top.png') for url in images.values())) + self.assertTrue(all('/game/2?' in p for p in calls[1:])) + # SteamGridDB rejects JPEG in logo/icon queries (the live API returned an error for SuperTux). + for p in calls[1:]: + jpeg = 'image%2Fjpeg' in p + self.assertEqual(jpeg, p.startswith(('/grids/', '/heroes/')), p) + + def test_unicode_titles_match_exactly_and_symbols_never_match_all(self): + self.assertEqual(sgdb._name('ビートセイバー VR!'), 'ビートセイバーvr') + with patch.object(sgdb,'api_key',return_value='test-key'), \ + patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'},{'id':2,'name':'!!!'}]) as get: + self.assertEqual(sgdb.lookup('★★★'),({},[])) + get.assert_not_called() + self.assertEqual(sgdb.lookup('ビートセイバー'),({},[])) + with patch.object(sgdb,'api_key',return_value='test-key'), \ + patch.object(sgdb,'_get',side_effect=AttributeError("'list' object has no attribute 'get'")): + self.assertEqual(sgdb.lookup('Game')[0],{}) + + def test_wrong_title_and_failed_lookup_fall_back(self): + with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'}]): + self.assertEqual(sgdb.lookup('Game'),({},[])) + with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=OSError('private-secret')): + result=sgdb.lookup('Game') + self.assertNotIn('private-secret',str(result));self.assertEqual(result[0],{}) + + def test_settings_never_return_key(self): + with tempfile.TemporaryDirectory() as root, patch.object(sgdb,'settings_path',return_value=Path(root)/'settings.json'), \ + patch.dict(sgdb.os.environ,{},clear=True): + result=sgdb.save_settings({'steamgriddb_api_key':'secret-fixture'}) + self.assertTrue(result['steamgriddb_configured']) + self.assertNotIn('secret-fixture',json.dumps(result)) + self.assertEqual(sgdb.api_key(),'secret-fixture') + if sys.platform!='win32':self.assertEqual((Path(root)/'settings.json').stat().st_mode&0o777,0o600) + sgdb.save_settings({'steamgriddb_api_key':''}) + self.assertFalse(sgdb.settings()['steamgriddb_configured']) + + +if __name__=='__main__':unittest.main() diff --git a/tests/test_link.py b/tests/test_link.py new file mode 100644 index 0000000..782306e --- /dev/null +++ b/tests/test_link.py @@ -0,0 +1,695 @@ +"""frame_link: finding a headset among its addresses and following each stage of +connecting, with a stand-in ssh (tests/fakessh/ssh) and real sockets on this computer. +Also the server's /api/connection, its event stream, and /api/devices. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import http.client +import json +import os +import shutil +import socket +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_devices as fd # noqa: E402 +import frame_link as fl # noqa: E402 +import frame_network as fn # noqa: E402 + +FAKESSH = ROOT / "tests" / "fakessh" +NET = {"id": "n-test", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "interface": "en0", + "ssid": None, "wifi": True, "local_ip": "192.168.1.9", "tailscale": {"up": False, "installed": False}} + + +def explain(msg): + """A cut-down server.unreachable, so this needs no server import.""" + if "Could not resolve" in msg: + return "Can't find the Frame on the network." + if "refused" in msg: + return "The Frame refused the connection." + if "timed out" in msg.lower(): + return "The Frame isn't answering." + if "Permission denied" in msg: + return "The Frame didn't accept this computer's SSH key." + return None + + +class Probe(unittest.TestCase): + def test_answers_refusals_and_unknown_names(self): + with socket.socket() as srv: + srv.bind(("127.0.0.1", 0)) + srv.listen(4) + port = srv.getsockname()[1] + seen = [] + res = fl.probe("127.0.0.1", port, update=lambda **f: seen.append(f["state"])) + self.assertEqual(res["state"], "answered") + self.assertEqual(res["ip"], "127.0.0.1") + self.assertIsInstance(res["rtt_ms"], float) + self.assertEqual(seen, ["resolving", "trying"]) + # Closed now. Windows retries a refused connect for about 2 s before saying so. + self.assertEqual(fl.probe("127.0.0.1", port, timeout=6 if os.name == "nt" else 2)["state"], "refused") + self.assertEqual(fl.probe("frame-control-test.invalid", 22, timeout=2)["state"], "unresolved") + + def test_failed_probes_read_like_ssh(self): + # So the server's UNREACHABLE table words them like any other ssh failure. + self.assertIn("Could not resolve hostname x", fl.probe_raw("x", 22, {"state": "unresolved"})) + self.assertIn("port 22: Connection refused", fl.probe_raw("x", 22, {"state": "refused"})) + self.assertIn("Operation timed out", fl.probe_raw("x", 22, {"state": "timeout"})) + + +class Pick(unittest.TestCase): + def pick(self, results, tried=()): + return fl.Link.pick(results, set(tried), threading.Condition(), time.monotonic() + 5) + + def test_best_ranked_answer_wins(self): + now = time.monotonic() + ok = lambda t=now: {"state": "answered", "t": t} + no = {"state": "timeout", "t": now} + self.assertEqual(self.pick([no, ok(), ok()]), 1) + self.assertEqual(self.pick([no, ok(), ok()], tried=[1]), 2) + self.assertIsNone(self.pick([no, no])) + # A worse-ranked answer waits PREFER for a better one still trying, then goes. + t0 = time.monotonic() + self.assertEqual(self.pick([None, ok(time.monotonic())]), 1) + self.assertGreaterEqual(time.monotonic() - t0, fl.PREFER - 0.05) + + def test_gives_up_on_slow_lookups_at_the_deadline(self): + t0 = time.monotonic() + results = [None] + self.assertIsNone(fl.Link.pick(results, set(), threading.Condition(), time.monotonic() + 0.3)) + self.assertLess(time.monotonic() - t0, 2) + self.assertEqual(results[0]["state"], "timeout") + + +@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script") +class Connecting(unittest.TestCase): + def setUp(self): + self.dir = Path(tempfile.mkdtemp(prefix="frame-link-")) + self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) + (self.dir / "ssh").mkdir() + self.log = self.dir / "calls.jsonl" + env = {"FRAME_CONTROL_SSH_DIR": str(self.dir / "ssh"), "FAKESSH_LOG": str(self.log), + "FAKESSH_DIR": str(self.dir), "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"} + patcher = mock.patch.dict(os.environ, env) + patcher.start() + self.addCleanup(patcher.stop) + for name, value in (("current_network", lambda *a, **k: dict(NET)), ("fingerprint", lambda: ("192.168.1.1", "en0", "aa:bb:cc:dd:ee:ff"))): + p = mock.patch.object(fn, name, value) + p.start() + self.addCleanup(p.stop) + self.srv = socket.socket() + self.srv.bind(("127.0.0.1", 0)) + self.srv.listen(16) + self.addCleanup(self.srv.close) + self.port = self.srv.getsockname()[1] + self.reg = fd.Registry(self.dir / "devices.json") + self.routes = [] + self.link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes", "-o", "ControlPath=x"], + control="x", apply=lambda alias, opts: self.routes.append((alias, list(opts))), + explain=explain) + self.addCleanup(self.link.stop) + + def test_start_routes_to_the_saved_headset_before_serving(self): + a = self.reg.add_device("frame", hosts=["192.0.2.1"]) + b = self.reg.add_device("frame-2", hosts=["192.0.2.2"]) + self.reg.set_active(b["id"]) + with mock.patch.object(self.link, "run", lambda: None): # no connector: only what start() applies + self.link.start() + self.assertEqual(self.routes[0][0], "frame-2") + self.assertIn("HostName=192.0.2.2", self.routes[0][1]) + self.assertNotEqual(a["id"], b["id"]) + + def test_headset_removed_elsewhere_mid_install_reaches_nothing(self): + a = self.reg.add_device("frame", hosts=["192.0.2.1"]) + self.reg.add_device("frame-2", hosts=["192.0.2.2"]) + self.reg.set_active(a["id"]) + other = fd.Registry(self.dir / "devices.json") # another Frame Control server + other.remove_device(a["id"]) + self.link.work = lambda: 1 + self.assertTrue(self.link.active_device().get("none")) + self.link.work = lambda: 0 + self.assertEqual(self.link.active_device()["alias"], "frame-2") # idle: move on + + def test_nothing_elsewhere_moves_a_running_install(self): + """A bare alias in use, then another server sets up and picks a headset.""" + self.link.override = None + self.hosts({"localhost": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)): + self.link.connect(["start"]) + started = self.routes[-1] + other = fd.Registry(self.dir / "devices.json") + other.set_active(other.add_device("frame-2", hosts=["192.0.2.2"])["id"]) + self.link.work = lambda: 1 + with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)): + self.link.close_master() + self.link.connect(["dropped"]) + self.assertEqual(self.routes[-1][0], started[0]) + self.assertTrue(self.link.deferred) + + def listen6(self): + """A "different device": the same port on IPv6 loopback.""" + try: + six = socket.socket(socket.AF_INET6) + self.addCleanup(six.close) + six.bind(("::1", self.port)) + six.listen(4) + except OSError: + self.skipTest("no IPv6 loopback") + + def pin(self, device_id): + fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True) + fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n") + + def hosts(self, mapping): + # An IPv4 answer is what ssh is pointed at, so localhost arrives as 127.0.0.1. + if "localhost" in mapping: + mapping = dict({"127.0.0.1": mapping["localhost"]}, **mapping) + os.environ["FAKESSH_HOSTS"] = json.dumps(mapping) + + def calls(self): + return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else [] + + def device(self, *hosts): + d = self.reg.add_device("frame-t", port=self.port, hosts=[]) + for h in hosts: + self.reg.add_address(d["id"], h, kind="lan") + return d + + def test_falls_through_to_the_address_that_is_really_the_headset(self): + # Tried in this order: a name that doesn't resolve, a different device, the headset. + self.listen6() + d = self.device("nothing.invalid", "::1", "127.0.0.1") + self.hosts({"::1": "wrong", "127.0.0.1": "ok"}) + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertEqual(s["via"]["host"], "127.0.0.1") + self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5) + rows = {p["host"]: p for p in s["probes"]} + self.assertEqual(rows["nothing.invalid"]["state"], "unresolved") + self.assertEqual(rows["::1"]["state"], "sshfailed") + self.assertIn("different headset", rows["::1"]["detail"]) + # Every ssh command was pointed at the winner, with the host key pinned per device. + alias, opts = self.routes[-1] + self.assertEqual(alias, "frame-t") + self.assertIn("HostName=127.0.0.1", opts) + self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts) + self.assertIn(f"Port={self.port}", opts) + master = [c for c in self.calls() if "ControlMaster=yes" in c][-1] + self.assertIn("StrictHostKeyChecking=accept-new", master) # first connection: nothing pinned yet + # ssh takes an option's first value: accept-new must come before the commands' own "yes". + self.assertLess(master.index("StrictHostKeyChecking=accept-new"), master.index("StrictHostKeyChecking=yes")) + self.assertIn("StrictHostKeyChecking=yes", opts) # every other command checks the pinned key + self.assertTrue(fd.known_hosts(d["id"]).parent.is_dir()) # where ssh saves the key it accepts + # It learned: 127.0.0.1 works on this network. + learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]} + self.assertEqual(learned["127.0.0.1"]["networks"], ["n-test"]) + self.assertEqual(learned["::1"]["networks"], []) + self.assertTrue(self.link.alive()) + self.link.close_master() + self.assertFalse(any(p.name.startswith("master-") for p in self.dir.iterdir())) + + def test_stages_are_published_as_they_happen(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + steps, versions = [], [] + real = self.link.stage + + def stage(sid, state, detail=None): + real(sid, state, detail) + steps.append((sid, state)) + versions.append(self.link.snapshot()["version"]) + self.link.stage = stage + before = self.link.snapshot()["version"] + self.assertIsNone(self.link.wait(before, 0.05)) # nothing new yet + self.link.connect(["start"]) + started = [sid for sid, state in steps if state == "active"] + self.assertEqual(list(dict.fromkeys(started)), ["network", "find", "ssh", "identity", "login"]) + self.assertEqual([sid for sid, state in steps if state == "done"][-3:], ["ssh", "identity", "login"]) + self.assertEqual(versions, sorted(versions)) # every step is a new version for the page + self.assertEqual(self.link.wait(before, 1)["phase"], "connected") + + def test_nothing_answers(self): + self.device("nothing.invalid", "also-nothing.invalid") + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "failed") + self.assertEqual(s["error"]["stage"], "find") + self.assertEqual(s["error"]["message"], "Can't find the Frame on the network.") + self.assertGreater(s["retry_at"], time.time()) + self.assertEqual([st["state"] for st in s["stages"]][:2], ["done", "failed"]) + + def test_refused_key_stops_at_login(self): + self.device("localhost") + self.hosts({"localhost": "denied"}) + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login")) + self.assertIn("SSH key", s["error"]["message"]) + + def test_pinned_identity_is_checked_strictly(self): + d = self.device("localhost") + self.pin(d["id"]) + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + master = [c for c in self.calls() if "ControlMaster=yes" in c][-1] + self.assertIn("StrictHostKeyChecking=yes", master) + + def test_ssh_goes_to_the_ipv4_address_that_answered(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["via"]["host"], s["via"]["ip"]), ("connected", "localhost", "127.0.0.1")) + self.assertIn("HostName=127.0.0.1", self.routes[-1][1]) + + def test_no_headset_after_removing_them_all(self): + d = self.device("localhost") + self.reg.remove_device(d["id"]) + self.link.connect(["switch"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["device"]["id"], s["retry_at"]), ("failed", "none", None)) + self.assertIn("No headset", s["error"]["message"]) + self.assertEqual(self.routes[-1], ("frame-control-no-headset", ["-o", "HostName=no-headset.invalid"])) + + def test_a_headset_without_addresses_reaches_nothing(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + fl.devices_action(self.link, {"action": "address-remove", "id": d["id"], "host": "localhost"}, None) + self.assertIn("HostName=no-address.invalid", self.routes[-1][1]) # at once, not after a retry + self.link.connect(["switch"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["retry_at"]), ("failed", None)) + self.assertIn("no addresses", s["error"]["message"]) + + def test_switching_back_to_the_frame_alias_the_server_started_with(self): + self.link.override = self.link.session_alias = "frame-bare" + other = self.device("localhost") + ids = [d["id"] for d in fl.devices_view(self.link)["devices"]] + self.assertEqual(ids, ["alias-frame-bare", other["id"]]) + fl.devices_action(self.link, {"action": "use", "id": other["id"]}, None) + self.assertIn("alias-frame-bare", [d["id"] for d in fl.devices_view(self.link)["devices"]]) # still there + fl.devices_action(self.link, {"action": "use", "id": "alias-frame-bare"}, None) + self.assertEqual(self.link.active_device()["alias"], "frame-bare") + self.assertEqual(self.routes[-1][0], "frame-bare") + + def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self): + d = self.device("localhost") + self.link.override = self.link.session_alias = "frame-t" + fl.devices_action(self.link, {"action": "remove", "id": d["id"], "config": True}, None) + self.assertNotIn("alias-frame-t", [x["id"] for x in fl.devices_view(self.link)["devices"]]) + + def test_setup_changing_the_login_waits_for_installs(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + cfg = self.dir / "ssh" / "config" + cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User steamos\n" + f" Port {self.port}\nHost *\n# <<< steam-frame (frame-t) <<<\n") + self.link.watch_config() # the block's login is recorded + routes = len(self.routes) + cfg.write_text(cfg.read_text().replace("User steamos", "User deck")) + running = [1] + self.link.work = lambda: running[0] + self.link.config_mtime = None + self.link.watch_config() + self.assertEqual(len(self.routes), routes) # an install is running: not yet + self.link.connect(["dropped"]) # a reconnect meanwhile keeps the login it started with + self.assertIn("User=steamos", self.routes[-1][1]) + running[0] = 0 + self.link.watch_config() + self.assertIn("User=deck", self.routes[-1][1]) + + def test_a_rename_leaves_the_login_in_the_config_alone(self): + d = self.device("localhost") + cfg = self.dir / "ssh" / "config" + cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User deck\n" + "Host *\n# <<< steam-frame (frame-t) <<<\n") # setup wrote a new user, not yet imported + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None) + self.assertIn("User deck", cfg.read_text()) + out = fl.devices_action(self.link, {"action": "update", "id": d["id"], "port": 2200}, None) + self.assertIn("User deck", cfg.read_text()) # changed meanwhile: left as it is + self.assertIn("left as it is", out["message"]) + + def test_a_late_failure_from_the_last_headset_is_ignored(self): + self.link.state["phase"] = "connected" + self.link.gen = 3 + self.link.lost("ssh: connect to host a port 22: Operation timed out", 2) # sent before the switch + self.assertEqual(self.link.kicks, []) + self.link.lost("ssh: connect to host b port 22: Operation timed out", 3) + self.assertEqual(len(self.link.kicks), 1) + + def test_a_jump_hosts_login_isnt_the_headsets(self): + opts = ["-o", "HostName=10.0.0.5"] + self.assertFalse(fl.Link.is_target('Authenticated to bastion ([1.2.3.4]:22) using "publickey".', opts, "frame")) + self.assertTrue(fl.Link.is_target('Authenticated to 10.0.0.5 ([10.0.0.5]:22) using "publickey".', opts, "frame")) + self.assertTrue(fl.Link.is_target('Authenticated to frame.local ([10.0.0.5]:22) using "publickey".', + ["-o", "HostName=FRAME.LOCAL"], "frame")) + + def test_a_forward_the_jump_host_couldnt_open_tries_the_next_address(self): + said = ["Authenticated to bastion ([1.2.3.4]:22) using \"publickey\".", + "channel 0: open failed: connect failed: Connection refused", "stdio forwarding failed"] + self.link.state["stages"] = [{"id": i, "state": "pending", "started": None, "ended": None, "detail": ""} + for i, _ in fl.STAGES] + self.assertEqual(self.link.failed("login", said, False, "frame"), "next") + self.assertEqual(self.link.failed("login", ["steamos@frame: Permission denied (publickey)."], False, "frame"), + "stop") + + def test_a_reconnect_being_started_isnt_a_live_connection(self): + self.link.state["phase"] = "connected" + self.assertTrue(self.link.alive()) + self.link.busy = True # the loop took a Retry off the queue and is about to reconnect + self.assertFalse(self.link.alive()) # so ensure() waits instead of starting work on it + + def test_probes_from_an_earlier_attempt_leave_the_new_rows_alone(self): + self.link.state.update(attempt=2, probes=[{"host": "b", "state": "waiting"}]) + self.link.probe_update(0, 1, state="answered", ip="10.0.0.2") + self.assertEqual(self.link.state["probes"][0], {"host": "b", "state": "waiting"}) + + def test_a_bare_alias_lets_ssh_config_decide(self): + self.link.override = "frame-bare" + self.hosts({"localhost": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)): + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertTrue(s["device"]["transient"]) + # Where ~/.ssh/config sends it, pinned down for the connection (ssh's own known_hosts). + alias, opts = self.routes[-1] + self.assertEqual((alias, opts[2:]), ("frame-bare", ["-o", "HostName=localhost", "-o", f"Port={self.port}", + "-o", "User=tester"])) + self.assertEqual(opts[:2], ["-o", "ControlPath=" + fl.frame_host.control_path(fl.Link.control_tag(s["device"]))]) + + def test_each_headset_has_its_own_shared_connection(self): + """ssh's %C hashes only address, user and port: two headsets at one address + (one moved) must still never share a ControlMaster.""" + a, b = (dict(fl.Link.bare("x"), id=i, transient=False, user="steamos", port=22) for i in ("aaaa1111", "bbbb2222")) + pa, pb = (next(o for o in self.link.host_opts(d, "192.0.2.5") if o.startswith("ControlPath=")) for d in (a, b)) + self.assertNotEqual(pa, pb) + self.assertIn("aaaa1111", pa) + long_alias = fl.Link.bare("x" * 64) + path = next(o for o in self.link.host_opts(long_alias, None) if o.startswith("ControlPath=")) + self.assertLess(len(path) - len("ControlPath=") - len("%C") + 40 + 17, 104) # fits a macOS socket path + + def test_a_bare_alias_keeps_its_pinned_route_for_reconnects_and_terminals(self): + self.link.override = "frame-bare" + self.hosts({"localhost": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)): + self.link.connect(["start"]) + pinned = self.routes[-1][1] + # ~/.ssh/config now sends the alias elsewhere, but an install is running. + self.link.work = lambda: 1 + with mock.patch.object(fl, "ssh_g", return_value=("elsewhere.invalid", 2222, "other", False)): + self.link.close_master() + self.link.connect(["dropped"]) + self.assertEqual(self.link.snapshot()["probes"][0]["host"], "localhost") # probed where commands go + self.assertEqual(self.link.snapshot()["phase"], "connected") + self.assertEqual(self.link.named_route(), ("frame-bare", pinned)) # terminals go there too + + def test_a_set_up_headset_behind_a_jump_host_is_left_to_ssh(self): + d = self.device("10.99.99.98", "10.99.99.99") # neither answers directly + self.hosts({"10.99.99.98": "wrong", "10.99.99.99": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)): + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual((s["phase"], s["via"]["host"]), ("connected", "10.99.99.99"), s["error"]) + self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", self.routes[-1][1]) # still pinned per headset + + def test_a_bare_alias_behind_a_jump_host_is_left_to_ssh(self): + self.link.override = "frame-jump" + self.hosts({"10.99.99.99": "ok"}) # ssh's ProxyJump would get there + with mock.patch.object(fl, "ssh_g", return_value=("10.99.99.99", 22, "tester", True)): + self.link.connect(["start"]) + s = self.link.snapshot() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertEqual(s["via"]["why"], "through a jump host") + + def test_changing_the_port_reroutes_even_if_the_attempt_fails(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + self.reg.update_device(d["id"], port=1) # nothing listens there + self.link.connect(["switch"]) + self.assertEqual(self.link.snapshot()["phase"], "failed") + self.assertIn("Port=1", self.routes[-1][1]) + + def test_test_now_checks_every_address_without_touching_the_connection(self): + self.listen6() + d = self.device("::1", "127.0.0.1", "nothing.invalid") + self.pin(d["id"]) + self.hosts({"::1": "wrong", "127.0.0.1": "ok"}) + self.link.test(d["id"]) + rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]} + self.assertEqual(rows["127.0.0.1"]["ssh"], "ok") + self.assertEqual(rows["::1"]["ssh"], "wrong") + self.assertEqual(rows["nothing.invalid"]["state"], "unresolved") + self.assertEqual(self.routes, []) + self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c)) + + def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + other = self.reg.add_device("frame-other", port=self.port) + self.reg.add_address(other["id"], "nothing.invalid") + self.link.use(other["id"]) + self.assertEqual(self.routes[-1][0], "frame-other") # at once, before any attempt + self.assertEqual(self.link.snapshot()["phase"], "connecting") + self.assertFalse(self.link.alive()) # so ensure() waits instead of using the old master + self.link.connect(["switch"]) + self.assertEqual(self.link.snapshot()["phase"], "failed") + alias, opts = self.routes[-1] + self.assertEqual(alias, "frame-other") + self.assertIn("HostName=nothing.invalid", opts) + self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts) + + def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + other = self.reg.add_device("frame-other", port=self.port) + self.reg.add_address(other["id"], "nothing.invalid") + pick = fl.Link.pick + + def switch_then_pick(*args): + if not getattr(self, "switched", False): + self.switched = True + self.link.use(other["id"]) # the user switches while A is being found + return pick(*args) + with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)): + self.link.connect(["start"]) + self.assertEqual(self.routes[-1][0], "frame-other") + self.assertEqual(self.link.snapshot()["phase"], "connecting") + self.assertFalse(self.link.alive()) + self.assertIsNone(self.link.master) + + def test_no_switching_while_something_is_installing(self): + d = self.device("localhost") + other = self.reg.add_device("frame-other") + for body in ({"action": "use", "id": other["id"]}, {"action": "remove", "id": d["id"]}, + {"action": "update", "id": d["id"], "port": 2222}, + {"action": "address-remove", "id": d["id"], "host": "localhost"}, + {"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"}, + {"action": "forget-identity", "id": d["id"]}): + with self.assertRaises(fd.DeviceError, msg=body): + fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1) + # Renaming, or changing another headset, is fine. + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None, busy=lambda: 1) + fl.devices_action(self.link, {"action": "update", "id": other["id"], "port": 2222}, None, busy=lambda: 1) + self.assertEqual(self.reg.get(d["id"])["name"], "Desk") + + def test_no_reconnecting_under_a_running_install(self): + self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + with self.assertRaises(fd.DeviceError): + fl.devices_action(self.link, {"action": "retry"}, None, busy=lambda: 1) + fl.devices_action(self.link, {"action": "retry"}, None) # fine when nothing runs + + def test_terminals_get_the_address_by_name(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + alias, opts = self.link.named_route() + self.assertEqual(alias, "frame-t") + self.assertIn("HostName=localhost", opts) + self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts) + + def test_renaming_during_an_install_is_fine(self): + d = self.device("localhost") + # The page sends the user and port along with the name, unchanged. + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk", "user": "steamos", + "port": str(self.port)}, None, busy=lambda: 1) + self.assertEqual(self.reg.get(d["id"])["name"], "Desk") + + def test_a_rename_shows_at_once(self): + d = self.device("localhost") + self.hosts({"localhost": "ok"}) + self.link.connect(["start"]) + fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None) + self.assertEqual(self.link.snapshot()["device"]["name"], "Desk") + + def test_stopping_mid_handshake_leaves_no_ssh_behind(self): + self.device("localhost") + self.hosts({"localhost": "slow"}) + t = threading.Thread(target=self.link.connect, args=(["start"],), daemon=True) + t.start() + for _ in range(100): + if self.link.pending: + break + time.sleep(0.05) + proc = self.link.pending + self.assertIsNotNone(proc) + self.link.stop() + t.join(10) + self.assertFalse(t.is_alive()) + self.assertIsNotNone(proc.poll()) + self.assertIsNone(self.link.master) + + def test_test_now_goes_through_a_jump_host(self): + d = self.device("10.99.99.99") + self.pin(d["id"]) + self.hosts({"10.99.99.99": "ok"}) + with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)): + self.link.test(d["id"]) + row = self.link.snapshot()["tests"][d["id"]]["rows"][0] + self.assertEqual(row["ssh"], "ok", row) + self.assertIn("jump host", row["detail"]) + + def test_devices_api_checks_everything(self): + d = self.device("localhost") + bad = [{"action": "address-add", "id": d["id"], "host": "-oProxyCommand=touch /tmp/x"}, + {"action": "address-add", "id": d["id"], "host": "a\nHost *"}, + {"action": "address-add", "id": d["id"], "host": "frame.local", "kind": "wifi"}, + {"action": "update", "id": d["id"], "user": "root; id"}, + {"action": "update", "id": d["id"], "port": 0}, + {"action": "address-move", "id": d["id"], "host": "localhost", "delta": 5}, + {"action": "setup", "alias": "-F/etc/passwd"}, + {"action": "setup", "alias": "frame-9", "host": "$(id)"}, + {"action": "use", "id": "nope"}, + {"action": "explode"}] + for body in bad: + with self.assertRaises(fd.DeviceError, msg=body): + fl.devices_action(self.link, body, open_setup=lambda *a: self.fail("setup ran")) + # Removing every headset leaves none in use, rather than falling back to the `frame` alias. + spare = self.reg.add_device("frame-spare") + fl.devices_action(self.link, {"action": "remove", "id": spare["id"]}, None) + # The only headset, whose ssh alias would stay: not without removing that too. + (self.dir / "ssh" / "config").write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n" + "Host *\n# <<< steam-frame (frame-t) <<<\n") + with self.assertRaises(fd.DeviceError): + fl.devices_action(self.link, {"action": "remove", "id": d["id"]}, None) + opened = [] + out = fl.devices_action(self.link, {"action": "setup", "alias": "frame-9", "host": "192.168.1.50"}, + open_setup=lambda alias, host: opened.append((alias, host)) or "a terminal") + self.assertEqual(opened, [("frame-9", "192.168.1.50")]) + self.assertIn("frame-9", out["message"]) + self.assertEqual(out["active"], d["id"]) + self.assertEqual(fl.next_alias(self.link), "frame") + (self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame` + self.assertEqual(fl.next_alias(self.link), "frame-2") + + +@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script") +class ServerConnection(unittest.TestCase): + """The real server, a Set Up Connection block in a stand-in ~/.ssh, and the stand-in ssh.""" + + @classmethod + def setUpClass(cls): + cls.dir = Path(tempfile.mkdtemp(prefix="frame-link-server-")) + ssh_dir = cls.dir / "ssh" + ssh_dir.mkdir() + cls.srv = socket.socket() # the "headset's" port 22 + cls.srv.bind(("127.0.0.1", 0)) + cls.srv.listen(16) + (ssh_dir / "config").write_text("# >>> steam-frame (frame) >>>\nHost frame\n HostName localhost\n" + f" Port {cls.srv.getsockname()[1]}\n" + " User steamos\nHost *\n# <<< steam-frame (frame) <<<\n") + env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", "FRAME_CONTROL_SSH_DIR": str(ssh_dir), + "FRAME_CONTROL_DATA_DIR": str(cls.dir / "data"), "FAKESSH_LOG": str(cls.dir / "calls.jsonl"), + "FAKESSH_DIR": str(cls.dir), "FAKESSH_HOSTS": json.dumps({"localhost": "ok", "127.0.0.1": "ok"}), + "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"} + env.pop("FRAME_ALIAS", None) + cls.log = tempfile.TemporaryFile() + cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, + stdout=subprocess.PIPE, stderr=cls.log, text=True) + cls.port = int(cls.proc.stdout.readline().split("127.0.0.1:")[1].split()[0]) + + @classmethod + def tearDownClass(cls): + cls.proc.terminate() + cls.proc.wait(timeout=15) + cls.proc.stdout.close() + cls.log.close() + cls.srv.close() + shutil.rmtree(cls.dir, ignore_errors=True) + + def request(self, method, path, body=None, key="1"): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20) + conn.request(method, path, body=json.dumps(body).encode() if body is not None else None, + headers={"X-Frame-UI": key, "Content-Type": "application/json"}) + r = conn.getresponse() + data = json.loads(r.read() or b"{}") + conn.close() + return r.status, data + + def wait_connected(self): + for _ in range(100): + status, s = self.request("GET", "/api/connection") + if s.get("phase") in ("connected", "failed"): + return s + time.sleep(0.1) + self.fail(f"never connected: {s}") + + def test_imports_the_headset_and_connects_through_its_port(self): + s = self.wait_connected() + self.assertEqual(s["phase"], "connected", s["error"]) + self.assertEqual(s["via"]["host"], "localhost") + self.assertEqual(s["device"]["alias"], "frame") + self.assertEqual(s["device"]["name"], "Steam Frame") + self.assertEqual(s["probes"][0]["host"], "localhost") + status, devices = self.request("GET", "/api/devices") + self.assertEqual(status, 200) + self.assertEqual([d["alias"] for d in devices["devices"]], ["frame"]) + self.assertEqual(devices["nextAlias"], "frame-2") + + def test_events_stream_the_state(self): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20) + conn.request("GET", "/api/connection/events", headers={"X-Frame-UI": "1"}) + r = conn.getresponse() + self.assertEqual(r.status, 200) + self.assertEqual(r.getheader("Content-Type"), "text/event-stream") + line = r.fp.readline() + self.assertTrue(line.startswith(b"data: "), line) + self.assertIn("stages", json.loads(line[6:])) + conn.close() + + def test_changes_meant_for_another_headset_are_refused(self): + conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20) + conn.request("POST", "/api/launch", body=b'{"appid": "620"}', + headers={"X-Frame-UI": "1", "Content-Type": "application/json", "X-Frame-Device": "someoneelse"}) + r = conn.getresponse() + self.assertEqual(r.status, 409) + self.assertIn("switched headsets", json.loads(r.read())["error"]) + conn.close() + + def test_guards_and_validation(self): + self.assertEqual(self.request("GET", "/api/connection", key="")[0], 403) + self.assertEqual(self.request("GET", "/api/devices", key="nope")[0], 403) + self.assertEqual(self.request("POST", "/api/devices", {"action": "address-add", "id": "x", "host": "a;b"})[0], 400) + self.assertEqual(self.request("POST", "/api/devices", {"action": "setup", "alias": "-oProxyCommand=x"})[0], 400) + self.assertEqual(self.request("POST", "/api/devices", {"action": "nope"})[0], 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_macview.py b/tests/test_macview.py index 9739add..e636927 100644 --- a/tests/test_macview.py +++ b/tests/test_macview.py @@ -10,6 +10,7 @@ Run: python3 -m unittest discover -s tests import base64 import http.client import json +import io import os import shutil import socket @@ -42,6 +43,43 @@ class Helpers(unittest.TestCase): self.assertEqual(h, 1080) self.assertAlmostEqual(w / h, 0.5, places=2) + def test_the_tunnel_follows_the_headset(self): + mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame") + mv.retarget("frame", ["-o", "ControlPath=/tmp/x-%C", "-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"]) + self.assertEqual(mv.host_opts, ["-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"]) + + class Tunnel: + ended = False + def poll(self): return None + def terminate(self): Tunnel.ended = True + mv.tunnel, mv.remote_port = Tunnel(), 47999 + mv.retarget("frame", ["-o", "HostName=192.0.2.9"]) # another address, same headset: keep it + self.assertFalse(Tunnel.ended) + mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # another headset: never the old one's tunnel + self.assertTrue(Tunnel.ended) + self.assertIsNone(mv.tunnel) + self.assertEqual(mv.frame, "frame-2") + + def test_a_switch_just_before_publishing_drops_the_old_headsets_tunnel(self): + mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame") + mv.port = 47000 + ended = [] + + class Proc: + def poll(self): return None + def terminate(self): ended.append(self) + def wait(self): return 0 + stderr = io.StringIO("") + + def probe(port): + mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # the app switches right now + return True + with mock.patch.object(frame_macview.subprocess, "Popen", return_value=Proc()), \ + mock.patch.object(frame_macview.time, "sleep"), mock.patch.object(mv, "_probe", probe): + self.assertFalse(mv._open_tunnel([], [47001])) + self.assertIsNone(mv.tunnel) + self.assertEqual(len(ended), 1) # the tunnel to the old headset was closed + @unittest.skipUnless(shutil.which("bash"), "needs bash") @unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution") def test_launch_script_parses(self): diff --git a/tests/test_media.py b/tests/test_media.py index 9665c27..f277341 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,6 +1,9 @@ """Owned media planning, eye isolation, decoder choice and fake-Frame ownership.""" +import argparse +import io import json import os +import signal from pathlib import Path import struct import sys @@ -16,6 +19,13 @@ import frame_splat as splat import server +def stop_now(): + """What systemd's SIGTERM does to the player, without signalling the test process.""" + handler = signal.getsignal(signal.SIGTERM) + if callable(handler): + handler(signal.SIGTERM, None) + + class Media(unittest.TestCase): def test_layout_evidence_and_override(self): for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'), @@ -56,6 +66,156 @@ class Media(unittest.TestCase): self.assertNotIn('-re', cmd) self.assertIn('-frames:v', cmd) + def play_with(self, name, busy=0, on_pixels=None, sleeps=None, info=None, + fail=None, layout='auto'): + """Run the player against a fake OpenVR; returns (status, pixels calls). + + Handle 0 is the theatre surround and 1 the screen. `fail(handle, n)` makes + the n-th upload busy; every status written is kept in self.writes.""" + calls = [] + self.writes = [] + write_status = player.write_status + + def record(path, **values): + self.writes.append(values) + write_status(path, **values) + + class FakeOverlay: + def create(self, *a, **k): + return len(calls) + + def call(self, *a): + pass + + def pixels(self, handle, data, w, h): + calls.append((handle, w, h)) + if on_pixels: + on_pixels(len(calls)) + if len(calls) <= busy or (fail and fail(handle, len(calls))): + raise player.OverlayBusy('standby') + + def close(self): + # A Stop landing during cleanup must be ignored, not become an error. + # Call the installed handler directly: a real SIGTERM kills Windows. + stop_now() + + frame = bytes(4*2*4) + proc = unittest.mock.MagicMock() + proc.stdout = io.BytesIO(frame*4) + proc.wait.return_value = 0 + proc.poll.return_value = 0 + old = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT) + with tempfile.TemporaryDirectory() as d, \ + patch.object(player, 'Overlay', FakeOverlay), \ + patch.object(player, 'probe', return_value=(info or {'codec_name': 'h264', 'width': 4, 'height': 2}, False)), \ + patch.object(player.subprocess, 'Popen', return_value=proc), \ + patch.object(player, 'write_status', side_effect=record), \ + patch.object(player.frame_splat, 'render', return_value=(bytes(4*4*2), 4, 2)), \ + patch.object(player.time, 'sleep', side_effect=sleeps): + path = Path(d)/name + path.write_bytes(b'x') + status = Path(d)/'status.json' + try: + player.play(argparse.Namespace(file=str(path), layout=layout, theatre=True, status=str(status))) + finally: + signal.signal(signal.SIGTERM, old[0]) + signal.signal(signal.SIGINT, old[1]) + return json.loads(status.read_text()), calls + + def test_video_survives_standby_and_stop_after_end_stays_ended(self): + # Verified 2026-09-29: an unworn Frame enters standby within seconds and + # SetOverlayRaw then returns RequestFailed (23) until it wakes. + result, calls = self.play_with('clip_SBS.mp4', busy=3) + self.assertEqual((result['state'], result['frames']), ('ended', 4)) + # Two video frames were dropped; the surround (handle 0) waited and was re-sent. + self.assertEqual(result['dropped'], 2) + self.assertIn((0, 1, 1), calls[3:]) + + def test_stop_mid_video_reports_stopped(self): + result, _ = self.play_with('clip_SBS.mp4', on_pixels=lambda n: n == 3 and stop_now()) + self.assertEqual(result['state'], 'stopped') + + def test_video_errors_when_steamvr_never_takes_frames(self): + with patch.object(player, 'BUSY_LIMIT', -1), \ + self.assertRaisesRegex(RuntimeError, 'stopped accepting frames'): + self.play_with('clip_SBS.mp4', busy=99) + + def test_still_waits_out_standby_without_a_limit(self): + # Stills have no timeline: keep retrying (here past BUSY_LIMIT) until shown. + ticks = iter(range(10)) + def sleep(_): + if next(ticks) == 8: + stop_now() + with patch.object(player, 'BUSY_LIMIT', -1): + result, calls = self.play_with('photo_SBS.png', busy=5, sleeps=sleep, + info={'codec_name': 'png', 'width': 4, 'height': 2}) + self.assertEqual(result['state'], 'stopped') + screen = [c for c in calls if c[0] == 1] + self.assertGreater(len(screen), 1) # retried through standby + self.assertEqual(calls[-1], (0, 1, 1)) # surround drained once the screen took a frame + + def still_with_late_surround(self, name, **kw): + # The screen takes its first frame while the surround is still refused + # (its first upload, the drain right after the screen, and one retry). + ticks = iter(range(10)) + def sleep(_): + if next(ticks) == 5: + stop_now() + surround_tries = [] + def fail(handle, n): + if handle == 0: + surround_tries.append(n) + return len(surround_tries) <= 3 + return False + result, calls = self.play_with(name, sleeps=sleep, fail=fail, **kw) + self.assertEqual(result['state'], 'stopped') + screen = [c for c in calls if c[0] == 1] + surround = [c for c in calls if c[0] == 0] + self.assertEqual(len(screen), 1) # shown once, not re-sent every second + self.assertEqual(len(surround), 4) # kept retrying after the screen, until it took + self.assertEqual(calls[-1][0], 0) + return calls + + def test_photo_surround_recovers_after_screen_is_shown(self): + self.still_with_late_surround('photo_SBS.png', + info={'codec_name': 'png', 'width': 4, 'height': 2}) + + def test_splat_surround_recovers_after_screen_is_shown(self): + self.still_with_late_surround('scene.splat') + + def test_video_standby_limit_is_five_minutes_without_an_accepted_frame(self): + self.assertEqual(player.BUSY_LIMIT, 300) + def run(times, busy): + # Upload n happens at times[n] seconds on a fake clock; 1 is the surround. + clock = [1000.0] + def on_pixels(n): + clock[0] = 1000.0 + times.get(n, times[max(times)]) + with patch.object(player.time, 'monotonic', side_effect=lambda: clock[0]): + return self.play_with('clip_SBS.mp4', on_pixels=on_pixels, + fail=lambda h, n: h == 1 and n in busy) + # Busy for 299 s, then a frame lands: no error. + result, _ = run({1: 0, 2: 0, 3: 299, 4: 299, 5: 299}, busy={2, 3}) + self.assertEqual((result['state'], result['dropped']), ('ended', 2)) + # An accepted frame resets the timer: 600 s busy in total, never 300 s in a row. + result, _ = run({1: 0, 2: 0, 3: 200, 4: 250, 5: 450}, busy={2, 3, 5}) + self.assertEqual((result['state'], result['dropped']), ('ended', 3)) + # 301 s in a row without an accepted frame is an error. + with self.assertRaisesRegex(RuntimeError, 'stopped accepting frames for 300 s'): + run({1: 0, 2: 0, 3: 301}, busy={2, 3, 4, 5}) + + def test_status_reports_where_the_layout_came_from(self): + video = {'codec_name': 'h264', 'width': 4, 'height': 2} + for name, layout, tags, expect in [ + ('clip_SBS.mp4', 'auto', None, ('sbs', 'filename')), + ('clip.mkv', 'auto', {'stereo_mode': 'left_right'}, ('full-sbs', 'metadata')), + ('clip_OU.mp4', 'sbs', None, ('sbs', 'explicit')), + ('clip.mkv', 'mono', {'stereo_mode': 'left_right'}, ('mono', 'explicit'))]: + with self.subTest(name=name, layout=layout): + self.play_with(name, layout=layout, info=dict(video, tags=tags) if tags else video) + playing = self.writes[0] + self.assertEqual(playing['state'], 'playing') + self.assertEqual((playing['layout'], playing['source']), expect) + def test_fake_frame_library_and_traversal(self): with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)): identity = 'a'*32+'/space and quote\'.png' diff --git a/tests/test_network.py b/tests/test_network.py new file mode 100644 index 0000000..b808dd8 --- /dev/null +++ b/tests/test_network.py @@ -0,0 +1,147 @@ +"""frame_network's parsers, with what macOS, Linux and Windows print. + +Run: python3 -m unittest discover -s tests +""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import json +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "ui")) + +import frame_network as fn # noqa: E402 + +MAC_ROUTE = """ route to: default +destination: default + mask: default + gateway: 192.168.1.1 + interface: en0 + flags: +""" +MAC_ARP = "? (192.168.1.1) at b4:fb:e4:1:87:3f on en0 ifscope [ethernet]\n" +MAC_ARP_INCOMPLETE = "? (192.168.1.1) at (incomplete) on en0 ifscope [ethernet]\n" +MAC_SUMMARY = """ { + BSSID : + ConnectionID : 1 + InterfaceType : WiFi + LinkStatusActive : TRUE + NetworkID : + SSID : + Security : WPA2_PSK +}""" +MAC_SUMMARY_NAMED = MAC_SUMMARY.replace("SSID : \n Security", "SSID : Home Net\n Security") +MAC_SUMMARY_WIRED = " {\n InterfaceType : Ethernet\n LinkStatusActive : TRUE\n}" + +LINUX_ROUTE = """default via 10.0.0.1 dev wlp2s0 proto dhcp src 10.0.0.23 metric 600 +default via 192.168.50.1 dev enp3s0 proto dhcp src 192.168.50.9 metric 100 +""" +LINUX_NEIGH = "192.168.50.1 dev enp3s0 lladdr 00:11:22:aa:bb:cc REACHABLE\n" +NMCLI = "no:Neighbour\nyes:Cafe\\: upstairs\nno:\n" + +WIN_ROUTE = """=========================================================================== +Interface List + 12...00 15 5d 01 02 03 ......Intel(R) Wi-Fi 6 AX201 160MHz +=========================================================================== + +IPv4 Route Table +=========================================================================== +Active Routes: +Network Destination Netmask Gateway Interface Metric + 0.0.0.0 0.0.0.0 192.168.0.254 192.168.0.40 50 + 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.50 35 +=========================================================================== +Persistent Routes: + None +""" +WIN_ARP = """ +Interface: 192.168.1.50 --- 0xc + Internet Address Physical Address Type + 192.168.1.1 b4-fb-e4-b5-67-55 dynamic +""" +NETSH = """ +There is 1 interface on the system: + + Name : Wi-Fi + Description : Intel(R) Wi-Fi 6 AX201 160MHz + State : connected + SSID : Office 5G + BSSID : 12:34:56:78:9a:bc + Network type : Infrastructure +""" +NETSH_OFF = NETSH.replace("State : connected", "State : disconnected") + +TAILSCALE = json.dumps({ + "BackendState": "Running", + "CurrentTailnet": {"Name": "example.github"}, + "Self": {"HostName": "laptop", "DNSName": "laptop.tail1234.ts.net.", "TailscaleIPs": ["fd7a:115c:a1e0::1", "100.101.102.103"]}, + "Peer": {"nodekey:1": {"HostName": "frame", "DNSName": "frame.tail1234.ts.net.", "OS": "linux", "Online": True, + "TailscaleIPs": ["100.101.102.103", "fd7a:115c:a1e0::1234:5678"]}, + "nodekey:2": {"HostName": "phone", "DNSName": "phone.tail1234.ts.net.", "OS": "iOS", "Online": False, + "TailscaleIPs": ["100.77.1.2"]}}, +}) + + +class Parsers(unittest.TestCase): + def test_macos(self): + self.assertEqual(fn.parse_route_macos(MAC_ROUTE), ("192.168.1.1", "en0")) + self.assertEqual(fn.parse_route_macos("route: writing to routing socket: not in table\n"), (None, None)) + self.assertEqual(fn.parse_arp_macos(MAC_ARP, "192.168.1.1"), "b4:fb:e4:01:87:3f") # padded + self.assertIsNone(fn.parse_arp_macos(MAC_ARP_INCOMPLETE, "192.168.1.1")) + self.assertIsNone(fn.parse_arp_macos(MAC_ARP, "192.168.1.10")) + self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY), (None, True)) # no Location permission + self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_NAMED), ("Home Net", True)) + self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_WIRED), (None, False)) + + def test_linux(self): + self.assertEqual(fn.parse_route_linux(LINUX_ROUTE), ("192.168.50.1", "enp3s0")) # lowest metric + self.assertEqual(fn.parse_route_linux(""), (None, None)) + self.assertEqual(fn.parse_neigh_linux(LINUX_NEIGH, "192.168.50.1"), "00:11:22:aa:bb:cc") + self.assertIsNone(fn.parse_neigh_linux("192.168.50.1 dev enp3s0 FAILED\n", "192.168.50.1")) + self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs") + self.assertIsNone(fn.parse_nmcli("no:Neighbour\n")) + + def test_windows(self): + self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50")) + self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55") + self.assertEqual(fn.parse_netsh(NETSH), "Office 5G") # not the BSSID + self.assertIsNone(fn.parse_netsh(NETSH_OFF)) + + def test_mac_addresses(self): + self.assertEqual(fn.norm_mac("B4-FB-E4-B5-67-55"), "b4:fb:e4:b5:67:55") + for bad in ("", "(incomplete)", "ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00", "b4:fb:e4:b5:67", "zz:fb:e4:b5:67:55"): + self.assertIsNone(fn.norm_mac(bad), bad) + + def test_network_id_is_stable_and_needs_both_parts(self): + a = fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55") + self.assertEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55")) + self.assertTrue(a.startswith("n-")) + self.assertNotEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:56")) # same IP, another router + self.assertIsNone(fn.network_id("192.168.1.1", None)) + self.assertIsNone(fn.network_id(None, "b4:fb:e4:b5:67:55")) + + def test_tailscale(self): + ts = fn.parse_tailscale(TAILSCALE) + self.assertTrue(ts["up"]) + self.assertEqual(ts["ip"], "100.101.102.103") + self.assertEqual(ts["name"], "laptop.tail1234.ts.net") + self.assertEqual(ts["tailnet"], "example.github") + frame = ts["peers"][0] + self.assertEqual((frame["name"], frame["dns"], frame["os"], frame["online"]), + ("frame", "frame.tail1234.ts.net", "linux", True)) + self.assertFalse(fn.parse_tailscale(json.dumps({"BackendState": "Stopped", "Self": {}}))["up"]) + self.assertEqual(fn.parse_tailscale("not json"), {"up": False, "peers": []}) + self.assertEqual(fn.parse_tailscale("[]"), {"up": False, "peers": []}) + + def test_address_kinds(self): + cases = {"frame.local": "mdns", "frame.local.": "mdns", "frame.tail1234.ts.net": "tailscale", + "100.101.102.103": "tailscale", "fd7a:115c:a1e0::1234:5678": "tailscale", + "192.168.1.40": "lan", "10.0.0.5": "lan", "fe80::1%en0": "lan", + "frame.example.com": "manual", "8.8.8.8": "manual"} + for host, kind in cases.items(): + self.assertEqual(fn.guess_kind(host), kind, host) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_panels.py b/tests/test_panels.py new file mode 100644 index 0000000..6cfb241 --- /dev/null +++ b/tests/test_panels.py @@ -0,0 +1,102 @@ +"""Fake-Frame panel responses and the headset page's real HTTP guards.""" +import http.client +import json +from pathlib import Path +import sys +import threading +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui')) +import frame_panels as panels + +# Shape verified with vrcmd on SteamVR 2.18.1 / BUILD_ID 20260925.6191901. +OVERLAYS = """---- OVERLAYS ---- +'valve.steam.desktopgame.12' -- 'Alex's ', 1920x1080 visible VROverlayType_Dashboard_Main +'valve.steam.desktopgame.12.thumb' -- 'Thumb', not_visible VROverlayType_Dashboard_Thumbnail +'valve.steam.desktopgame.12.layer1' -- 'Layer', visible VROverlayType_Subview +'system.pointer' -- 'Pointer', visible VROverlayType_Basic +'valve.steam.desktopgame.13' -- 'Other', not_visible VROverlayType_Dashboard_Main +""" + + +class Panels(unittest.TestCase): + def test_enumerates_only_main_panels_including_hidden(self): + rows = panels.parse_overlays(OVERLAYS) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]['title'], "Alex's ") + self.assertTrue(rows[0]['visible']) + self.assertFalse(rows[1]['visible']) + + def test_unavailable_runtime_is_not_an_empty_workspace(self): + with self.assertRaises(panels.PanelError): + panels.parse_overlays('SteamVR not running') + self.assertEqual(panels.parse_overlays('---- OVERLAYS ----'), []) + + @patch.object(panels, 'run', return_value=OVERLAYS) + def test_focus_revalidates_and_dispatches_without_shell(self, run): + key = 'valve.steam.desktopgame.12' + self.assertEqual(panels.focus(key)['requested'], key) + self.assertEqual(run.call_args.args[0], [panels.VRCMD, '--showdashboard', key]) + + @patch.object(panels, 'run', return_value=OVERLAYS) + def test_closed_or_injected_panel_never_dispatches(self, run): + for key in (None, 12, 'x;touch /tmp/bad', '../other', 'missing'): + with self.assertRaises(panels.PanelError): + panels.focus(key) + self.assertEqual(run.call_count, 1) # only valid-looking 'missing' enumerates + + +class HeadsetHTTP(unittest.TestCase): + def setUp(self): + self.server = panels.HTTPServer(('127.0.0.1', 0), panels.Handler) + self.server.key = 'test-key' + self.server.closing = False + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.thread.join() + self.server.server_close() + + def request(self, path, body=None, headers=None): + c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5) + try: + c.request('POST' if body is not None else 'GET', path, body=body, headers=headers or {}) + r = c.getresponse() + return r.status, r.read().decode() + finally: + c.close() + + def test_page_is_public_but_contains_no_key_or_private_titles(self): + status, page = self.request('/') + self.assertEqual(status, 200) + self.assertNotIn('test-key', page) + self.assertIn('textContent=p.title', page) # titles never become HTML + self.assertEqual(self.request('/panels')[0], 403) + + @patch.object(panels, 'state', return_value={'panels': []}) + def test_auth_host_and_origin_checks(self, state): + auth = {'X-Panel-Key': 'test-key'} + self.assertEqual(self.request('/panels', headers=auth)[0], 200) + for extra in ({'Host': 'evil.test'}, {'Origin': 'https://evil.test'}, {'X-Panel-Key': 'wrong'}): + self.assertEqual(self.request('/panels', headers={**auth, **extra})[0], 403) + self.assertEqual(state.call_count, 1) + + @patch.object(panels, 'focus', return_value={'requested': 'panel'}) + def test_post_validation_and_close(self, focus): + auth = {'X-Panel-Key': 'test-key'} + for body in ('[]', '{broken', '0', '"text"', 'x' * 1025): + self.assertEqual(self.request('/focus', body, auth)[0], 400) + self.assertEqual(focus.call_count, 0) + self.assertEqual(self.request('/focus', '{"key":"panel"}', auth)[0], 200) + self.assertEqual(focus.call_args.args, ('panel',)) + self.assertEqual(self.request('/close', '{}', auth)[0], 200) + self.assertTrue(self.server.closing) + + @patch.object(panels, 'state', side_effect=panels.PanelError('offline')) + def test_offline_is_an_error_not_a_successful_empty_list(self, state): + status, body = self.request('/panels', headers={'X-Panel-Key': 'test-key'}) + self.assertEqual(status, 502) + self.assertEqual(json.loads(body), {'error': 'offline'}) diff --git a/tests/test_server.py b/tests/test_server.py index 1bb565d..b82034c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -34,7 +34,9 @@ class ServerGuards(unittest.TestCase): @classmethod def setUpClass(cls): cls.port = free_port() - env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"} + cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up + env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1", + "FRAME_CONTROL_SSH_DIR": cls.ssh_dir} cls.log = tempfile.TemporaryFile() cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)], env=env, stdout=cls.log, stderr=subprocess.STDOUT) @@ -84,6 +86,7 @@ class ServerGuards(unittest.TestCase): def test_api_needs_custom_header(self): # and plain form posts from other sites can't set it. + self.assertEqual(self.request("POST", "/api/comfort", {"action": "start"})[0], 403) self.assertEqual(self.request("GET", "/api/status")[0], 403) self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403) self.assertEqual(self.request("GET", "/api/shots")[0], 403) @@ -99,6 +102,8 @@ class ServerGuards(unittest.TestCase): def test_input_validation(self): cases = [ + ("/api/comfort", {"action": "poweroff"}), + ("/api/comfort", {"action": "start", "minutes": 0}), ("/api/launch", {"appid": "620; rm -rf ~"}), ("/api/launch", {"appid": ""}), ("/api/flatpak", {"id": "org.example.App;id", "action": "install"}), @@ -106,6 +111,8 @@ class ServerGuards(unittest.TestCase): ("/api/volume", {"level": 1.5}), ("/api/clipboard", {"text": ""}), ("/api/open", {"what": "anything-else"}), + ("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}), + ("/api/open", {"what": "shot"}), ("/api/shots/save", {"ids": []}), ("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}), ("/api/shots/save", {"ids": [1]}), @@ -116,6 +123,10 @@ class ServerGuards(unittest.TestCase): status, payload = self.post(path, body) self.assertEqual(status, 400, f"{path} {body} -> {payload}") + def test_showing_a_shot_needs_it_saved_here(self): + status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"}) + self.assertEqual(status, 404, payload) + def test_screenshot_ids_checked_before_ssh(self): for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"): status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"}) @@ -235,6 +246,108 @@ class ServerGuards(unittest.TestCase): self.assertEqual(self.post("/api/nope", {})[0], 404) +class OneServer(unittest.TestCase): + """Two servers for one user would each connect and edit headsets on their own.""" + + def start(self, env): + proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close())) + return proc + + def test_a_second_server_is_refused_until_the_first_exits(self): + data = tempfile.mkdtemp(prefix="frame-one-server-") + env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid", + "FRAME_CONTROL_SERVER_WAIT": "1"} + first = self.start(env) + self.assertIn("Frame Control on", first.stdout.readline()) + second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env, + capture_output=True, text=True, timeout=60) + self.assertEqual(second.returncode, 1) + self.assertIn("already running", second.stderr) + first.terminate() + first.wait(10) + self.assertIn("Frame Control on", self.start(env).stdout.readline()) + + def test_a_private_server_runs_alongside_but_cant_change_headsets(self): + """The MCP adapter starts its own server (FRAME_PRIVATE_SSH=1) while the app runs.""" + data = tempfile.mkdtemp(prefix="frame-one-server-") + env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid", + "FRAME_CONTROL_SERVER_WAIT": "1"} + self.assertIn("Frame Control on", self.start(env).stdout.readline()) + private = self.start({**env, "FRAME_PRIVATE_SSH": "1"}) + line = private.stdout.readline() + self.assertIn("Frame Control on", line) + port = int(line.split("http://127.0.0.1:")[1].split()[0]) + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + conn.request("POST", "/api/devices", body=json.dumps({"action": "use", "id": "x"}), + headers={"Content-Type": "application/json", "X-Frame-UI": "1", "Host": f"127.0.0.1:{port}"}) + r = conn.getresponse() + self.assertEqual(r.status, 403, r.read()) + + @unittest.skipIf(os.name == "nt", "no SIGTERM on Windows") + def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self): + """The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit.""" + env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"), + "FRAME_ALIAS": "frame-control-test.invalid"} + proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"], + env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close())) + self.assertIn("Frame Control on", proc.stdout.readline()) + proc.terminate() + self.assertEqual(proc.wait(30), 0, proc.stdout.read()) + + +class ArtworkSettings(unittest.TestCase): + """The settings panel's endpoints, with and without the page's X-Frame-UI key.""" + + def test_settings_need_and_accept_the_ui_key(self): + with tempfile.TemporaryDirectory() as home: + port = free_port() + env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1", + "HOME": home, "APPDATA": home, "XDG_DATA_HOME": home} + for name in ("STEAMGRIDDB_API_KEY", "FRAME_STEAMGRIDDB_API_KEY", "FRAME_UI_KEY"): + env.pop(name, None) + proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(port)], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + def request(method, path, body=None, headers=None): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + conn.request(method, path, body=json.dumps(body).encode() if body is not None else None, + headers=headers or {}) + r = conn.getresponse() + payload = r.read() + conn.close() + return r.status, payload + for _ in range(100): + try: + if request("GET", "/")[0] == 200: + break + except OSError: + time.sleep(0.05) + key = {"X-Frame-UI": "1", "Content-Type": "application/json"} + self.assertEqual(request("GET", "/api/settings/artwork")[0], 403) + self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403) + status, payload = request("GET", "/api/settings/artwork", headers=key) + self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False)) + status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key) + self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, True)) + self.assertNotIn(b"abc_1", payload) + status, payload = request("GET", "/api/settings/artwork", headers=key) + self.assertTrue(json.loads(payload)["steamgriddb_configured"]) + self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "a b"}, key)[0], 400) + finally: + proc.terminate() + proc.wait(timeout=10) + + def test_panel_script_uses_the_keyed_api_helper(self): + script = (ROOT / "ui" / "artwork-settings.js").read_text(encoding="utf-8") + self.assertNotIn("fetch(", script) + self.assertIn("api('/api/settings/artwork'", script) + page = (ROOT / "ui" / "index.html").read_text(encoding="utf-8") + self.assertLess(page.index("async function api("), page.index('''' + + +class Handler(BaseHTTPRequestHandler): + def setup(self): + super().setup() + self.connection.settimeout(5) + + def log_message(self, *args): + pass # never log the page's access key + + def reply(self, code, value, html=False): + data = value.encode() if html else json.dumps(value).encode() + self.send_response(code) + self.send_header('Content-Type', 'text/html; charset=utf-8' if html else 'application/json') + self.send_header('Content-Length', str(len(data))) + self.send_header('Cache-Control', 'no-store') + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('Referrer-Policy', 'no-referrer') + self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'") + self.end_headers() + self.wfile.write(data) + + def allowed(self): + host = '127.0.0.1:' + str(self.server.server_port) + origin = self.headers.get('Origin') + return (self.headers.get('Host') == host and + (origin is None or origin == 'http://' + host) and + hmac.compare_digest(self.headers.get('X-Panel-Key', '').encode(), self.server.key.encode())) + + def do_GET(self): + if self.path == '/': + return self.reply(200, PAGE, html=True) # no data or access key in the page + if not self.allowed(): + return self.reply(403, {'error': 'Open the switcher from Frame Control.'}) + try: + if self.path == '/panels': + return self.reply(200, state()) + self.reply(404, {'error': 'Not found'}) + except PanelError as e: + self.reply(502, {'error': str(e)}) + + def do_POST(self): + if not self.allowed(): + return self.reply(403, {'error': 'Forbidden'}) + try: + size = int(self.headers.get('Content-Length', '0')) + if not 0 < size <= 1024: + raise ValueError('Invalid request size') + body = json.loads(self.rfile.read(size)) + if not isinstance(body, dict): + raise ValueError('Expected an object') + if self.path == '/focus': + return self.reply(200, focus(body.get('key'))) + if self.path == '/close': + self.server.closing = True + return self.reply(200, {'closed': True}) + self.reply(404, {'error': 'Not found'}) + except (ValueError, PanelError) as e: + self.reply(400, {'error': str(e)}) + + +def serve(): + """Own only our Chromium profile and process group. No changes to Steam, + SteamVR, other Chromium sessions, or global power settings. + """ + import fcntl # only on the Frame; module/tests also import on Windows + folder = Path.home() / '.local/share/frame-control/panels' + folder.mkdir(parents=True, exist_ok=True, mode=0o700) + with (folder / 'lock').open('w') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print(json.dumps(focus(PANEL_KEY)), flush=True) + return + chrome = Path.home() / 'chromium-xr/chrome' + if chrome.is_file(): + command = [str(chrome)] + profile = folder / 'chromium' + elif Path('/usr/bin/chromium').is_file(): + command = ['/usr/bin/chromium'] + profile = folder / 'chromium' + elif subprocess.run(['flatpak', 'info', 'org.chromium.Chromium'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0: + command = ['flatpak', 'run', 'org.chromium.Chromium'] + profile = Path.home() / '.var/app/org.chromium.Chromium/data/frame-panel-switcher' + else: + raise PanelError('The headset switcher needs Chromium. The companion switcher still works.') + server = HTTPServer(('127.0.0.1', 0), Handler) + server.key = secrets.token_urlsafe(32) + server.closing = False + server.timeout = .5 + url = 'http://127.0.0.1:%d/#%s' % (server.server_port, server.key) + env = {**os.environ, 'DISPLAY': ':0'} + env.pop('WAYLAND_DISPLAY', None) + browser = subprocess.Popen([*command, '--ozone-platform=x11', + '--user-data-dir=' + str(profile), + '--no-first-run', '--no-default-browser-check', + '--password-store=basic', '--window-size=1200,800', '--app=' + url], + env=env, start_new_session=True, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + def stop(signum, frame): + server.closing = True + signal.signal(signal.SIGTERM, stop) + win = None + try: + deadline = time.monotonic() + 30 + while not win and time.monotonic() < deadline and browser.poll() is None: + server.handle_request() # Chromium must fetch the page before it has a title + for line in run(['xwininfo', '-root', '-children']).splitlines(): + m = re.match(r'\s*(0x[0-9a-fA-F]+) .*\[fc-panels\]', line) + if m: + win = m[1] + break + if not win: + raise PanelError('The switcher window did not appear within 30 seconds.') + run(['xprop', '-id', win, '-f', 'STEAM_GAME', '32c', '-set', 'STEAM_GAME', str(PANEL_ID)]) + print(json.dumps({'message': 'Opened the panel switcher in the headset.'}), flush=True) + # Caller reads exactly one line, then disconnects; no more stdout. + while not server.closing and browser.poll() is None: + server.handle_request() + # Closing the last app window need not exit Chromium. + if win not in run(['xwininfo', '-root', '-children']): + break + finally: + server.server_close() + if browser.poll() is None: + os.killpg(browser.pid, signal.SIGTERM) + try: + browser.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(browser.pid, signal.SIGKILL) + browser.wait() + + +def open_switcher(): + # This command runs from an installed path, never from the SSH stdin copy. + proc = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--serve'], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, start_new_session=True) + line = proc.stdout.readline() + proc.stdout.close() + if not line: + raise PanelError('The headset switcher could not start.') + result = json.loads(line) + if 'error' in result: + raise PanelError(result['error']) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--focus') + parser.add_argument('--open', action='store_true') + parser.add_argument('--serve', action='store_true') + args = parser.parse_args() + try: + if args.serve: + serve() + else: + print(json.dumps(open_switcher() if args.open else focus(args.focus) if args.focus else state())) + except (PanelError, OSError) as e: + print(json.dumps({'error': str(e)}), flush=True) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/ui/frame_report.py b/ui/frame_report.py index ffcfa9d..25b887c 100644 --- a/ui/frame_report.py +++ b/ui/frame_report.py @@ -6,6 +6,10 @@ project as a `problem_report` event: only the maintainer can read it, and nothing is published. It is sent whatever the analytics settings are, because the person sends it deliberately. Diagnostics are scrubbed first (frame_telemetry.scrub); the person's own words are sent as written. + +An email address goes with a report only when the person ticks "may contact me with +follow-up questions" (contact_followup). Standing choices made in Settings are +frame_contact.py's `contact_consent` events; `contacts` lists them. """ import os import platform @@ -13,6 +17,7 @@ import sys import time import uuid +import frame_contact import frame_host import frame_telemetry @@ -107,19 +112,26 @@ def send(body): """Send the report to PostHog. Returns {"id", "message"}; raises ReportError.""" kind = body.get('kind') if body.get('kind') in KINDS else 'bug' title, text, diag = compose(body) + followup = bool(body.get('contactFollowup')) + contact = str(body.get('contact') or '').strip() if followup else '' + if followup and not frame_contact.valid_email(contact): + raise ValueError('add your email address for follow-up questions, or untick that box') ref = uuid.uuid4().hex[:8].upper() props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text, - 'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag, + 'contact': contact, 'contact_followup': followup, 'diagnostics': diag, 'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'} # Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics. event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()), 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props} + started = time.time() try: frame_telemetry.post([event], timeout=30) except frame_telemetry.SendError as e: raise ReportError(str(e)) try: - frame_telemetry.record_sent([event]) + with frame_telemetry._lock: # the lock a removal holds while wiping its address + frame_contact.redact_removed(event, started) + frame_telemetry.record_sent([event]) except OSError: pass # it was sent; failing to log it here mustn't make the person send it again return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'} @@ -135,22 +147,71 @@ def inbox(days=30): import frame_compat_db res = frame_compat_db._posthog_query( "SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, " - "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics " + "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, " + "properties.contact_followup " f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY " "ORDER BY timestamp DESC LIMIT 200") return res.get('results') or [] +def _yes(v): + return v is True or str(v).lower() in ('true', '1') + + +def contacts(): + """{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest + contact_consent event agrees to each, oldest first. A withdrawal, or a change to another + address, replaces what came before, so withdrawn addresses are never listed. "Newest" is + the highest rev from that copy (then time), so every field comes from the same event + whatever order they arrived in or what the clocks said.""" + import frame_compat_db + newest = "tuple(ifNull(toInt(properties.rev), 0), timestamp)" + res = frame_compat_db._posthog_query( + f"SELECT distinct_id, argMax(properties.email, {newest}), argMax(properties.updates, {newest}), " + f"argMax(properties.followup, {newest}), argMax(timestamp, {newest}) FROM events " + "WHERE event = 'contact_consent' GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000") + out = {'updates': [], 'followup': []} + for row in res.get('results') or []: + if not isinstance(row, list) or len(row) != 5: + continue + _, email, updates, followup, ts = row + email = str(email or '').strip() + if not frame_contact.valid_email(email): + continue + for kind, agreed in (('updates', updates), ('followup', followup)): + if _yes(agreed): + out[kind].append((email, str(ts or '')[:10])) + return out + + +USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]' + + def main(): cmd, *args = sys.argv[1:] or ['inbox'] + if cmd == 'contacts': + kinds = args[:1] or ['updates', 'followup'] + if not set(kinds) <= {'updates', 'followup'}: + sys.exit(USAGE) + found = contacts() + for kind in kinds: + print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}" + f" ({len(found[kind])})") + for email, since in found[kind]: + print(f" {email} (since {since})") + print() + return if cmd != 'inbox': - sys.exit('usage: frame_report.py inbox [days]') + sys.exit(USAGE) for row in inbox(*(args[:1] or [30])): - if not isinstance(row, list) or len(row) != 10: + if not isinstance(row, list) or len(row) != 11: continue - ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row) + ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10]) + # Reports from before contact_followup existed only carried an address given for a reply. + reply = contact and (row[10] is None or _yes(row[10])) print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}") - print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}") + print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}" + f"{', may follow up at ' + contact if reply else ''}") print(' ' + text.replace('\n', '\n ')) if diag: print(' --- diagnostics\n ' + diag.replace('\n', '\n ')) diff --git a/ui/frame_status.py b/ui/frame_status.py index 2047abf..92ef7a1 100644 --- a/ui/frame_status.py +++ b/ui/frame_status.py @@ -1,9 +1,12 @@ """Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object. Read-only. Every probe is best effort: a missing tool or file gives null, not an -error. Paths verified on SteamOS 0.3.0 (vr), build 20260922. +error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1 +(build 20260925.6191901). See docs/vr-utilities.md. """ +import ctypes as C import glob +import math import json import os import re @@ -156,27 +159,156 @@ def flatpaks(): return out -uptime = read("/proc/uptime") -procs = process_names() -print(json.dumps({ - "time": time.time(), - "hostname": socket.gethostname(), - "os": os_release(), - "uptime": float(uptime.split()[0]) if uptime else None, - "battery": battery(), - "power": power_source(), - "disk": {"root": disk("/"), "home": disk("/home")}, - "memory": memory(), - "temp": max_temp(), - "wifi": wifi(), - "ip": ip_addr(), - "volume": volume(), - "services": { - "steamvr": "vrserver" in procs, - "desktop": "plasmashell" in procs, - "lepton": port_listening(5555), - "rdp": "xrdp" in procs, - }, - "games": games(), - "flatpaks": flatpaks(), -})) +def thermal_alerts(): + """Use the kernel's per-zone hot/critical trips, never a guessed chip limit.""" + alerts, known = [], False + for z in glob.glob("/sys/class/thermal/thermal_zone*"): + t = num(z + "/temp", 0.001) + for trip in glob.glob(z + "/trip_point_*_type"): + if read(trip) not in ("hot", "critical"): + continue + limit = num(trip[:-4] + "temp", 0.001) + if t is not None and limit is not None and limit > 0: + known = True + if t >= limit: + alerts.append({"zone": read(z + "/type"), "tempC": t, "limitC": limit}) + return alerts if known else None + + +def activity_level(): + try: + rows = json.loads(run("/opt/steamvr/bin/linuxarm64/vrcmd", "--stats")) + if not isinstance(rows, list): + return None + return next((r.get("activity_level") for r in rows + if isinstance(r, dict) and r.get("operation") == "status"), None) + except (ValueError, TypeError): + return None + + +# OpenVR C ABI, ValveSoftware/openvr headers/openvr_capi.h (IVRCompositor_029). +# Exact interface version: never guess an index against a different table. +class Pose(C.Structure): + _fields_ = [('matrix', C.c_float * 12), ('velocity', C.c_float * 3), + ('angular', C.c_float * 3), ('result', C.c_int), + ('valid', C.c_bool), ('connected', C.c_bool)] + + +class Timing(C.Structure): + _fields_ = [(n, C.c_uint32) for n in ('size', 'index', 'presents', 'mis', 'dropped', 'flags')] + [ + ('time', C.c_double)] + [(n, C.c_float) for n in ( + 'gpuPre', 'gpuPost', 'gpu', 'compositorGpu', 'compositorCpu', 'idleCpu', 'interval', + 'presentCpu', 'waitCpu', 'submit', 'posesCalled', 'posesReady', 'frameReady', + 'updateStart', 'updateEnd', 'renderStart')] + [ + ('pose', Pose), ('ready', C.c_uint32), ('first', C.c_uint32), ('transfer', C.c_float)] + + +class OpenVR: + def __enter__(self): + self.lib = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so') + self.lib.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p] + self.lib.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)] + self.lib.VR_GetGenericInterface.restype = C.c_void_p + err = C.c_int() + self.lib.VR_InitInternal2(C.byref(err), 3, None) # background: never start or keep SteamVR running + if err.value: + raise RuntimeError(f'SteamVR unavailable (init {err.value})') + return self + + def __exit__(self, *args): + self.lib.VR_ShutdownInternal() + + def function(self, interface, index, result, *args): + err = C.c_int() + ptr = self.lib.VR_GetGenericInterface(('FnTable:' + interface).encode(), C.byref(err)) + if err.value or not ptr: + raise RuntimeError(f'{interface} unavailable ({err.value})') + return C.CFUNCTYPE(result, *args)(C.cast(ptr, C.POINTER(C.c_void_p))[index]) + + +def cpu_ticks(): + line = (read('/proc/stat') or '').splitlines() + if not line or not line[0].startswith('cpu '): + return None + try: + # guest/guest_nice are already included in user/nice. + ticks = [int(x) for x in line[0].split()[1:9]] + return sum(ticks), ticks[3] + ticks[4] + except (ValueError, IndexError): + return None + + +def cpu_percent(before, after): + if before is None or after is None or after[0] <= before[0]: + return None + return round(max(0, min(100, 100 * (1 - (after[1] - before[1]) / (after[0] - before[0])))), 1) + + +def positive(value): + return round(value, 2) if math.isfinite(value) and value > 0 else None + + +def timing_values(before, after): + dt, frames = after.time - before.time, after.index - before.index + if dt <= 0 or frames <= 0 or frames / dt > 1000: + return {} # standby or old data; never present stale timings as live + return {'compositorFps': positive(frames / dt), + 'frameMs': positive(1000 * dt / frames), + 'appFps': positive(1000 / after.interval) if after.interval > 0 else None, + 'gpuMs': positive(after.gpu), 'compositorCpuMs': positive(after.compositorCpu)} + + +def performance(): + out = {'compositorFps': None, 'frameMs': None, 'appFps': None, + 'gpuMs': None, 'compositorCpuMs': None, 'cpuPercent': None, + 'gpuMHz': num('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6)} + before = cpu_ticks() + try: + with OpenVR() as vr: + get = vr.function('IVRCompositor_029', 10, C.c_bool, C.POINTER(Timing), C.c_uint32) + a, b = Timing(), Timing() + a.size = b.size = C.sizeof(Timing) + first = get(C.byref(a), 0) + time.sleep(0.2) + if first and get(C.byref(b), 0): + out.update(timing_values(a, b)) + except (OSError, RuntimeError, AttributeError): # AttributeError: a SteamVR build without these exports + time.sleep(0.2) + out['cpuPercent'] = cpu_percent(before, cpu_ticks()) + return out + + +def status(): + uptime = read("/proc/uptime") + procs = process_names() + return { + "time": time.time(), + "performance": performance(), + "hostname": socket.gethostname(), + "os": os_release(), + "uptime": float(uptime.split()[0]) if uptime else None, + "battery": battery(), + "power": power_source(), + "disk": {"root": disk("/"), "home": disk("/home")}, + "memory": memory(), + "temp": max_temp(), + "wifi": wifi(), + "ip": ip_addr(), + "volume": volume(), + "services": { + "steamvr": "vrserver" in procs, + "desktop": "plasmashell" in procs, + "lepton": port_listening(5555), + "rdp": "xrdp" in procs, + }, + "games": games(), + "flatpaks": flatpaks(), + } + + +def main(): + print(json.dumps(status())) + + +if __name__ == "__main__": + main() diff --git a/ui/frame_steam.py b/ui/frame_steam.py index 5c57d54..343200b 100644 --- a/ui/frame_steam.py +++ b/ui/frame_steam.py @@ -141,6 +141,21 @@ OWNED_JS = r""" })() """ +# Software has app_type 2, so utility ownership must not use OWNED_JS's games filter. +# Steam prices checked 2026-09-28: OVR Advanced Settings is paid on Steam too. +UTILITY_IDS = (1009850, 1173510, 1068820, 908520, 1494460) +FREE_UTILITIES = (1494460,) +UTILITIES_JS = """(() => { + const apps = appStore.allApps; + if (!apps || !apps.length) throw new Error("Steam library is not loaded; ownership is unknown"); + return [1009850,1173510,1068820,908520,1494460].map(id => { + const a = apps.find(a => a.appid === id); + return {id, owned: !!a, installed: !!a?.local_per_client_data?.installed, + frame: a ? (a.steam_hw_compat_category_packed >> 8) & 3 : 0}; + }); +})()""" + + WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({ state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0, free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))""" @@ -158,6 +173,10 @@ def owned(): def install(appid): page = Page() + if appid in UTILITY_IDS and appid not in FREE_UTILITIES: + rows = page.eval(UTILITIES_JS) + if not any(r['id'] == appid and r['owned'] for r in rows): + raise Fail("This paid utility is not owned by the Frame account. No install or store action was taken.") app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})" f"(appStore.GetAppOverviewByAppID({appid}))") if app and app["installed"]: @@ -216,6 +235,8 @@ def main(): cmd = sys.argv[1] if len(sys.argv) > 1 else "" if cmd == "owned": out = owned() + elif cmd == "utilities": + out = {"utilities": Page().eval(UTILITIES_JS)} elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit(): out = (install if cmd == "install" else store)(int(sys.argv[2])) else: diff --git a/ui/frame_steamgriddb.py b/ui/frame_steamgriddb.py new file mode 100644 index 0000000..6a18bba --- /dev/null +++ b/ui/frame_steamgriddb.py @@ -0,0 +1,105 @@ +"""Optional SteamGridDB artwork. Credentials stay on the host, never in app metadata.""" +import json +import os +import re +import tempfile +import time +import unicodedata +import urllib.parse + +import frame_host + +API = 'https://www.steamgriddb.com/api/v2' +MAX_JSON = 2 * 1024 * 1024 + + +def settings_path(): + return frame_host.data_dir('artwork-settings.json') + + +def api_key(): + env = os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY') + if env: + return env.strip() + try: + return str(json.loads(settings_path().read_text()).get('steamgriddb_api_key') or '') + except (OSError, ValueError, AttributeError): + return '' + + +def settings(): + return {'steamgriddb_configured': bool(api_key()), + 'environment': bool(os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY'))} + + +def save_settings(body): + key = body.get('steamgriddb_api_key') + if not isinstance(key, str) or len(key) > 200 or (key and not re.fullmatch(r'[A-Za-z0-9_-]+', key)): + raise ValueError('enter a valid SteamGridDB API key, or an empty value to remove it') + path = settings_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd, temp = tempfile.mkstemp(prefix='.artwork-', dir=str(path.parent)) + try: + with os.fdopen(fd, 'w') as f: + json.dump({'steamgriddb_api_key': key}, f) + os.replace(temp, path) + finally: + if os.path.exists(temp): + os.remove(temp) + return settings() + + +def _get(path, key, deadline=None): + from apk_sources import _images + # No redirects: the credential never goes anywhere but the API. Errors never contain it. + data = _images.get(API + path, {'Authorization': 'Bearer ' + key, 'Accept': 'application/json'}, redirects=0, + deadline=time.monotonic() + 12 if deadline is None else min(deadline, time.monotonic() + 12), + limit=MAX_JSON) + result = json.loads(data) + if not isinstance(result, dict) or not result.get('success') or not isinstance(result.get('data'), list): + raise ValueError('SteamGridDB lookup failed') + return result['data'] + + +def _name(value): + # Letters and digits of any script, so a CJK title never normalises to ''. + return ''.join(c for c in unicodedata.normalize('NFKC', str(value or '')).casefold() if c.isalnum()) + + +def lookup(name, deadline=None): + """Best-voted art per slot for an exact title match; unrelated games are never guessed.""" + key = api_key() + if not key: + return {}, [] + try: + names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))} - {''} + if not names: + return {}, [] + matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key, deadline) + game = next((g for g in matches if isinstance(g, dict) and _name(g.get('name')) in names), None) + if not game: + return {}, [] + gid = int(game['id']) + result, warnings = {}, [] + for slot, kind, dimensions in [('grid', 'grids', '600x900'), ('wide', 'grids', '920x430'), + ('hero', 'heroes', ''), ('logo', 'logos', ''), ('icon', 'icons', '')]: + try: + # Logos and icons only come as PNG (or WebP/ICO); asking for JPEG there is rejected outright. + mimes = 'image/png,image/jpeg' if kind in ('grids', 'heroes') else 'image/png' + query = {'types': 'static', 'nsfw': 'false', 'humor': 'false', 'mimes': mimes} + if dimensions: + query['dimensions'] = dimensions + records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key, deadline) + records = [r for r in records if isinstance(r, dict) and str(r.get('url', '')).startswith('https://') + and not r.get('nsfw')] + if dimensions: + w, h = map(int, dimensions.split('x')) + records = [r for r in records if (r.get('width'), r.get('height')) == (w, h)] + records.sort(key=lambda r: (int(r.get('score') or 0), int(r.get('upvotes') or 0)), reverse=True) + if records: + result[slot] = records[0]['url'] + except Exception: # HTTPException, odd JSON: this slot falls back + warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art') + return result, warnings + except Exception: + return {}, ['SteamGridDB unavailable; using source or generated art'] diff --git a/ui/frame_titles.py b/ui/frame_titles.py index bc208a0..7bfbaca 100644 --- a/ui/frame_titles.py +++ b/ui/frame_titles.py @@ -16,9 +16,9 @@ checked on a headset; see docs/sideloading.md. Python stdlib only. CLI: python3 ui/frame_titles.py inspect PATH python3 ui/frame_titles.py install PATH [--name N] [--exe REL] [--runtime R] - python3 ui/frame_titles.py list | launch ID | remove ID + python3 ui/frame_titles.py list | launch ID | remove ID | refresh-art ID|--all """ -import hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile +import base64, hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile import frame_android import frame_host @@ -632,7 +632,7 @@ def _rsync(): _install_lock = threading.Lock() -def install(path, name=None, exe=None, runtime=None, progress=None): +def install(path, name=None, exe=None, runtime=None, progress=None, artwork=None): """Sideload a .zip, folder or executable as a Devkit Game; returns the title dict. name: the Steam name (sanitised to the title id), default from the file name. @@ -643,12 +643,12 @@ def install(path, name=None, exe=None, runtime=None, progress=None): """ plan = inspect(path, name) try: - return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress) + return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress, artwork=artwork) finally: discard(plan) -def install_plan(plan, name=None, exe=None, runtime=None, progress=None): +def install_plan(plan, name=None, exe=None, runtime=None, progress=None, artwork=None): """Install an inspect() plan, optionally with another name, target or runtime.""" if name: plan['name'], plan['id'] = name, title_id(name) @@ -656,13 +656,21 @@ def install_plan(plan, name=None, exe=None, runtime=None, progress=None): _choose(plan, exe or plan['target'], runtime) step = progress or (lambda *a: None) with _install_lock: - return _install(plan, step) + return _install(plan, step, artwork) -def _install(plan, step): +def _install(plan, step, artwork=None): gid = plan['id'] if not NEW_ID_RE.match(gid) or gid.lower() in RESERVED_IDS: raise FrameError(f'bad title id {gid!r}') + icon = None + for filename in ('icon.png', 'logo.png'): + path = os.path.join(plan['root'], filename) + if os.path.isfile(path): + with open(path, 'rb') as f: + icon = f.read(frame_android.frame_artwork.MAX_IMAGE + 1) + break + images, warnings = frame_android.frame_artwork.prepare(plan['name'], icon, artwork) step("Syncing Valve's devkit tools to the Frame", 0.02) ensure_utils() existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes' @@ -672,6 +680,7 @@ def _install(plan, step): if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'): raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}') registered = False + library_ready, shortcut, steam_registered = False, None, False try: step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1) if _rsync(): @@ -687,17 +696,40 @@ def _install(plan, step): reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}', timeout=90), 'steam-client-create-shortcut') meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'], - 'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S')} + 'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'), + # Until art is applied: the only entries automatic backfill may touch. + 'art_pending': True} ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30) registered = True # the files stay: Steam registers them once it's running if 'error' in reply: err = str(reply['error']).strip().rstrip('.') hint = ' With Steam running on the Frame, install it again.' if 'not running' in err else '' raise FrameError(f"Uploaded, but Steam didn't register it: {err}.{hint}") + steam_registered = True + shortcut = _library_shortcut(gid, directory) + if not shortcut: + raise FrameError('Steam registered the title but its shortcut is not available for mandatory artwork; retry install') + result = frame_android.apply_library(shortcut, plan['name'], directory + '/.frame-artwork', images, + category='Sideloaded', details={'source': plan['source']}) + meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False, + library_warnings=warnings + result.get('warnings', [])) + ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30) + library_ready = True step('Done', 1.0) meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip()) return meta finally: + if steam_registered and not library_ready and not existed: + # A newly registered title must not remain as a blank library tile. Cleanup + # failures are swallowed so the error that got us here is the one reported. + try: + if shortcut: + frame_android.shortcut_tool('remove', str(shortcut)) + else: + ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120) + except FrameError: + pass + registered = False if not registered and not existed: # A first install that failed part-way: don't leave an orphan folder behind. try: @@ -706,6 +738,90 @@ def _install(plan, step): pass + +def _library_shortcut(gid, directory): + """The Steam shortcut of title gid, only ever one that is provably this title's. + + Steam's app overviews don't expose devkit_gameid (checked 2026-09-28, build 20260925.6191901), + so after the saved id this matches the shortcut's executable or start folder inside directory. + Never by display name: another non-Steam shortcut could share it and would be renamed or deleted. + """ + shortcuts = json.loads(frame_android.shortcut_tool('list')) + matches = [s for s in shortcuts if s.get('devkit_gameid') == gid] + if not matches: + try: + meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json 2>/dev/null || true') or 'null') + matches = [s for s in shortcuts if meta.get('shortcut') and s.get('appid') == meta.get('shortcut')] + except (ValueError, AttributeError): + pass + if not matches: + root = posixpath.normpath(directory) + + def inside(path): + path = str(path or '').strip().strip('"') + return bool(path) and (posixpath.normpath(path) + '/').startswith(root + '/') + matches = [s for s in shortcuts if inside(s.get('exe')) or inside(s.get('start_dir'))] + if len(matches) > 1: + raise FrameError('ambiguous Steam shortcut for ' + gid) + return int(matches[0]['appid']) if matches else None + + +def _home(): + return ssh('echo $HOME', timeout=30).strip() + + +def refresh_art(gid=None, artwork=None, fill_only=False): + """Render and apply Steam artwork for Frame Control's titles (all of them when gid is None). + + fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library).""" + if gid is None: + results = [] + for t in list_titles(): + if not t['frame_control']: + continue + try: + results.append(refresh_art(t['id'], artwork, fill_only)) + except Exception as e: # report each title; one failure doesn't stop the rest + results.append({'id': t['id'], 'name': t['name'], 'error': str(e) or type(e).__name__}) + return results + with _install_lock: + gid = _check_id(gid) + try: + meta = json.loads(ssh(f'cat {GAMES}/{gid}-framecontrol.json', timeout=30)) + except (FrameError, ValueError): + meta = None + if not isinstance(meta, dict): + raise FrameError(f'{gid} was not installed by Frame Control') + directory = f'{_home()}/{GAMES}/{gid}' + script = frame_android.cached_art_script(directory + '/.frame-artwork') + f""" +icon = '' +for name in ('icon.png', 'logo.png'): + try: + with open(os.path.join({directory!r}, name), 'rb') as f: + icon = base64.b64encode(f.read(12 * 1024 * 1024 + 1)).decode() + break + except OSError: + pass +print(json.dumps({{'artwork': cached, 'icon': icon}})) +""" + found = _json_out(ssh('python3 -', input=script, timeout=60), 'the title artwork') + cached = {k: base64.b64decode(v) for k, v in (found.get('artwork') or {}).items()} + icon = base64.b64decode(found.get('icon') or '') or None + name = str(meta.get('name') or gid) + images, warnings = frame_android.frame_artwork.prepare(name, icon, artwork if artwork is not None else cached) + shortcut = _library_shortcut(gid, directory) + if not shortcut: + raise FrameError(f"Steam hasn't registered {gid} yet; with Steam running on the Frame, refresh again") + result = frame_android.apply_library(shortcut, name, directory + '/.frame-artwork', images, + category='Sideloaded', details={'source': meta.get('source')}, + fill_only=fill_only) + meta.update(shortcut=shortcut, artwork=result.get('artwork', {}), art_pending=False, + library_warnings=warnings + result.get('warnings', []), + artwork_refreshed=time.strftime('%Y-%m-%dT%H:%M:%S')) + ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30) + return meta + + LIST_SCRIPT = r''' import json, os root = os.path.expanduser('~/devkit-game') @@ -741,7 +857,9 @@ def list_titles(): 'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'), 'source': str(meta.get('source') or ''), 'size': meta.get('size'), 'installed': meta.get('installed'), 'registered': t.get('settings') is not None, - 'frame_control': bool(meta)}) + 'frame_control': bool(meta), + # Only a title whose install couldn't apply art; older installs have no flag and keep theirs. + 'art_pending': bool(meta.get('art_pending')), 'art_missing': bool(meta.get('art_pending'))}) return titles @@ -772,9 +890,20 @@ def remove(gid): try: gid = _check_id(gid) ensure_utils() - # steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too. + try: + shortcut = _library_shortcut(gid, f'{_home()}/{GAMES}/{gid}') + except (FrameError, ValueError): + shortcut = None + # steamos-delete removes the folder, the shortcut and its Proton prefix (found through the + # shortcut), so it runs first; its json files stay, so clear them too. ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120) ssh(f'rm -f {_json_files(gid)}', timeout=30) + # Then tidy what it leaves (artwork, collections); best effort. + if shortcut: + try: + frame_android.shortcut_tool('remove', str(shortcut)) + except (FrameError, ValueError): + pass return {'id': gid} finally: _install_lock.release() @@ -815,8 +944,15 @@ def main(): progress=lambda text, _: print(text + '…', file=sys.stderr)) elif cmd == 'list': r = list_titles() + if any(t['art_missing'] for t in r): + print('Some titles have no Steam artwork: python3 ui/frame_titles.py refresh-art --all', file=sys.stderr) elif cmd in ('launch', 'remove') and args: r = globals()[cmd](args[0]) + elif cmd == 'refresh-art' and args: + r = refresh_art(None if args[0] == '--all' else args[0]) + if isinstance(r, list) and any('error' in t for t in r): + print(json.dumps(r, indent=1)) + raise SystemExit(1) else: sys.exit(__doc__) except FrameError as e: diff --git a/ui/frame_utilities.py b/ui/frame_utilities.py new file mode 100644 index 0000000..d3a4019 --- /dev/null +++ b/ui/frame_utilities.py @@ -0,0 +1,29 @@ +"""Optional software, separate from Frame Control's own controls and HUD. + +Public reports are attributed leads, never local verification. Compatibility +reports reuse the existing database with steam: package keys. +""" +REPORT = 'https://www.roadtovr.com/valve-steam-frame-review/' +UTILITIES = ( + (1009850, 'OVR Advanced Settings', False, 'No verified Frame result. Steam edition is paid; the developer also publishes free source/releases.', 'https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings'), + (1173510, 'XSOverlay', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT), + (1068820, 'OVR Toolkit', False, 'Supplied research reports Proton support, but the linked review did not corroborate it on recheck. Untested.', REPORT), + (908520, 'fpsVR', False, 'No Frame-specific result established in the supplied public research. Untested here.', 'https://store.steampowered.com/app/908520/'), + (1494460, 'Desktop+', True, 'Free, developer-published software. No verified Frame result.', 'https://github.com/elvissteinjr/DesktopPlus'), +) + + +def catalogue(ownership, reports): + owned = {r['id']: r for r in ownership} + out = [] + for appid, name, free, note, source in UTILITIES: + local = owned.get(appid, {}) + matches = [r for r in reports if r.get('package') == f'steam:{appid}' and r.get('rating') in ('works', 'issues', 'broken')] + latest = max(matches, key=lambda r: r.get('date') or '') if matches else None + out.append({'id': appid, 'name': name, 'free': free, 'owned': local.get('owned'), + 'installed': local.get('installed', False), 'frame': local.get('frame', 0), + 'status': latest['rating'] if latest else 'untested', + 'report': {k: latest.get(k) for k in ('notes', 'date', 'steamos', 'source')} if latest else None, + 'note': note, 'source': source, + 'canInstall': bool(free or local.get('owned'))}) + return {'utilities': out} diff --git a/ui/frame_vr.py b/ui/frame_vr.py new file mode 100644 index 0000000..6ebf8c2 --- /dev/null +++ b/ui/frame_vr.py @@ -0,0 +1,126 @@ +"""Frame Control's optional performance HUD, using Frame platform tools only. + +Controls remain blocked pending idle-headset verification; no playspace writes +are exposed. See docs/vr-utilities.md for the probe evidence and follow-up. +""" +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import sys +import time + +from frame_status import battery, max_temp, performance + +ROOT = Path.home() / '.local/share/frame-control/vr' +APPID = '2000250025' + + +def validate(body): + action = body.get('action') + if action not in ('hud-start', 'hud-stop'): + raise ValueError('VR action must be hud-start or hud-stop; playspace controls are not yet verified') + return action + + +def save(path, data): + tmp = path.with_suffix('.tmp') + tmp.write_text(json.dumps(data)) + os.replace(tmp, path) + + +def process_identity(pid): + try: + stat = Path(f'/proc/{pid}/stat').read_text().rsplit(')', 1)[1].split() + args = Path(f'/proc/{pid}/cmdline').read_bytes().split(b'\0') + if b'frame-control-hud' in args and b'xterm' in Path(f'/proc/{pid}/comm').read_bytes(): + return stat[19] # field 22, starttime; protects against PID reuse + except OSError: + pass + return None + + +def panel(action): + path = ROOT / 'hud.json' + saved = json.loads(path.read_text()) if path.exists() else {} + pid = saved.get('pid') + running = bool(pid and saved.get('start') and process_identity(pid) == saved['start']) + if action == 'hud-stop': + if running: + os.kill(pid, signal.SIGTERM) # xterm closes the PTY; child exits on HUP + path.unlink(missing_ok=True) + return {'message': 'HUD closed.'} + if running: + return {'message': 'HUD is already open. Find Frame Control HUD in the SteamVR dashboard.'} + env = {**os.environ, 'DISPLAY': ':0'} + p = subprocess.Popen(['xterm', '-name', 'frame-control-hud', '-title', 'Frame Control HUD', + '-fa', 'Monospace', '-fs', '20', '-geometry', '56x16', + '-bg', '#171d25', '-fg', '#d6d7d8', '-e', + sys.executable, str(ROOT / 'frame_vr.py'), 'hud'], + env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + try: + deadline = time.monotonic() + 8 + while time.monotonic() < deadline and p.poll() is None: + tree = subprocess.check_output(['xwininfo', '-display', ':0', '-root', '-tree'], + text=True, timeout=2) + for w in re.findall(r'(0x[0-9a-f]+).*"frame-control-hud"', tree): + owner = subprocess.check_output(['xprop', '-display', ':0', '-id', w, '_NET_WM_PID'], + text=True, timeout=2) + if owner.strip().endswith('= ' + str(p.pid)): + subprocess.run(['xprop', '-display', ':0', '-id', w, '-f', 'STEAM_GAME', '32c', + '-set', 'STEAM_GAME', APPID], check=True, timeout=2) + identity = process_identity(p.pid) + if not identity: + raise RuntimeError('HUD process exited before its panel was ready') + save(path, {'pid': p.pid, 'start': identity}) + return {'message': 'HUD opened. In SteamVR, select Frame Control HUD and Float in World or dock it to a controller.'} + time.sleep(.1) + raise RuntimeError('HUD did not create a window; is gamescope running?') + except BaseException: + if p.poll() is None: + p.terminate() + p.wait(timeout=3) + raise + + +def hud(): + def fmt(v, unit=''): + return 'unavailable' if v is None else f'{v:.1f}{unit}' + while True: + p, b = performance(), battery() or {} + lines = ['FRAME CONTROL HUD', '', + 'Compositor FPS ' + fmt(p['compositorFps']), + 'Compositor period ' + fmt(p['frameMs'], ' ms'), + 'Application FPS ' + fmt(p['appFps']), + 'Render GPU time ' + fmt(p['gpuMs'], ' ms'), + 'Compositor CPU ' + fmt(p['compositorCpuMs'], ' ms'), + 'System CPU ' + fmt(p['cpuPercent'], '%'), + 'GPU clock ' + fmt(p['gpuMHz'], ' MHz'), + 'Hottest sensor ' + fmt(max_temp(), ' C'), + 'Battery ' + fmt(b.get('percent'), '%'), '', + time.strftime('Updated %H:%M:%S'), 'Close this window to stop.'] + print('\033[2J\033[H' + '\n'.join(lines), flush=True) + time.sleep(2) + + +def dispatch(body): + import fcntl # Frame only; validation is also imported by Windows hosts + action = validate(body) + ROOT.mkdir(parents=True, exist_ok=True, mode=0o700) + with (ROOT / 'control.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + return panel(action) + + +if __name__ == '__main__': + if sys.argv[1:] == ['hud']: + hud() + else: + try: + print(json.dumps(dispatch(json.load(sys.stdin)))) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as e: + print(json.dumps({'error': str(e)})) + sys.exit(1) diff --git a/ui/index.html b/ui/index.html index bafe060..9ae4592 100644 --- a/ui/index.html +++ b/ui/index.html @@ -86,6 +86,58 @@ .wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; } .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: 190px; 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; } + .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; } + .dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; + padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } + .dlg::backdrop { background: rgba(0,0,0,.55); } + .dlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); } + .dlg h3, [data-page=devices] h3 { margin: 16px 0 6px; font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--muted); font-weight: 700; } + .dlg label, .dev-form label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; } + .dlg label input, .dev-form label input { margin-top: 5px; } + .facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 0; font-size: 13px; } + .facts dt { color: var(--muted); } .facts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; } + .stages { list-style: none; margin: 0; padding: 0; } + .stages li { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 8px; padding: 6px 0; border-top: 1px solid rgba(255,255,255,.05); } + .stages li:first-child { border-top: 0; } + .stages .ic { width: 16px; height: 16px; border-radius: 50%; margin-top: 2px; display: grid; place-items: center; font-size: 11px; font-weight: 700; } + .stages .done .ic { background: rgba(89,191,64,.2); color: var(--green-hi); } + .stages .failed .ic { background: rgba(217,65,38,.25); color: #ff8a73; } + .stages .pending .ic { border: 1.5px solid var(--dim); } + .stages .active .ic { border: 2px solid rgba(255,255,255,.15); border-top-color: var(--blue); animation: spin .8s linear infinite; } + .stages .lb { color: var(--bright); font-size: 13.5px; } .stages .pending .lb { color: var(--muted); } + .stages .dt { grid-column: 2 / 4; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; } + .stages .failed .dt { color: #ff8a73; } + .stages .tm { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; } + .probes { width: 100%; border-collapse: collapse; font-size: 12.5px; } + .probes td { padding: 5px 8px 5px 0; border-top: 1px solid rgba(255,255,255,.05); vertical-align: top; } + .probes td:first-child { color: var(--bright); min-width: 170px; overflow-wrap: anywhere; } + .res-answered, .res-ok { color: var(--green-hi); } .res-refused, .res-sshfailed, .res-wrong, .res-denied { color: #ff8a73; } + .res-timeout, .res-unresolved, .res-unreachable, .res-unpinned { color: var(--warn); } + .res-trying, .res-resolving, .res-waiting, .res-checking { color: var(--link); } + .dev-grid { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(0, 2.2fr); gap: 22px; align-items: start; } + @media (max-width: 1000px) { .dev-grid { grid-template-columns: 1fr; } } + .dev-item { cursor: pointer; border-radius: 3px; padding: 10px !important; margin: 0 -10px; } + .dev-item:hover { background: rgba(255,255,255,.04); } + .dev-item.sel { background: rgba(26,159,255,.12); } + .dev-form { display: grid; grid-template-columns: 2fr 1.3fr 1fr .8fr; gap: 0 10px; align-items: end; } + .dev-form input[readonly] { color: var(--muted); } + .dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; + padding: 8px 8px; font: inherit; font-size: 13px; } + .addr .t { overflow-wrap: anywhere; } + .addr .s { white-space: normal; } + .addr-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; } } + .found { margin-top: 10px; } + /* ---- drop anywhere ---- */ #media select { min-width: 0; max-width: 100%; flex: 1; } .dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none; @@ -102,6 +154,7 @@ .shelf-head .count { color: var(--muted); font-size: 13px; } .shelf-head .spacer { flex: 1; } .sub { color: var(--muted); font-size: 12.5px; } + .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .hint { color: var(--muted); font-size: 12.5px; margin-top: 11px; line-height: 1.5; } /* ---- buttons ---- */ @@ -122,7 +175,7 @@ .seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; } .seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; } .seg button.on { background: var(--btn-hi); color: var(--bright); } - input[type=text], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; + input[type=text], input[type=search], input[type=url], input[type=password], input[type=email], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 9px 11px; font: inherit; } textarea { resize: vertical; min-height: 76px; } input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); } @@ -205,7 +258,12 @@ .shot-card .row { flex-wrap: nowrap; } .shot-card .grow { flex: 1; min-width: 0; } .shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .shot-card .s { color: var(--muted); font-size: 12px; } + .shot-card .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .ctx-menu { position: fixed; z-index: 1000; min-width: 200px; padding: 4px; border-radius: 4px; background: #232c38; + box-shadow: 0 10px 28px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.08); } + .ctx-menu button { display: block; width: 100%; height: 30px; padding: 0 10px; text-align: left; background: none; } + .ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--blue); color: #fff; outline: none; } + .ctx-menu hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 4px 2px; } /* ---- library shelf (portrait capsules, like Steam's library home) ---- */ .shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; } @@ -243,6 +301,11 @@ background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; } .notice .grow { flex: 1; min-width: 260px; } .notice .progress { width: 160px; margin-top: 0; display: block; } + .contact-opts { display: flex; gap: 6px 18px; flex-wrap: wrap; margin-top: 8px; } + .contact-opts label { display: inline-flex; gap: 7px; align-items: center; cursor: pointer; } + .contact-opts input { width: 15px; height: 15px; margin: 0; accent-color: var(--blue); } + #contactNotice input[type=email] { width: min(320px, 100%); margin-top: 8px; padding: 7px 10px; } + #cEmail { max-width: 420px; } .popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; } .popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); } .popt b { font-weight: 600; color: var(--text); } @@ -317,8 +380,8 @@ .and-btns { flex-basis: 100%; padding-left: 46px; } .and-icon { width: 36px; height: 36px; border-radius: 8px; flex: none; background: rgba(0,0,0,.25); object-fit: cover; } .cat-tools { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } - .cat-tools input[type=text] { flex: 1 1 240px; width: auto; } - .cat-tools select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; + .cat-tools input[type=text], .cat-tools input[type=search] { flex: 1 1 240px; width: auto; } + .cat-tools select, .source-offer select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 9px 10px; font: inherit; font-size: 13px; max-width: 220px; } .vchips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 14px; } .vchip { height: 26px; font-size: 12px; padding: 0 10px; opacity: .55; } @@ -360,7 +423,7 @@ .disp .k2 { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin: 12px 0 5px; } .disp .seg { flex-wrap: wrap; } .disp .seg button { padding: 0 10px; } - .disp input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; + .disp input[type=number], #comfort input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; } /* Touch screens can't hover: keep the library's name and Play button showing. */ @media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } } @@ -415,7 +478,7 @@ button { height: 38px; } button.small { height: 32px; } .actions button { height: 46px; } - .cat-tools select { max-width: none; flex: 1 1 100%; } + .cat-tools select, .source-offer select { max-width: none; flex: 1 1 100%; } .pad-area { height: 240px; } .pad-keys .seg { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); } .pad-keys .pad-clicks { grid-template-columns: repeat(2, 1fr); } @@ -423,6 +486,155 @@ /* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */ body.typing nav { display: none; } } + /* ---- Discover: artwork first, details when you need them ---- */ + .sr-only { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; } + #androidLibrary { grid-template-columns:1fr; } + #androidLibrary .and-col { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr)); align-items:start; } + .store-tabs { display:flex; align-items:center; gap:8px; margin-bottom:20px; border-bottom:1px solid #ffffff12; padding-bottom:14px; } + .store-tabs > button { background:transparent; color:var(--muted); border-radius:6px; } + .store-tabs > button.on { color:var(--bright); background:#ffffff0e; } + .store-heading h1 { font-size:clamp(28px,3.2vw,38px); letter-spacing:-1.2px; line-height:1.15; margin:8px 0 10px; font-weight:700; color:var(--bright); } + .store-heading p, .sources-intro p { color:#a8b5c2; margin:0 0 24px; font-size:15px; } + .store-eyebrow { color:var(--link); font-size:11px; text-transform:uppercase; font-weight:700; letter-spacing:1.8px; } + .store-toolbar { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,400px); align-items:center; gap:32px; } + .store-toolbar .store-heading p { margin-bottom:0; } + @media(max-width:900px) { .store-toolbar { display:block; } .store-toolbar .store-heading p { margin-bottom:20px; } } + .store-search { display:flex; align-items:center; gap:12px; border:1px solid #ffffff12; background:#0e1723a6; border-radius:8px; padding:6px 8px 6px 16px; } + .store-search svg { width:21px; height:21px; color:var(--muted); flex:none; } + .store-search input[type=search] { background:transparent; padding:9px 0; font-size:15px; border:0; box-shadow:none; min-width:0; } + .store-search:focus-within { border-color:var(--link); } + .store-search button { border-radius:5px; } + .store-filters { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:16px 0 24px; } + .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; } + 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; } + .store-result-count:empty { display:none; } + .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-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; } + .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; } + .store-row-head p { color:var(--muted); font-size:12px; margin:0; } + .store-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(225px,1fr)); gap:18px; } + .store-card { display:block; width:100%; height:auto; min-width:0; padding:0; border:1px solid #ffffff0b; border-radius:9px; overflow:hidden; text-align:left; background:#1b2938; color:var(--text); transition:transform .18s,box-shadow .18s; font-weight:400; } + .store-card:hover { transform:translateY(-4px); background:#223448; box-shadow:0 12px 28px #0004; } + .store-card .store-art { aspect-ratio:16/9; width:100%; } + .store-card-content { padding:15px 14px; display:block; } + .store-card-title { display:flex; align-items:center; gap:9px; margin-bottom:9px; min-width:0; } + .store-icon { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:8px; overflow:hidden; flex:none; background:hsl(var(--hue) 35% 28%); color:#fff; font-size:17px; position:relative; } + .store-icon img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; } + .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; } + .store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; } + .store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; } + .store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; } + .store-empty p { max-width:390px; color:var(--muted); margin:0 0 22px; } + .store-skeleton { aspect-ratio:1.2; background:linear-gradient(110deg,#233446 30%,#30455b 45%,#233446 60%); background-size:250% 100%; border-radius:9px; animation:store-shimmer 1.8s ease infinite; } + @keyframes store-shimmer { to { background-position:-150% 0; } } + .store-dialog { padding:0; border:1px solid #ffffff1a; border-radius:14px; background:#172331; color:var(--text); width:min(980px,calc(100vw - 40px)); max-width:none; max-height:calc(100dvh - 48px); overflow:auto; box-shadow:0 30px 100px #0009; } + .store-dialog::backdrop { background:#030a13c9; backdrop-filter:blur(7px); } + .store-close { position:sticky; float:right; top:14px; margin:14px 14px -54px 0; z-index:6; height:36px; width:36px; padding:0; border-radius:50%; background:#09131dcc; color:white; font-size:25px; line-height:1; border:1px solid #ffffff30; } + .detail-banner { height:195px; } + .detail-banner::after { content:""; position:absolute; inset:0; z-index:2; background:linear-gradient(0deg,#172331,transparent 70%); } + .detail-content { padding:0 30px 30px; position:relative; } + .detail-heading { display:flex; gap:16px; align-items:center; margin-top:-24px; position:relative; z-index:3; } + .detail-heading .store-icon { width:70px; height:70px; border-radius:15px; box-shadow:0 3px 18px #0004; font-size:32px; } + .detail-heading h1 { font-size:30px; color:white; letter-spacing:-.7px; margin:0 0 4px; } + .detail-heading p { margin:0; color:#a8b5c2; font-size:13px; } + .detail-content > .store-badges { margin:20px 0; } + .detail-layout { display:grid; grid-template-columns:minmax(0,1fr) 290px; gap:30px; margin-top:22px; } + .detail-layout h2 { text-transform:none; letter-spacing:0; font-size:17px; color:white; margin:0 0 14px; } + .detail-description { color:#b5c2ce; font-size:14px; line-height:1.75; white-space:pre-line; } + .detail-gallery { display:flex; gap:10px; overflow-x:auto; scroll-snap-type:x mandatory; margin:0 0 10px; } + .detail-gallery .store-art { flex:0 0 86%; aspect-ratio:16/9; border-radius:7px; scroll-snap-align:start; } + .detail-gallery .store-art img { object-fit:contain; background:#0b1420; } + .gallery-controls { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; font-size:11px; color:var(--muted); } + .gallery-controls button { height:30px; width:34px; padding:0; border-radius:5px; } + .detail-buy { align-self:start; background:#0e1926; padding:18px; border:1px solid #ffffff0a; border-radius:10px; } + .detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; } + .detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; } + .detail-verdict.blocked { color:#e3b77a; } + .detail-verdict.unknown { color:#adbac7; } + .detail-price { font-size:23px; color:white; font-weight:600; } + .detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; } + .offer-choice { display:flex; align-items:flex-start; gap:9px; padding:11px 9px; margin-top:8px; border:1px solid #ffffff12; border-radius:7px; cursor:pointer; } + .offer-choice:has(input:checked) { background:#1e3c52; border-color:#5386a6; } + .offer-choice input { margin:4px 0 0; accent-color:var(--blue); } + .offer-choice strong { font-size:12px; display:block; color:var(--bright); } + .offer-choice small { display:block; font-size:10px; color:#a8bbc9; margin-top:4px; } + .offer-mark { display:inline-flex; justify-content:center; align-items:center; height:26px; width:26px; flex:none; border-radius:6px; background:hsl(var(--hue) 30% 30%); color:white; font-size:11px; } + .detail-technical { border-top:1px solid #ffffff14; margin-top:25px; padding-top:16px; font-size:12px; color:var(--muted); } + .detail-technical summary { cursor:pointer; } + .detail-technical dl { display:grid; grid-template-columns:110px minmax(0,1fr); gap:6px; } + .detail-technical dd { margin:0; overflow-wrap:anywhere; } + .detail-buy .store-primary:disabled { opacity:1; color:#d6e8f7; background:#274f71; cursor:default; } + #detailProgress::-webkit-progress-bar { background:#283b4d; border-radius:3px; } + #detailProgress::-webkit-progress-value { background:var(--blue); border-radius:3px; } + #detailProgress { height:4px; width:100%; accent-color:var(--blue); display:block; margin-bottom:10px; } + .sources-dialog { width:min(560px,calc(100vw - 32px)); padding:30px; } + .sources-dialog .store-close { margin:-16px -16px -20px 0; top:0; } + .sources-intro h1 { font-size:28px; color:white; letter-spacing:-.7px; margin:9px 0; } + .source-setting { display:flex; align-items:center; gap:13px; padding:17px 0; border-top:1px solid #ffffff0c; } + .source-setting .offer-mark { width:42px; height:42px; font-size:16px; border-radius:10px; } + .source-setting .grow { flex:1; min-width:0; } + .store-dialog a { color:var(--link); } + .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-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; } + .source-toggle:checked::after { transform:translateX(15px); } + .source-repo-form { display:grid; gap:12px; border-top:1px solid #ffffff14; margin-top:10px; padding-top:24px; } + .source-repo-form h2 { font-size:19px; letter-spacing:0; text-transform:none; color:white; margin:0; } + .source-repo-form p { color:var(--muted); font-size:12px; margin:0; } + .source-repo-form label { font-size:12px; } + .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; } + #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)); } } + @media(max-width:1000px) { .store-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } .detail-layout { grid-template-columns:minmax(0,1fr) 260px; gap:20px; } } + @media(max-width:680px) { + .store-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; } + .store-tabs { margin-bottom:24px; gap:2px; } .store-tabs button { padding:0 10px; font-size:12px; } + .store-heading p { font-size:13px; max-width:320px; } + .store-filters { align-items:flex-start; flex-direction:column; gap:10px; margin-bottom:18px; } + .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; } + .store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; } + .store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; } + .store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; } + .store-card-foot { font-size:10px; margin-top:12px; } .store-summary { font-size:11px; } + .store-dialog { width:calc(100vw - 16px); max-height:calc(100dvh - 20px); border-radius:10px; } + .detail-banner { height:190px; } .detail-content { padding:0 18px 24px; } .detail-heading h1 { font-size:24px; } + .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; } } + @@ -441,9 +653,11 @@ Games2 Android3 Tools4 + Devices5
- Connecting… + + — + @@ -496,6 +722,7 @@ + @@ -575,6 +802,55 @@ +
+

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.
+
+ + +
+
The HUD is ours and uses the Frame’s built-in tools. Place its panel with SteamVR’s Float in World or controller docking controls. It updates every two seconds until closed.
+

Playspace and seated comfort

+

Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.

+
For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.
+
Optional VR software +

None of Frame Control’s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.

+ +
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.

+
+
+ + + +
+
+ + + + + +
+
+

Checking session…

+ +

A one-minute warning, then Steam Home. Games stay running: save and pause first. Keep this app open and connected for notifications.

+
How sessions and alerts work +

This is a reminder, not a parental lock. The timer continues on the Frame if you disconnect; a headset restart cancels it. Cancel before changing settings. Set break/check-in to 0 to turn them off.

+

Battery, heat and check-in alerts appear on connected companions during a session. Headset warnings and break reminders continue without a companion. iOS may suspend phone notifications in the background. Breaks and check-ins count SteamVR activity, not confirmed wear time.

+
+
+ +
+

Keyboard and trackpad

@@ -612,7 +888,7 @@
Loading…
-
Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
+
Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to ~/Pictures/SteamFrame. Right-click for more.
@@ -655,9 +931,16 @@
-

Android apps

-
-
+
+
+
A little more possibility

Find your next favorite.

Great games and useful apps. All in one place, all free to explore.

+
+
+ +
+
+
+ -
-

Find apps

+
@@ -788,6 +1071,19 @@ +
+

Library artwork settings

+

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

+ + +
+ + + Get a free key +
+
+ +

Remote & power

@@ -810,6 +1106,15 @@
+
+

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.
+
+
+
+
+
+

Headsets

+
+
Loading…
+
Frame Control talks to one headset at a time. Each can be reached at several addresses; + it tries them all at once and uses the best one that answers on the network you're on.
+

This computer's network

+
Checking…
+
+ +
+
Networks are told apart by their router (its IP and hardware address), so this works on wired + networks and when your computer won't share the Wi-Fi name.
+
+
Pick a headset.
+
+
+ +

Connection

+
+

This computer

+
+

Steps

+
    +

    Addresses

    +
    +
    + + + +
    +
    + +
    +

    Add a headset

    +
    This opens Set Up Connection in a terminal window. On the headset, first turn on Steam Settings → + System → Enable Developer Mode, then Developer → Set User Password. Setup finds the headset, adds a key and + asks for that password once (or for a tap in the headset under Developer → Pair new host).
    + + +
    + +
    +
    +
    + +
    +

    Remove this headset?

    +
    + +
    + +
    +
    +
    + + diff --git a/ui/server.py b/ui/server.py index a2c08f3..0433041 100755 --- a/ui/server.py +++ b/ui/server.py @@ -3,7 +3,8 @@ Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py). Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set -up by scripts/connect.sh or ui/frame_connect.py. +up by scripts/connect.sh or ui/frame_connect.py, or another headset picked on the +Devices tab: frame_link.py finds it at one of its addresses (frame_devices.py). Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app) Env: FRAME_ALIAS (default frame) @@ -14,6 +15,7 @@ Env: FRAME_ALIAS (default frame) """ import argparse import base64 +import contextlib import hashlib import http.client import json @@ -42,17 +44,27 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import frame_agent # noqa: E402 import frame_assistant # noqa: E402 import frame_android # noqa: E402 +from apk_sources import search as apk_search, SourceError # noqa: E402 import frame_apk_versions # noqa: E402 import frame_catalog # noqa: E402 +import frame_devices # noqa: E402 +import frame_steamgriddb +import frame_comfort # noqa: E402 +import frame_contact # noqa: E402 import frame_host # noqa: E402 +import frame_link # noqa: E402 import frame_macview # noqa: E402 import frame_media # noqa: E402 +import frame_panels # noqa: E402 import frame_report # noqa: E402 import frame_store # noqa: E402 import frame_telemetry # noqa: E402 import frame_titles # noqa: E402 import frame_touch # noqa: E402 import frame_webinstall # noqa: E402 +import frame_vr # noqa: E402 +import frame_utilities # noqa: E402 +import frame_compat_db # noqa: E402 frame_host.trust_bundled_cas() @@ -68,18 +80,92 @@ DEVICE = os.environ.get("FRAME_DEVICE") or "phone" # What the Frame's KDE Connect calls this device (keyboard and trackpad). INPUT_NAME = DEVICE if LOCAL else socket.gethostname().split(".")[0] FRAME = os.environ.get("FRAME_ALIAS", "frame") +FRAME_FROM_ENV = "FRAME_ALIAS" in os.environ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME): sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}") # Reuse one SSH connection for the frequent status/screenshot calls, where ssh # supports it (not on Windows: there every command connects on its own). -CONTROL = None if LOCAL else frame_host.control_path(private=os.environ.get("FRAME_PRIVATE_SSH") == "1") -MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])] +# A private server (the MCP adapter starts one per session) keeps its own SSH masters, and +# uses the headsets without editing them. +PRIVATE = os.environ.get("FRAME_PRIVATE_SSH") == "1" +CONTROL = None if LOCAL else frame_host.control_path(private=PRIVATE) +# The ControlPath itself is per headset: the connector puts it in HOST_OPTS. +MUX = ["ssh", "-o", "BatchMode=yes"] +MUX_BASE = list(MUX) # Commands use the master when it's up and connect directly when it isn't. -SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"] +SSH_TAIL = [*(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"] +SSH = [*MUX, *SSH_TAIL] +# The address the connector picked (-o HostName=... and friends), in every ssh command. +HOST_OPTS = [] # Android helpers share the multiplexed connection when it's up. frame_android.SSH_OPTS = SSH[1:] + +_route_lock = threading.Lock() + + +def route(alias, host_opts): + """Point every ssh, scp and rsync at `alias` with `host_opts` (frame_link calls this + when it picks a headset and an address). The lists change in place, so code holding + them follows; frame_titles reads frame_android.SSH_OPTS at call time.""" + global FRAME, HOST_OPTS + with _route_lock: + moved = alias != FRAME + if moved: + frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset + FRAME = frame_android.FRAME = alias + HOST_OPTS = list(host_opts) + MUX[:] = [*MUX_BASE, *HOST_OPTS] + SSH[:] = [*MUX, *SSH_TAIL] + frame_android.SSH_OPTS = SSH[1:] + # Long-lived ssh processes started on the old route (outside the lock: they take their own). + mv = globals().get("macview") + if mv: + mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too + agent = globals().get("_input") + if moved and agent: + agent.stop() # typing and pointing mustn't go on reaching the headset switched away from + + +LINK = None # the connector (frame_link.Link); None on the Frame itself + +# Installs and other changes in progress. Switching headsets waits for them: they +# read the ssh settings step by step, so a switch could send the rest (or a failed +# install's clean-up) to the other headset. +_work_lock = threading.Lock() +_work = [0] + + +@contextlib.contextmanager +def working(meant=None): + """Counts as running work. `meant`: the headset the page made this change for; if the + app has switched away from it, refuse (checked together with counting, so a switch + can't slip in between).""" + with _work_lock: + if LINK and meant and meant != LINK.active_device()["id"]: + raise Failure("Frame Control switched headsets; try again on this one", 409) + _work[0] += 1 + try: + yield + finally: + with _work_lock: + _work[0] -= 1 + + +def busy_thread(fn, *args): + """A thread that counts as work from before it starts until it ends.""" + with _work_lock: + _work[0] += 1 + + def run(): + try: + fn(*args) + finally: + with _work_lock: + _work[0] -= 1 + return threading.Thread(target=run, daemon=True) + APPID = re.compile(r"^\d{1,10}$") FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$") MAX_UPLOAD = 8 * 1024**3 @@ -158,7 +244,41 @@ _jobs_lock = threading.Lock() _jobs = {} # id -> {"label", "done", "error", "message", "result", "time"} -def start_job(label, work): +_backfill = {"running": False, "last": 0.0} +_backfill_lock = threading.Lock() + + +def backfill_art(apps=(), titles=()): + """Apply art Frame Control couldn't at install time (Steam wasn't running), once Steam is up. + + Only entries marked art_pending at install; it fills empty Steam slots and never replaces art or + names the user may have customised. Runs in the background, at most once every five minutes.""" + pkgs = [a["package"] for a in apps if a.get("art_pending")] + gids = [t["id"] for t in titles if t.get("art_pending")] + with _backfill_lock: + if not (pkgs or gids) or _backfill["running"] or time.time() - _backfill["last"] < 300: + return False + _backfill.update(running=True, last=time.time()) + + def run(): + try: + frame_android.shortcut_tool("list") # Steam isn't up: try again on a later listing + for refresh, key in [(frame_android.refresh_art, p) for p in pkgs] + \ + [(frame_titles.refresh_art, g) for g in gids]: + try: + refresh(key, fill_only=True) + except Exception as e: + print(f"artwork backfill for {key}: {e}", file=sys.stderr) + except Exception: + pass + finally: + with _backfill_lock: + _backfill["running"] = False + threading.Thread(target=run, daemon=True).start() + return True + + +def start_job(label, work, progress=False): """Run work() in the background. It returns a dict with a "message".""" now = time.time() with _jobs_lock: @@ -167,14 +287,20 @@ def start_job(label, work): job = secrets.token_hex(8) _jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now} + def report(stage, percent=None): + with _jobs_lock: + _jobs[job].update(stage=stage, percent=percent) + def run(): fields = {} try: - result = work() + result = work(report) if progress else work() fields = {"message": result.get("message") or f"{label}: done", "result": result} except (Failure, frame_android.FrameError) as e: fields = {"error": unreachable(str(e)) or str(e)} frame_telemetry.diagnostic(f"job {label.split()[0]}", e) + except SourceError as e: # already user-readable, and about a store, not the Frame + fields = {"error": str(e)} except Exception as e: fields = {"error": f"{type(e).__name__}: {e}"} frame_telemetry.diagnostic(f"job {label.split()[0]}", e) @@ -182,7 +308,7 @@ def start_job(label, work): with _jobs_lock: _jobs[job].update(fields, done=True, time=time.time()) - threading.Thread(target=run, daemon=True).start() + busy_thread(run).start() return {"message": f"{label}…", "job": job} @@ -195,42 +321,14 @@ def job_status(query): return snapshot -_master_lock = threading.Lock() -_master = None - - def ensure_master(): - """Start the shared SSH connection if it isn't up (one attempt at a time). - - No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket. - """ - global _master - if not CONTROL: - return - - def up(): - try: - return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL, - timeout=5).returncode == 0 - except subprocess.TimeoutExpired: - return False - - with _master_lock: - if up() or (_master and _master.poll() is None): - return - # Keepalives make a dead link (Frame asleep, off Wi-Fi) exit within ~10s, - # so the next request starts a fresh master. - _master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5", - "-o", "ServerAliveCountMax=2", "-N", FRAME], - stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, **frame_host.DETACHED) - for _ in range(60): - if up() or _master.poll() is not None: - return - time.sleep(0.05) + """Make sure the connection to the headset is up, or being tried (frame_link.Link.ensure).""" + if LINK: + LINK.ensure() def ssh(remote, *, stdin=None, timeout=30, text=True): + route_gen = LINK.gen if LINK else None # which headset this command is for try: ensure_master() # Never let ssh inherit our stdin: under the app it's the pipe held open for @@ -242,6 +340,8 @@ def ssh(remote, *, stdin=None, timeout=30, text=True): raise Failure(f"Timed out talking to {FRAME}") if r.returncode != 0: err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace") + if r.returncode == 255 and LINK and unreachable(err): + LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}") failure.stdout = r.stdout if text else r.stdout.decode(errors="replace") raise failure @@ -271,8 +371,45 @@ def status(_body): return s +def comfort(body): + try: + frame_comfort.validate(body) + except ValueError as e: + raise Failure(str(e), 400) + # Content-addressed, user-only helper bundle. Desktop and phone use the same + # on-headset state/lock; no listener, service registration or third-party app. + import hashlib + files = {name: (HERE / name).read_text() for name in + ("frame_comfort.py", "frame_status.py", "frame_steam.py")} + version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16] + script = """import json, os, pathlib, subprocess, sys +os.umask(0o077) +files = %r +root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r +root.mkdir(parents=True, exist_ok=True) +for name, source in files.items(): + path = root / name + if not path.exists(): + tmp = root / (name + '.' + str(os.getpid())) + tmp.write_text(source) + tmp.replace(path) +r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True) +print(r.stdout, end='') +""" % (files, version, json.dumps(body)) + out = json.loads(ssh("python3 -", stdin=script, timeout=65)) + if out.get("error") and "active" not in out: + raise Failure(out["error"], 409) + return out + + def headset_view(): """Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes.""" + # Counts as work: the copy and clean-up must reach the headset that took the capture. + with working(): + return _headset_view() + + +def _headset_view(): # `timeout`: VR_Init can block if SteamVR is restarting. out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30) # SteamVR prints its own notices (e.g. about vrwebhelper) on stdout too, so @@ -473,6 +610,27 @@ def steam(body): return res +def vr(body): + try: + action = frame_vr.validate(body) + except ValueError as e: + raise Failure(str(e), 400) + sources = {name: (HERE / (name + '.py')).read_text() for name in ('frame_status', 'frame_vr')} + script = "import sys, types, json, os\n" + for name, source in sources.items(): + script += f"m = types.ModuleType({name!r}); sys.modules[{name!r}] = m\nexec({source!r}, m.__dict__)\n" + # Only the optional HUD needs files: its terminal child survives this SSH call. + if action == 'hud-start': + script += "m.ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)\n" + for name, source in sources.items(): + script += f"p = m.ROOT / {name + '.py'!r}; t = p.with_suffix('.' + str(os.getpid()) + '.tmp')\nt.write_text({source!r}); os.replace(t, p)\n" + script += f"try:\n print(json.dumps(m.dispatch({body!r})))\nexcept Exception as e:\n print(json.dumps({{'error': str(e)}}))\n" + result = json.loads(ssh('python3 -', stdin=script, timeout=20)) + if result.get('error'): + raise Failure(result['error']) + return result + + def steam_search(query): q = parse_qs(query) cc = (q.get("cc") or [""])[0].upper() @@ -1110,6 +1268,115 @@ class TouchAgent(InputAgent): _touch = TouchAgent() +def remote_touch(body): + """{"events": [...]} points, clicks, scrolls and types into the focused panel.""" + events = body.get("events", []) + if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT: + raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400) + return _touch.send([touch_event(e) for e in events]) + + +# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ---- + +PANEL_WINDOW = re.compile(r"^\d{1,10}$") +PANEL_DISPLAY = re.compile(r"^:\d{1,2}$") +TOUCH_BUTTONS = ("left", "right", "middle") + + +def panels(): + """The headset's app panels (window, display, name, size) and which has focus.""" + return json.loads(ssh("python3 - panels", stdin=(HERE / "frame_touch.py").read_text(), timeout=20)) + + +def panel_target(q): + window, display = (q.get("window") or [""])[0], (q.get("display") or [""])[0] + if not PANEL_WINDOW.match(window) or not PANEL_DISPLAY.match(display): + raise Failure("window must be a window id and display an X display such as :1", 400) + return window, display + + +def panel_capture(query): + """One frame of a panel's own window, as PNG (its pixels, without the room around it).""" + window, display = panel_target(parse_qs(query)) + return ssh(f"DISPLAY={display} timeout 10 ffmpeg -hide_banner -loglevel error -nostdin -f x11grab " + f"-window_id {window} -i {display} -frames:v 1 -f image2pipe -c:v png -", timeout=20, text=False) + + +def touch_event(event): + """A frame_touch.py event with only the fields it knows, in range.""" + if not isinstance(event, dict): + raise Failure("each touch event must be an object", 400) + + def num(name, limit): + value = event.get(name) + if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value: + raise Failure(f"{name} must be a number", 400) + return max(-limit, min(limit, round(float(value), 4))) + out = {} + if "fx" in event or "fy" in event: + if "window" not in event: + raise Failure("a position needs the panel's window and display", 400) + out.update(fx=num("fx", 1), fy=num("fy", 1)) + # Any event can name the panel it's meant for; the Frame drops it if another has focus. + if "window" in event: + window, display = event.get("window"), event.get("display") + if isinstance(window, bool) or not isinstance(window, int) or window <= 0: + raise Failure("window must be the panel's window id", 400) + if not isinstance(display, str) or not PANEL_DISPLAY.match(display): + raise Failure("display must be an X display such as :1", 400) + out.update(window=window, display=display) + for name in ("dx", "dy"): + if name in event: + out[name] = num(name, INPUT_MOVE_LIMIT) + if "button" in event: + if event["button"] not in TOUCH_BUTTONS: + raise Failure(f"button must be one of {', '.join(TOUCH_BUTTONS)}", 400) + out["button"], out["down"] = event["button"], event.get("down") is not False + if "scroll" in event: + sc = event["scroll"] + if not isinstance(sc, list) or len(sc) != 2: + raise Failure("scroll must be [dx, dy]", 400) + out["scroll"] = [num_value(v, 5000) for v in sc] + if "key" in event: + key = event["key"] + if isinstance(key, bool) or not isinstance(key, int) or not 0 < key < 768: + raise Failure("key must be a Linux key code", 400) + out["key"], out["down"] = key, event.get("down") is not False + if "text" in event: + text = event["text"] + if not isinstance(text, str) or not 0 < len(text) <= INPUT_TEXT_LIMIT: + raise Failure(f"text must be 1 to {INPUT_TEXT_LIMIT} characters", 400) + out["text"] = text + if not set(out) - {"window", "display"}: + raise Failure("touch event has nothing to do", 400) + return out + + +def num_value(value, limit): + if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value: + raise Failure("scroll values must be numbers", 400) + return max(-limit, min(limit, round(float(value), 2))) + + +class TouchAgent(InputAgent): + """frame_touch.py on the Frame, fed events over one long-lived ssh. Nothing to install: + it uses gamescope's own input socket and the libei that's on the image.""" + + def __init__(self): + super().__init__(source=HERE / "frame_touch.py", packages=[]) + + def deliver(self, report, force=False): + return "" + + def command(self, folder=""): + code = base64.b64encode(self.source.read_bytes()).decode() + return "python3 -u -c " + shlex.quote( + f"import base64;exec(compile(base64.b64decode('{code}'),'frame_touch','exec'))") + + +_touch = TouchAgent() + + def remote_touch(body): """{"events": [...]} points, clicks, scrolls and types into the focused panel.""" events = body.get("events", []) @@ -1166,23 +1433,35 @@ def open_thing(body): if what in ("reboot", "poweroff", "suspend"): return power(what, body.get("password")) raise Failure("open that from the app", 400) + # The headset and address in use, as every other command gets them (one snapshot). + with _route_lock: + alias, opts = LINK.named_route() if LINK else (FRAME, list(HOST_OPTS)) + host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), None) + if what in ("terminal", "reboot", "poweroff", "suspend", "rdp", "sftp") and host and host.endswith(".invalid"): + raise Failure("No headset address to use: add one on the Devices tab", 400) try: if what == "terminal": - return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"} + return {"message": f"Opened an SSH session in {terminal(['ssh', *opts, alias])}"} if what in ("reboot", "poweroff", "suspend"): # logind answers "challenge" over SSH, so sudo (and the password) is needed. - where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what]) + where = terminal(["ssh", "-t", *opts, alias, "sudo", "systemctl", what]) return {"message": f"Confirm with the Developer Mode password in {where} to {what}"} if what == "steamlink": return {"message": frame_host.open_steam_link()} if what == "rdp": - return {"message": frame_host.open_rdp(FRAME)} + return {"message": frame_host.open_rdp(alias, host)} if what == "sftp": - return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"} + return {"message": f"Opened an SFTP session in {terminal(['sftp', *opts, alias])}"} if what == "shots": SHOTS_DIR.mkdir(parents=True, exist_ok=True) frame_host.open_path(SHOTS_DIR) return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"} + if what == "shot": + saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1] + if not saved.exists(): + raise Failure("That screenshot isn't saved on this computer yet", 404) + frame_host.reveal_path(saved) + return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"} except frame_host.HostError as e: raise Failure(str(e), 500) raise Failure("unknown target", 400) @@ -1213,6 +1492,14 @@ def android(body): return {"message": f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel.", "app": m} return start_job(f"Install {pkg}", work) + if action == "refresh-art": + if not pkg and not body.get("all"): + raise Failure('choose a package or all apps', 400) + if not body.get('all'): + return start_job('Refresh Steam artwork', lambda: {'apps': [frame_android.refresh_art(pkg)]}) + # Everything Frame Control sideloaded: Android apps and devkit titles. + return start_job('Refresh Steam artwork', lambda: { + 'apps': frame_android.refresh_art(), 'titles': frame_titles.refresh_art()}) if action in ("launch", "stop"): m = getattr(frame_android, action)(pkg) return {"message": f"{'Launching' if action == 'launch' else 'Stopped'} {m['label']}"} @@ -1304,7 +1591,8 @@ def stage_title(path, temp_dir=None, name=None): raise token = secrets.token_hex(12) with _titles_lock: - _staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time()} + _staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time(), + "device": LINK.active_device()["id"] if LINK else None} return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}", "token": token, "plan": frame_titles.public(plan)} @@ -1349,20 +1637,24 @@ def titles(body): if action == "discard": _drop_staged(entry) return {"message": "Discarded"} + if LINK and entry.get("device") != LINK.active_device()["id"]: + _drop_staged(entry) # it was checked against the other headset's titles + raise Failure("Frame Control switched headsets since this was read; drop the file again", 409) with _titles_lock: _title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None, "message": None, "title": None, "time": time.time()} ensure_master() opt = lambda k: str(body.get(k) or "") or None # noqa: E731 - threading.Thread(target=_run_title_install, daemon=True, - args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start() + busy_thread(_run_title_install, token, entry, opt("name"), opt("exe"), opt("runtime")).start() return {"message": f"Installing {entry['plan']['source']}", "job": token} - if action not in ("launch", "remove"): + if action not in ("launch", "remove", "refresh-art"): raise Failure("unknown action", 400) gid = str(body.get("id", "")) if not frame_titles.ID_RE.match(gid): raise Failure("bad title id", 400) ensure_master() + if action == "refresh-art": + return start_job(f"Steam artwork for {gid}", lambda: {"titles": [frame_titles.refresh_art(gid)]}) try: m = getattr(frame_titles, action)(gid) except frame_android.FrameError as e: @@ -1466,7 +1758,7 @@ class AdbTunnel: fwd = [a for p, lp in self.local.items() for a in ("-L", f"127.0.0.1:{lp}:127.0.0.1:{p}")] # Its own connection (ControlPath=none), so killing it drops the forwards. self.proc = _proc = subprocess.Popen( - ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none", + ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none", *HOST_OPTS, "-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) _live_tunnels.add(_proc) @@ -1741,7 +2033,7 @@ def webinstall_start(body): _web_jobs.clear() _web_jobs[pid] = job # Started under the lock, so shutdown never sees a thread it can't join. - worker = threading.Thread(target=_webinstall_run, args=(plan, job), daemon=True) + worker = busy_thread(_webinstall_run, plan, job) _web_workers.add(worker) worker.start() return {"job": pid} @@ -1886,6 +2178,32 @@ def _sweep_one(prefix, d): pass +# ---- Panel switcher (same helper on the companion and in the headset) ---- + +def panels_action(body): + action = body.get("action", "list") + script = (HERE / "frame_panels.py").read_text() + if action == "open": + # Installed in the user account so the page can outlive this SSH call. + remote = ('umask 077; mkdir -p ~/.local/share/frame-control/panels && ' + 'tmp=$(mktemp ~/.local/share/frame-control/panels/install.XXXXXX) && ' + 'cat > "$tmp" && mv "$tmp" ~/.local/share/frame-control/panels/switcher.py && ' + 'python3 ~/.local/share/frame-control/panels/switcher.py --open') + elif action == "list": + remote = "python3 -" + elif action == "focus": + key = body.get("key") + if not isinstance(key, str) or not frame_panels.KEY.fullmatch(key): + raise Failure("Choose an open panel.", 400) + remote = "python3 - --focus " + shlex.quote(key) + else: + raise Failure("Unknown panel action", 400) + result = json.loads(ssh(remote, stdin=script, timeout=45)) + if "error" in result: + raise Failure(result["error"], 502) + return result + + # ---- Our Frame-side media player ----------------------------------------- _MEDIA_LOCK = threading.Lock() @@ -2021,14 +2339,138 @@ def agent_approval(body): return frame_agent.approvals.decide(body.get("confirmation"), body.get("accept")) -POST = {"/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval, - "/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, - "/api/input": remote_input, "/api/touch": remote_touch, +def source_text(body, key, optional=False): + value = body.get(key) + if optional and value in (None, ''): + return None + if not isinstance(value, str) or not value.strip() or len(value) > 2000: + raise Failure('Provide a valid ' + key, 400) + return value.strip() + + +def source_search(query): + args = parse_qs(query) + q = args.get('q', [''])[0] + vr = args.get('vr', [''])[0] + installable = args.get('installable', [''])[0] + if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'): + raise Failure('Invalid search filters', 400) + try: + return apk_search.search(q, vr=None if not vr else vr in ('true', '1'), + source=args.get('source', [None])[0], installable=installable in ('true', '1')) + except SourceError as e: + raise Failure(str(e), 400) + + +def source_install(body): + source, entry = source_text(body, 'source'), source_text(body, 'id') + version = body.get('version_code') + if version is not None and (type(version) is not int or version < 0): + raise Failure('version_code must be a non-negative integer', 400) + try: + _, selected = apk_search.resolve(source) + if not selected['enabled']: + raise SourceError('This source is disabled') + except SourceError as e: + raise Failure(str(e), 400) + return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True) + + +def source_manage(body): + action = body.get('action') + try: + if action == 'enable': + if type(body.get('enabled')) is not bool: + raise Failure('enabled must be true or false', 400) + return apk_search.set_enabled(source_text(body, 'source'), body['enabled']) + if action == 'add': + url = source_text(body, 'url') + fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True) + parts = urlparse(url) + if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username: + raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400) + apk_search.repo_module() # fail now if this build can't manage repositories + + def add(): # downloads and verifies the whole index: a job, not a request + source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name) + message = 'Added ' + source['name'] + if source.get('trust_on_first_use'): + message += '. Trusted on first use: ' + source['fingerprint'].upper() + return {'message': message, 'source': {k: source.get(k) for k in + ('id', 'name', 'fingerprint', 'trust_on_first_use')}} + return start_job('Add repository', add) + if action == 'game-data': + package = source_text(body, 'package') + return start_job('Add game data', lambda: apk_search.add_game_data(package)) + if action == 'remove': + apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source')) + return {'message': 'Repository removed'} + raise Failure('Unknown source action', 400) + except SourceError as e: + raise Failure(str(e), 400) + + +POST = { + "/api/vr": vr, + "/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, + "/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat, + "/api/input": remote_input, "/api/touch": remote_touch, + "/api/settings/artwork": frame_steamgriddb.save_settings, + "/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots, "/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start, "/api/webinstall/cancel": webinstall_cancel, "/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event, - "/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action} + "/api/contact": frame_contact.save, "/api/contact/prompt": frame_contact.prompt, + "/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action, + "/api/devices": lambda body: devices_post(body)} + + + + +# ---- headsets and the connection (frame_devices.py, frame_link.py) ---------- + +def open_setup(alias, host=None): + """Set Up Connection for `alias` in a terminal window, as the app's menu does.""" + if frame_host.MAC: + argv = ["env", f"FRAME_ALIAS={alias}", "zsh", str(HERE.parent / "scripts" / "connect.sh")] + else: + argv = [sys.executable, str(HERE / "frame_connect.py"), "--alias", alias] + return terminal(argv + ([host] if host else [])) + + +def devices_post(body): + if not LINK: + raise Failure("Headsets are managed from the computer app", 400) + if PRIVATE: + raise Failure("Headsets are managed in the Frame Control app", 403) + try: + # Under the work lock: nothing can start on the old headset while it switches. + with _work_lock: + return frame_link.devices_action(LINK, body, open_setup, lambda: _work[0]) + except frame_devices.DeviceError as e: + raise Failure(str(e), 400) + + +def devices_get(path, query): + if not LINK: + raise Failure("Headsets are managed from the computer app", 400) + q = parse_qs(query) + device = (q.get("id") or [None])[0] + try: + if path == "/api/devices": + return dict(frame_link.devices_view(LINK), nextAlias=frame_link.next_alias(LINK)) + if path == "/api/devices/tailscale": + return frame_link.tailscale_find(LINK, device) + return frame_link.mdns_find(LINK, device) + except frame_devices.DeviceError as e: + raise Failure(str(e), 400) + + +def connection_state(): + if not LINK: # on the Frame itself there's nothing to find + return {"local": True, "phase": "connected", "version": 0} + return LINK.snapshot() # ---- HTTP ------------------------------------------------------------------ @@ -2135,6 +2577,10 @@ class Handler(BaseHTTPRequestHandler): try: if path in ("/", "/index.html"): self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8") + elif path == "/api/settings/artwork": + self.send_json(frame_steamgriddb.settings()) + elif path == "/artwork-settings.js": + self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8') elif path == "/assistant": page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c")) self.send_bytes(page.encode(), "text/html; charset=utf-8") @@ -2145,14 +2591,41 @@ class Handler(BaseHTTPRequestHandler): self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else {"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER, "computer": "Mac" if frame_host.MAC else "PC"}) + elif path == "/api/connection": + self.send_json(connection_state()) + elif path == "/api/connection/events": + self.connection_events() + elif path in ("/api/devices", "/api/devices/tailscale", "/api/devices/mdns"): + self.send_json(devices_get(path, url.query)) + elif path.startswith("/source-image/"): + from apk_sources import _images + try: + self.send_bytes(*_images.image(path.rsplit("/", 1)[-1])) + except Exception: + self.send_json({"error": "Artwork unavailable"}, 404) + elif path == "/api/sources/details": + args = parse_qs(url.query) + try: + self.send_json(apk_search.details(source_text({k: v[0] for k, v in args.items()}, "source"), + source_text({k: v[0] for k, v in args.items()}, "id"))) + except SourceError as e: + raise Failure(str(e), 400) + elif path == "/api/sources": + self.send_json({"sources": apk_search.sources()}) + elif path == "/api/search": + self.send_json(source_search(url.query)) elif path == "/api/apk-versions": self.send_json(apk_versions(url.query)) elif path == "/api/android": ensure_master() - self.send_json({"apps": frame_android.list_apps()}) + apps = frame_android.list_apps() + backfill_art(apps=apps) + self.send_json({"apps": apps}) elif path == "/api/titles": ensure_master() - self.send_json({"titles": frame_titles.list_titles()}) + titles_list = frame_titles.list_titles() + backfill_art(titles=titles_list) + self.send_json({"titles": titles_list}) elif path == "/api/titles/job": self.send_json(title_job(url.query)) elif path == "/api/licenses": @@ -2172,10 +2645,14 @@ class Handler(BaseHTTPRequestHandler): "shared": frame_catalog.compat_db.shared()}) elif path == "/api/android/catalog": self.send_json({"apps": frame_catalog.catalog()}) + elif path == "/api/vr/utilities": + self.send_json(frame_utilities.catalogue(steam_frame("utilities")["utilities"], frame_compat_db.load())) elif path == "/api/macview": self.send_json(macview_state(parse_qs(url.query))) elif path == "/api/telemetry": self.send_json(frame_telemetry.state()) + elif path == "/api/contact": + self.send_json(frame_contact.state()) elif path == "/api/computer/state": self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20))) elif path == "/api/status": @@ -2215,10 +2692,12 @@ class Handler(BaseHTTPRequestHandler): if not self.local_request(): return path = urlparse(self.path).path + meant = self.headers.get("X-Frame-Device") body = None try: if path == "/api/upload": - self.send_json(self.upload()) + with working(meant): + self.send_json(self.upload()) return handler = POST.get(path) if not handler: @@ -2230,7 +2709,9 @@ class Handler(BaseHTTPRequestHandler): body = json.loads(self.rfile.read(length) or b"{}") if not isinstance(body, dict): raise Failure("request body must be a JSON object", 400) - self.send_json(handler(body)) + with (contextlib.nullcontext() if path == "/api/devices" else working(meant)): + result = handler(body) + self.send_json(result) except Failure as e: if e.status >= 500: frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e) @@ -2244,6 +2725,30 @@ class Handler(BaseHTTPRequestHandler): frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e) self.send_json({"error": f"{type(e).__name__}: {e}"}, 500) + def connection_events(self): + """Server-sent events: the connection state each time it changes, until the page goes. + (The page reads it with fetch, which can send the X-Frame-UI header; EventSource can't.)""" + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Frame-Options", "DENY") + self.end_headers() + self.close_connection = True + version = -1 + try: + while True: + snap = connection_state() if version < 0 else (LINK.wait(version, 15) if LINK else None) + if snap is None: + if not LINK and version >= 0: + time.sleep(15) + self.wfile.write(b": still here\n\n") # keeps proxies and the page's watchdog happy + else: + version = max(snap["version"], 0) + self.wfile.write(b"data: " + json.dumps(snap).encode() + b"\n\n") + self.wfile.flush() + except OSError: + pass # the page went away + def stream_video(self, query): """Raw H.264 of the headset view until the page disconnects (see stream_command).""" global _stream_proc @@ -2383,6 +2888,24 @@ class LoopbackServer(ThreadingHTTPServer): self.server_name, self.server_port = "127.0.0.1", self.server_address[1] +_ONE_SERVER = None + + +def one_server(): + """Only one Frame Control server per user: two would each connect, reconnect and + edit the headsets on their own, and could move each other's installs to another + headset. Held until this process exits. (FRAME_CONTROL_DATA_DIR gives a second, + separate one, as the tests do. A private server, the MCP adapter's, runs alongside: + it can't add, remove or switch headsets.)""" + lock = frame_devices.file_lock(frame_host.data_dir("server.lock"), timeout=float(os.environ.get("FRAME_CONTROL_SERVER_WAIT") or 20)) # while the app restarts it + try: + lock.__enter__() + except OSError: + sys.exit("Frame Control is already running on this computer (the app, or a server started " + "from a terminal). Quit it, then try again.") + return lock + + def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810))) @@ -2392,17 +2915,31 @@ def main(): args = ap.parse_args() httpd = LoopbackServer(("127.0.0.1", args.port), Handler) sweep_tmp() + threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search frame_telemetry.start() + frame_contact.start() + global LINK, _ONE_SERVER + if not LOCAL: + if not PRIVATE: # a private server only uses the headsets (see one_server) + _ONE_SERVER = one_server() + LINK = frame_link.Link(frame_devices.Registry(), env_alias=FRAME if FRAME_FROM_ENV else None, + mux_base=MUX_BASE, control=CONTROL, apply=route, explain=unreachable) + LINK.work_lock, LINK.work = _work_lock, lambda: _work[0] + LINK.start() if not frame_host.WINDOWS: signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt)) if args.exit_on_eof: def watch_stdin(): - sys.stdin.buffer.read() + # os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock, + # and if a signal stops the server first, Python aborts (SIGABRT) at exit + # when it can't take that lock back from this thread. + while os.read(0, 4096): + pass threading.Thread(target=httpd.shutdown, daemon=True).start() threading.Thread(target=watch_stdin, daemon=True).start() - # The real port, which --port 0 leaves to the system (the iPhone app reads it from here). - print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True) try: + # The real port, which --port 0 leaves to the system (the iPhone app reads it from here). + print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True) httpd.serve_forever() except KeyboardInterrupt: pass @@ -2414,10 +2951,8 @@ def main(): webinstall_shutdown() macview.shutdown() # close the headset's viewers before the agent goes # The master was started with -N, so it stays up until told to exit. - if CONTROL: - subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL) - if _master and _master.poll() is None: - _master.terminate() + if LINK: + LINK.stop() for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way if proc.poll() is None: proc.terminate()