Compare commits

...
Author SHA1 Message Date
SAPHID 840f53f032 Reject colliding Decky plugin ZIP paths 2026-10-25 10:16:03 +00:00
Alex SouthwellandSAPHID faf7bc6224 Prepare Frame Control 0.4.33 (#235)
* Prepare Frame Control 0.4.33

* Record Framey sample and Decky recovery limits

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:51:10 +00:00
Alex SouthwellandSAPHID 289a971c90 Add Decky settings sample for Framey plugin authors (#233)
* Provide Decky automatic JSX runtime in Framey panel

* Add Decky settings sample for Framey authors

* Use official Decky settings path in sample backend

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:39:01 +00:00
Alex SouthwellandSAPHID f2f6085887 Allow Decky plugin removal when loader is stopped (#234)
* Allow safe Decky plugin removal when loader is stopped

* Stop failed Decky unit before recovery removal

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:34:39 +00:00
Alex SouthwellandSAPHID d1541099b8 Clear stale Framey job IDs after app restart (#232)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:30:52 +00:00
Alex SouthwellandSAPHID 0440959834 Provide Decky automatic JSX runtime in Framey panel (#231)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:26:47 +00:00
Alex SouthwellandSAPHID 482d031a18 Serialize managed Decky changes across desktop and headset (#230)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:24:48 +00:00
Alex SouthwellandSAPHID 20a5ced63d Test Framey settings persistence across plugin lifecycle (#229)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:20:19 +00:00
Alex SouthwellandSAPHID 02fe664583 Prepare Frame Control 0.4.32 (#228)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 08:06:51 +00:00
Alex SouthwellandSAPHID e2ffcd34f3 Support Decky panel routes and toasts in Framey (#227)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

* Show Decky plugin job progress in VR

* Show plugin quick actions and settings in Framey panel

* Add local Decky routes and toasts to Framey

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:56:38 +00:00
Alex SouthwellandSAPHID 2002b9d70e Show Decky plugin quick actions and settings in VR (#226)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

* Show Decky plugin job progress in VR

* Show plugin quick actions and settings in Framey panel

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:46:32 +00:00
Alex SouthwellandSAPHID 9b86264813 Show Decky plugin install progress and errors in VR (#225)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

* Show Decky plugin job progress in VR

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:38:25 +00:00
Alex SouthwellandSAPHID ba97a67580 Browse and search Decky store in VR (#224)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:28:23 +00:00
Alex SouthwellandSAPHID c7a17478a3 Host Decky plugin panels in Frame Control VR (#223)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:16:05 +00:00
Alex SouthwellandSAPHID a625800d62 Bump Frame Control to 0.4.31 (#222)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:04:38 +00:00
Alex SouthwellandSAPHID f0f39ac823 Prepare Frame Control 0.4.31 and record round-two validation (#221)
* Prepare Frame Control 0.4.31 with round-two validation

* State the x86 Linux runtime limit in smoke evidence

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:54:03 +00:00
Alex SouthwellandSAPHID 1252ecc7af Add read-only large status screen to headset panel (#220)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:46:01 +00:00
Alex SouthwellandSAPHID 7b53af1c18 Fix OBS lifecycle cleanup and validate real headset capture (#219)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:42:11 +00:00
Alex SouthwellandSAPHID 1255ef2bbf Fix Steam clip export collisions across headsets, accounts and sessions (#217)
* Fix Steam clip export collisions across headset and session identities

* Group clip export changelog with screenshot copying

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:37:51 +00:00
Alex SouthwellandSAPHID fdd60d3ccd Fix auto-sync retry recovery and headset-bound listings (#218)
* Fix auto-sync retry recovery and headset-bound listings

* Read auto-sync Settings fixture as UTF-8 on Windows

* Keep auto-sync changelog separate from parallel fixes

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:35:47 +00:00
Alex SouthwellandSAPHID d5fa8feb31 Fix orphan smoke cleanup and portable smoke wrapper (#216)
* Fix orphan smoke cleanup and portable smoke wrapper

* Resolve smoke wrapper fixture working directory on macOS

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:31:45 +00:00
Alex SouthwellandSAPHID 4e07409627 Document Steam Frame plugin research and verification limits (#210)
* Record Steam Frame plugin research and fresh read-only evidence

* Update Decky research for official store audit

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:29:47 +00:00
Alex SouthwellandSAPHID da34d94009 Skip backup paths with symlinked ancestors (#215)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:19:38 +00:00
Alex SouthwellandSAPHID 234b0a1814 Add managed Decky store and Frame plugin compatibility audit (#208)
* Add managed Decky store plugins and Frame compatibility audit

* Reject malformed flags and linked plugin folders

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:15:42 +00:00
Alex SouthwellandSAPHID 7a8e919546 Validate extension bridge API versions before loading panels (#209)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:13:55 +00:00
Alex SouthwellandSAPHID d79efb4730 Bump Frame Control to 0.4.30 (#207)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:39:30 +00:00
Alex SouthwellandSAPHID 95643b7ea0 Support restricted split APKs in Android backup import (#206)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:29:21 +00:00
Alex SouthwellandSAPHID 9dab523af9 docs: distinguish current Frame OS checks from September notes (#205)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:13:12 +00:00
Alex SouthwellandSAPHID a29fac2594 Bump Frame Control to 0.4.29 (#204)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 04:21:10 +00:00
Alex SouthwellandSAPHID 6d5cb20847 Document ProtonDB Badges test on Steam Frame (#202)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 04:11:01 +00:00
Alex Southwell 987c36c5f6 Merge pull request #201 from saphid/fleet/se9k61wn4f-headset-actions
Add headset quick actions and extension panels
2026-10-25 04:10:29 +00:00
Alex Southwell 649185237f Merge pull request #198 from saphid/fleet/se9k61wn4f-headset-ui
Add removable Frame Control panel for SteamVR
2026-10-25 04:09:48 +00:00
Alex SouthwellandSAPHID 5b43170fda Bump Frame Control to 0.4.28 (#203)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:59:02 +00:00
SAPHID 0cb085dc50 Check POSIX screenshot mode only on POSIX 2026-10-25 03:56:24 +00:00
SAPHID 61db62edcf Make headset screenshot test portable 2026-10-25 03:52:23 +00:00
SAPHID b0a65129f1 Add headset quick actions and extension panels 2026-10-25 03:47:30 +00:00
Alex SouthwellandSAPHID 958743c79d Docs: guide SteamOS OBS host setup with test limits (#200)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:47:21 +00:00
Alex SouthwellandSAPHID 05308932e8 Docs: distinguish PC SteamVR stream failures from SSH (#199)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:46:57 +00:00
Alex SouthwellandSAPHID 4e4853e82b Docs: explain streaming lag and Japanese setup (#197)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:41:41 +00:00
SAPHID d44b6121ae Use POSIX member paths in headset bundle on Windows 2026-10-25 03:39:53 +00:00
SAPHID af6f97ca5a Add removable Frame Control headset panel 2026-10-25 03:33:58 +00:00
Alex SouthwellandSAPHID 3438c248d9 Bump Frame Control to 0.4.27 (#196)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:24:52 +00:00
Alex SouthwellandSAPHID 509ddbdc95 Add experimental Decky Loader manager for Steam Frame (#195)
* Add sandboxed local Frame Control extensions

* Add experimental Decky Loader manager for Steam Frame

* ci: verify Decky PR against main

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:14:42 +00:00
Alex SouthwellandSAPHID f2d293bd1a Add local Frame Control extensions (#194)
* Add sandboxed local Frame Control extensions

* docs: clarify local extension network boundary

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 02:48:37 +00:00
Alex Southwell a5a554d0c2 Document real Frame screenshot Save verification (#193)
Document real Frame screenshot Save verification
2026-10-25 02:36:30 +00:00
SAPHID af2dca8365 Document real Frame screenshot copy check 2026-10-25 02:29:56 +00:00
Alex Southwell 6a8e0093de Keep equal Steam screenshot filenames from overwriting each other (#192)
Keep equal Steam screenshot filenames from overwriting each other
2026-10-25 02:28:29 +00:00
SAPHID 53107fd91b Keep saved Steam screenshots separate by headset and game 2026-10-25 02:17:29 +00:00
117 changed files with 9041 additions and 284 deletions

No files matched your search

+6 -1
View File
@@ -46,7 +46,7 @@ jobs:
python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json"
python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1'
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
run: node --check app/main.js && node --check app/obs-source.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
- name: App tests
run: node --test app/test/*.test.js
- name: Website
@@ -71,6 +71,11 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: OBS lifecycle tests
run: node app/test/obs-source.test.js
- name: Server tests
run: python -m unittest discover -s tests -v
+32
View File
@@ -2,6 +2,33 @@
## Unreleased
- Fix smoke cleanup skipping orphan metadata and Steam shortcuts after a test
title directory was already removed. Sweep and verify all six exact reserved
smoke IDs; preserve similarly named titles. Make `scripts/frame-smoke.sh`
work with POSIX sh and Python 3 without zsh, including from another working
directory. Local regressions cover both fixes; no new headset run.
- Skip backup saves and settings reached through a symbolic link anywhere below
the home folder, and recheck paths before packing each file. Local synthetic
tests cover redirected Proton prefixes and links changed after discovery;
concurrent filesystem changes can still race the checks.
[Backup layout and limits](docs/backup-and-recovery.md).
- Import a base APK with supported native-library or asset splits from a local
Android backup or XAPK/APKS/APKM bundle. Check package and version before
install; refuse resource, code and feature splits and device-variant sets.
This route has local fixture coverage, not a real split-game headset test.
- Save Steam screenshots under a separate folder for each headset, Steam
account and game. Equal screenshot filenames no longer overwrite one another;
existing files in the old flat folder are left in place.
[Copying guide](docs/recording.md).
- Keep Steam clip exports separate for each headset, Steam account and nested
recording session. Manual export and automatic copying use the same folders;
existing flat exports stay untouched and do not mark unrelated clips saved.
[Recording guide](docs/recording.md#steam-clips).
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
Highlight exact PnP instances and known state changes after a dropout;
incomplete inventories stay unknown. A Forget button clears the comparison.
@@ -203,3 +230,8 @@
shared Wine services are left alone. Verified with local Linux process
regressions and a 13/13 real Frame smoke run that stopped five test-owned
Proton process PIDs before removing the Windows test title.
- Keep failed automatic screenshot and clip copies visible after three attempts.
**Resume** and **Copy everything now** allow another try without restarting;
headset switches cannot use another headset's list or saved-file history.
[Copying guide](docs/recording.md).
+3
View File
@@ -58,6 +58,9 @@ node --test site/test/*.test.mjs # website feedback function
Say what you tested, and whether you tried it on a real Steam Frame.
To add a local feature without changing the core app, see the
[extension guide](docs/extensions.md) and its working example.
## Blocking
If you ignore this document twice, or spam the tracker with agent-generated
+10 -1
View File
@@ -148,7 +148,7 @@ Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
The [source](https://github.com/saphid/frame-control) and [release build workflow](.github/workflows/release.yml) are public. The Windows installer is **not code-signed**, so SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.
日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
@@ -245,6 +245,14 @@ network. Guest Wi-Fi/client isolation can prevent discovery and connections.
check Developer Mode, the address and network isolation, then run setup again.
For connections away from home, see [Tailscale](docs/tailscale.md).
**Does Frame Control lower streaming lag?** No. Frame Control manages the Frame
over SSH. SteamVR or Steam Link carries the game stream. Frame Control does not
carry that stream, lower ping, or need the Wireless Adapter. It can connect over
Wi-Fi, the Wireless Adapter, or USB when your computer has an SSH route to the
Frame. The wired doctor checks only that SSH route. A data-capable USB-A-to-C
cable has not been tested for the Frame's USB network. See the
[connection FAQ and Japanese quick start](docs/connection-faq.md).
**What it changes:** only what you click. Installs go to your user account on
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
games in `~/devkit-game`), and nothing
@@ -290,6 +298,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled 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) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame plugin author guide](docs/frame-plugin-author.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
+24 -53
View File
@@ -311,57 +311,28 @@ ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// pipe lets it stop SSH on every platform, including Windows where SIGTERM
// terminates Python without running its cleanup block.
const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py");
let obsChild = null;
let obsState = { state: "idle", alias: "", error: "" };
function publicObs() { return { ...obsState, supported: true }; }
function setObs(state, alias = "", error = "") {
obsState = { state, alias, error };
if (win && !win.isDestroyed()) win.webContents.send("obs:state", publicObs());
return publicObs();
}
function stopObs() {
if (!obsChild) return publicObs();
setObs("stopping", obsState.alias);
if (obsChild.stdin && !obsChild.stdin.writableEnded) obsChild.stdin.end();
return publicObs();
}
ipcMain.handle("obs:status", (e) => fromUi(e) ? publicObs() : null);
ipcMain.handle("obs:start", async (e) => {
if (!fromUi(e)) return null;
if (obsChild) return publicObs();
const alias = activeAlias();
if (!ALIAS_RE.test(alias)) throw new Error("Select a valid headset first.");
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
const env = { ...process.env, PATH: await loginPath() };
const executable = python || await findPython(env);
if (!executable) throw new Error(PYTHON_HELP);
// A concurrent click or a headset switch may have happened while finding Python.
if (obsChild) return publicObs();
if (alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
const child = spawn(executable, [...PY_FLAGS, OBS_SCRIPT, "--frame", alias, "--control-stdin"],
{ env, stdio: ["pipe", "ignore", "pipe"], windowsHide: true });
obsChild = child;
setObs("starting", alias);
let output = "";
child.stderr.on("data", (chunk) => {
output = (output + chunk.toString("utf8")).slice(-2048);
if (obsChild === child && output.includes("Frame Control OBS stream is ready")) setObs("running", alias);
});
child.once("error", (err) => {
if (obsChild !== child) return;
obsChild = null;
setObs("failed", alias, `Could not start OBS source: ${err.message}`);
});
child.once("exit", (code, signal) => {
if (obsChild !== child) return;
const stopping = obsState.state === "stopping" || quitting;
obsChild = null;
setObs(stopping ? "idle" : "failed", alias,
stopping ? "" : `OBS source stopped (${signal || code}). ${output.trim().split("\n").pop() || "Check SteamVR and SSH."}`);
});
return publicObs();
const { createObsSource } = require("./obs-source");
const obsSource = createObsSource({
spawn,
// Keep the button hidden until native Windows/Frame streaming is verified.
supported: !IS_WIN,
prepare: async alias => {
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
const env = { ...process.env, PATH: await loginPath() };
const executable = python || await findPython(env);
if (!executable) throw new Error(PYTHON_HELP);
if (quitting || alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
return { executable, args: [...PY_FLAGS, OBS_SCRIPT], env };
},
onState: state => {
if (win && !win.isDestroyed()) win.webContents.send("obs:state", state);
},
});
ipcMain.handle("obs:stop", (e) => fromUi(e) ? stopObs() : null);
const publicObs = () => obsSource.status();
const stopObs = () => obsSource.stop();
ipcMain.handle("obs:status", e => fromUi(e) ? publicObs() : null);
ipcMain.handle("obs:start", (e, port) => fromUi(e) ? obsSource.start(activeAlias(), port) : null);
ipcMain.handle("obs:stop", e => fromUi(e) ? stopObs() : null);
// 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.
@@ -373,7 +344,7 @@ ipcMain.on("devices:changed", (e, list) => {
.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;
if (obsChild && obsState.alias !== activeAlias()) stopObs();
if (obsSource.status().alias && obsSource.status().alias !== activeAlias()) stopObs();
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
@@ -765,11 +736,11 @@ if (!app.requestSingleInstanceLock()) {
app.on("before-quit", (event) => {
quitting = true;
stopServer();
if (!obsChild || obsQuitReady) return;
if (!obsSource.child || obsQuitReady) { stopObs(); return; }
event.preventDefault();
if (obsQuitWaiting) return;
obsQuitWaiting = true;
const child = obsChild;
const child = obsSource.child;
let timer;
child.once("close", () => {
clearTimeout(timer);
+59
View File
@@ -0,0 +1,59 @@
// Own one Python relay. Async preparation can be cancelled before a process exists.
const ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
function createObsSource({ prepare, spawn, onState = () => {}, supported = true }) {
let child = null, attempt = 0;
let state = { state: 'idle', alias: '', port: 15099, error: '', supported };
const status = () => ({ ...state });
function set(next, alias = state.alias, port = state.port, error = '') {
state = { state: next, alias, port, error, supported };
onState(status()); return status();
}
function stop() {
++attempt;
if (!child) return set('idle');
set('stopping');
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
return status();
}
async function start(alias, port = 15099) {
if (!supported) return status();
if (!ALIAS.test(alias || '')) throw new Error('Select a valid headset first.');
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('OBS port must be a whole number from 1024 to 65535.');
if (child || state.state === 'starting') return status();
const id = ++attempt;
set('starting', alias, port);
try {
const { executable, args = [], env } = await prepare(alias);
if (id !== attempt) return status();
const proc = spawn(executable, [...args, '--frame', alias, '--port', String(port), '--control-stdin'],
{ env, stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
child = proc;
// The helper may exit between a click and closing its control pipe.
proc.stdin.on('error', () => {});
let output = '';
proc.stderr.on('data', chunk => {
output = (output + chunk.toString('utf8')).slice(-2048);
if (child === proc && state.state === 'starting' && output.includes('Frame Control OBS stream is ready'))
set('running', alias, port);
});
proc.once('error', error => {
if (child !== proc) return;
child = null; set('failed', alias, port, `Could not start OBS source: ${error.message}`);
});
// close follows stderr EOF, so the final failure message is available.
proc.once('close', (code, signal) => {
if (child !== proc) return;
const stopping = state.state === 'stopping';
child = null;
set(stopping ? 'idle' : 'failed', alias, port,
stopping ? '' : `OBS source stopped (${signal || code}). ${output.trim().split('\n').pop() || 'Check SteamVR and SSH.'}`);
});
return status();
} catch (error) {
if (id !== attempt) return status();
return set('failed', alias, port, error.message);
}
}
return { start, stop, status, get child() { return child; } };
}
module.exports = { createObsSource };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.26",
"version": "0.4.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.26",
"version": "0.4.33",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.26",
"version": "0.4.33",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -36,6 +36,7 @@
},
"files": [
"main.js",
"obs-source.js",
"preload.js",
"install-link.js",
"updater.js",
+1 -1
View File
@@ -23,7 +23,7 @@ contextBridge.exposeInMainWorld("frameApp", {
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
obs: {
status: () => ipcRenderer.invoke("obs:status"),
start: () => ipcRenderer.invoke("obs:start"),
start: (port) => ipcRenderer.invoke("obs:start", port),
stop: () => ipcRenderer.invoke("obs:stop"),
onState: (cb) => {
ipcRenderer.removeAllListeners("obs:state");
+86
View File
@@ -0,0 +1,86 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { createObsSource } = require('../obs-source');
function fixture(prepare) {
const states = [], children = [];
const source = createObsSource({
prepare: prepare || (async () => ({ executable: 'python', args: ['helper.py'], env: {} })),
spawn(executable, args, options) {
const child = new EventEmitter();
child.stderr = new EventEmitter(); child.stdin = new EventEmitter();
child.stdin.end = () => { child.stdin.writableEnded = true; };
children.push({ child, executable, args, options }); return child;
},
onState: s => states.push(s),
});
return { source, states, children };
}
test('Stop during Python discovery cancels the pending start', async () => {
let resolve;
const f = fixture(() => new Promise(r => { resolve = r; }));
const pending = f.source.start('frame', 15100);
assert.equal(f.source.status().state, 'starting');
f.source.stop();
resolve({ executable: 'python', args: ['helper.py'], env: {} });
await pending;
assert.equal(f.children.length, 0);
assert.equal(f.source.status().state, 'idle');
});
test('late stderr cannot undo Stop; closing allows a fresh start on another port', async () => {
const f = fixture(); await f.source.start('frame', 15100);
const { child, args } = f.children[0];
assert.deepEqual(args.slice(-5), ['--frame', 'frame', '--port', '15100', '--control-stdin']);
child.stderr.emit('data', Buffer.from('Frame Control OBS stream is '));
child.stderr.emit('data', Buffer.from('ready\n'));
assert.equal(f.source.status().state, 'running');
f.source.stop();
child.stderr.emit('data', Buffer.from('last ffmpeg warning\n'));
assert.equal(f.source.status().state, 'stopping');
child.emit('close', 0, null);
assert.equal(f.source.status().state, 'idle');
await f.source.start('other-frame', 15101);
assert.equal(f.source.status().port, 15101);
// An old child cannot overwrite the replacement's state.
child.emit('close', 255, null);
assert.equal(f.source.status().state, 'starting');
});
test('concurrent starts spawn once and preparation failures are retryable', async () => {
let attempts = 0;
const f = fixture(async () => {
if (++attempts === 1) throw new Error('Python missing');
return { executable: 'python', args: [], env: {} };
});
assert.equal((await f.source.start('frame')).state, 'failed');
assert.match(f.source.status().error, /Python missing/);
await Promise.all([f.source.start('frame'), f.source.start('frame')]);
assert.equal(f.children.length, 1);
});
test('invalid ports and aliases cannot spawn; errors and closed pipes remain visible', async () => {
const f = fixture();
for (const port of [0, 1023, 65536, 15099.5, '15100']) {
await assert.rejects(f.source.start('frame', port), /port/i);
}
await assert.rejects(f.source.start('-bad'), /headset/i);
assert.equal(f.children.length, 0);
await f.source.start('frame');
const { child } = f.children[0];
child.stdin.emit('error', Object.assign(new Error('closed pipe'), { code: 'EPIPE' }));
child.stderr.emit('data', Buffer.from('network lost\n'));
child.emit('close', 255, null);
assert.equal(f.source.status().state, 'failed');
assert.match(f.source.status().error, /network lost/);
});
test('an unverified host hides the controls and cannot spawn a relay', async () => {
let spawned = false;
const source = createObsSource({ supported: false, prepare: async () => ({}), spawn: () => { spawned = true; } });
assert.equal(source.status().supported, false);
await source.start('frame');
assert.equal(spawned, false);
});
+10 -4
View File
@@ -4,7 +4,7 @@ A SideQuest backup can contain a small APK and much larger `main.…obb` and
`patch.…obb` files. All of them belong to the same game. Installing only the APK
can leave the game unable to start because its assets are missing.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the base APK, any supported splits, and its OBB files together.
You can also drop them anywhere in the window. In the desktop app, dropping the
APK alone finds matching OBB files beside it; dropping a folder containing the
APK and OBBs also works. Browsers can read only the files you explicitly choose.
@@ -37,9 +37,13 @@ are discarded after use or cancellation; abandoned imports expire after an hour
when the importer is next used, and dead-server staging folders are swept at
startup.
This flow handles one ordinary APK and standard OBB files for one game at a time.
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
archives need their own import handling. PC ZIP/folder/EXE imports keep their
This flow handles one base APK, supported native-library or asset splits, and
standard OBB files for one game at a time. It checks the package and version in
each split manifest before touching the headset. The existing Quest merger
rejects resource, code and feature splits and sets with multiple device
variants. A merged APK is re-signed; that can affect a game's licence or login.
XAPK/APKS/APKM archives use the same checks. Nested Android/data folders and
private save archives remain unsupported. PC ZIP/folder/EXE imports keep their
existing route. Quest USB and Meta library imports keep their existing workflow.
Validation covers actual APK parsing, the three-file backup layout, package
@@ -55,3 +59,5 @@ released, and the battery remained at 99%. See [device evidence](evidence/androi
The headset was unworn; this checks transfer and persistence, not rendering,
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
flow does not change APK compatibility or remove licence checks.
The supported-split backup route was checked with generated local fixtures and
existing merger tests, not a real split game on the Frame.
+11 -9
View File
@@ -436,13 +436,13 @@ permissions remain unverified. They do not change APK install or launch behavior
## Local APK/OBB backups and Android bundles
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
standalone APK and its matching `main.<version>.<package>.obb` and/or
base APK, any supported splits, and its matching `main.<version>.<package>.obb` and/or
`patch.<version>.<package>.obb` files together. The desktop app also discovers
matching OBB files beside a lone selected APK. Browser uploads use only the
files you selected. Installed apps can receive OBB-only imports.
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
one standalone APK and optional matching OBBs. These extensions describe
one base APK, optional supported splits and matching OBBs. These extensions describe
containers, not a guarantee that their contents are supported. OBBs may be
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
one enclosing backup folder is allowed. Known manifest/icon/signature
@@ -450,7 +450,7 @@ sidecars are ignored; the APK manifest determines the package. Unknown files,
unsafe paths, links, encrypted entries, duplicate paths (including case
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
100-file/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
extract an Android ZIP yourself and select its APK/OBBs.
The importer previews the app and files before **Import game & data**.
@@ -463,12 +463,14 @@ data setup. Uploaded and extracted staging is removed on cancellation,
inspection failure, or job completion/failure; abandoned imports expire
through the existing one-hour staging lifecycle. Original local files stay intact.
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
bases marked as requiring splits. It does not select device variants or restore
`Android/data`, private app data, saves, or entitlements. For the separate
restricted native-library/asset split workflow see
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
code and feature splits are not supported by that merger.
**Limits:** this backup importer requires exactly one base APK. For split sets,
each split must identify the same package and version, and pass the existing
[Quest split merger](quest-import.md#split-apks). It refuses resource, code and
feature splits and does not choose among device variants. Merging re-signs the
APK, which can affect licences. It does not restore `Android/data`, private app
data, saves or entitlements. These split imports have local fixture and merger
tests, but no real split game has been installed with this backup route on the
Frame.
For issue #102, the RE4 report only establishes that an APK attempt failed.
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
+23 -2
View File
@@ -100,7 +100,21 @@ copy one by hand if you need it.
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
screenshots (**Tools → Capture & Live** saves those to Pictures).
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
- Symbolic links (never followed), and single files over 2 GiB.
- Symbolic links, including any ancestor below the home folder of a selected
save or settings path, and single files over 2 GiB. Discovery checks each
component, and packing checks it again before opening each file.
Steam backups use the normal `~/.local/share/Steam` directory tree. A separate
`~/.steam/steam` alias does not affect this route. A library, Proton prefix or
Wine save folder redirected by a symlink below the home folder is skipped,
even if its target is inside home; it is not resolved and backed up elsewhere.
The home folder itself is trusted and may use the host's normal path aliases.
These are path checks, not a filesystem snapshot: a concurrent rename or link
replacement between a check and a read can still race it. Close games and
avoid moving or relinking these folders during backup. This fix rejects links
already present when checked, including ones introduced after discovery; it
does not provide atomic protection against concurrent filesystem changes.
Backups aren't encrypted. Game folders can still hold a launcher's login, and
Android app data can hold an app's, so keep backups private.
@@ -192,11 +206,18 @@ If the button can't help, or the headset isn't reachable:
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
for real against a folder laid out like the Frame's home, with only SSH faked:
which paths are picked, that sign-in files and keys never are (also inside game
folders), links never followed, the manifest and its checksums, restore checks
folders), links in save/settings roots and their ancestors refused, the manifest
and its checksums, restore checks
after a reset (game not installed, no Proton folders yet, not signed in), that a
restore replaces only its own files and keeps the old ones, unsafe archives
(`..`, absolute paths, links, other games' folders) writing nothing, backup
names that try to leave the backups folder, and the exact Game Mode command.
Synthetic symlink fixtures cover every component of a Proton save root below
home, including `compatdata/<appid>` and `steamuser/AppData`, plus directory
and file links introduced between discovery and packing. Ordinary Proton and
non-Steam saves, Linux saves, controller layouts, reference settings and home
aliases still pack with their original bytes. These are local fixture checks;
this path fix has not been tested on a headset.
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
the server, deletes it and restores it. Neither involves a real headset.
+37
View File
@@ -0,0 +1,37 @@
# Connection and streaming lag FAQ
## Does Frame Control lower my streaming lag?
No. Frame Control uses SSH to manage the Frame. SteamVR or Steam Link sends the
game stream. Frame Control does not carry the game stream. It cannot lower your
game-stream ping or fix a stream that stops on the PC. A 40–75 ms ping in a VR
game is not a Frame Control measurement.
You do **not** need the Steam Wireless Adapter to use Frame Control. The app can
connect over Wi-Fi, the Wireless Adapter, or USB if the computer has an SSH
route to the Frame. Use the route that lets the app connect. To improve game
streaming, check the SteamVR or Steam Link connection on the PC and the Frame.
The [wired connection doctor](wired-connection.md) checks only Frame Control's
SSH route. A successful doctor check does not prove that the game stream uses
the cable or has low lag.
## Can I use a USB-A-to-C cable?
A Mac reached the Frame over a USB-C connection on the
`10.86.200.232/29` USB network. That test used a USB-C host port. We have
**not tested a USB-A-to-C cable** with the Frame. A data-capable USB-A-to-C
cable might show a USB network interface on your PC, but this is unknown on
the tested Frame OS and PC hosts. Check whether your PC shows that interface
and whether Frame Control's wired doctor can reach the Frame at
`10.86.200.233`. A charge-only cable cannot carry USB data. You can use Wi-Fi
for Frame Control if the USB route does not work.
## 日本語: 5 ステップで接続
1. [公式リリース](https://github.com/saphid/frame-control/releases/latest)から PC の OS 用ファイルをダウンロードします。Windows は `.exe`、macOS は `.dmg`、Linux は `.AppImage` または `.deb` です。同じリリースの `SHA256SUMS` でファイルを確認してください。
2. PC に Frame Control をインストールして起動します。Windows はスタートメニュー、macOS はアプリケーション、Linux はアプリ一覧から開きます。AppImage の場合は実行権限を付けて開きます。
3. Frame の Steam 設定 → システムで開発者モードを有効にし、開発者設定でユーザーパスワードを設定します。Frame と PC を同じ信頼できるネットワークに接続し、Frame を起こしておきます。
4. PC の Frame Control で **Set Up Connection…** を選びます。Frame が見つからない場合は、Frame の設定で IP アドレスを確認して入力します。macOS では **Devices & connection → + Add a headset…** からアドレスを設定できます。
5. Frame に **Pair new host** が出たら、自分が開始した要求だけを承認します。ペアリングに失敗した場合は、PC のターミナルで開発者モードのパスワードを入力します。接続後、Frame Control に戻って状態を確認します。
Frame Control は管理用の SSH 接続を使います。ゲーム映像は SteamVR / Steam Link が送ります。Frame Control を入れてもゲームの ping は下がりません。無線アダプターは必須ではありません。USB-A-to-C ケーブルで Frame の USB ネットワークが使えるかは、まだ確認できていません。
+11 -4
View File
@@ -1,22 +1,29 @@
# What we have tested
Current for Frame Control **v0.4.26**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
Current for Frame Control **v0.4.33**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.31 candidate source `1255ef2`, Frame OS 0.5.6 build `20261022.6153909`: [sanitized results](evidence/round2-smoke-2026-10-25.json) |
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
| OBS headset source on Linux | Real Frame to OBS 30 on a Linux host; H.264 video and a synthetic 440 Hz output tone reached a saved recording | No | Real game audio, audible monitoring, perceptual A/V sync and awake headset view | 25 Oct, PR #174 head, shipped in v0.4.22: [OBS log](evidence/obs-media-source-real-2026-10-25.md) |
| OBS headset source on Windows | No physical Windows PC test | No OBS/Frame VM end-to-end test | App Start/Stop path has automated Windows checks only | 25 Oct, v0.4.23: [Windows PR #179](https://github.com/saphid/frame-control/pull/179), [OBS guide](obs-headset-source.md) |
| OBS headset source on Linux | Real Frame to OBS 30.0.2 on a Linux host: 187.367 s, 720p30 H.264/AAC stereo, 5,621 frames, no encoder skips; room passthrough appeared in the recording | No | Synthetic tone was measured, not real game audio or audible listening. Default audio led by about 0.43 s in this setup; a measured +480 ms OBS offset removed residual skew at test resolution. Source-image latency was 1.431 s in one trial; other hosts and long sessions are unverified | 25 Oct, v0.4.30 package plus PR #219 runtime: [validation](evidence/obs-validation-2026-10-25.md) |
| OBS headset source on Windows | No physical Windows PC test | Windows 11 VM helper fixture passed, but a native bridge to the real Frame sent no packets | In-app OBS controls remain hidden; native Windows OBS and firewall path are unverified | 25 Oct, PR #219: [validation](evidence/obs-validation-2026-10-25.md), [OBS guide](obs-headset-source.md) |
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified. The new large read-only status screen has browser layout and packaging checks only | 25 Oct, v0.4.29 device check and v0.4.31 status screen: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [PR #220](https://github.com/saphid/frame-control/pull/220), [panel guide](headset-app.md) |
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.33 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [compatibility table](decky-frame.md) |
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Fake Frame tests cover setting read/write through update and reinstall; page tests cover the official JSX runtime, stale job recovery and an author sample bundle. The sample is not published in the store. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.33: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
+195
View File
@@ -0,0 +1,195 @@
# Decky Loader on Steam Frame
For the modding-path survey, licence review and update limits, see
[plugin and modding research](plugins-research.md).
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
verified. Frame Control therefore labels its user-service manager experimental.
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
VR user interface has not been seen or used.
## Official store plugin audit, 2026-10-25
On a real Frame with Frame OS 0.5.6 build `20261022.6153909`, SteamVR 2.18.2
reported `activity_level` 3 (standby). The battery was 99% before and after.
The test downloaded the official store's current archives, checked each SHA-256
against store metadata, and inspected their manifests and binaries. For seven
non-root candidates, the removable user-service loader started each plugin in
turn. It stopped between plugins. Logs showed `Loaded <name>` for all seven.
The service and test folders were removed after the run; the owned shared lock
was released. **A load message is not proof that a plugin's page, settings, or
action works in VR.** No plugin was tried by a wearer.
| Official store plugin (tested version) | Result | Evidence and limit |
| --- | --- | --- |
| ProtonDB Badges 1.3.4 | Partial | ARM64 backend loaded. Earlier 1.1.0 also registered in the shared Gamepad UI; badges in the VR shell were not seen. |
| CSS Loader 2.1.3 | Partial | Backend loaded; one `SyntaxWarning`. Theme controls and CSS application in VR were not checked. |
| SteamGridDB 1.8.1 | Partial | Backend loaded. Artwork selection and its UI were not checked. |
| Animation Changer 1.3.2 | Partial | Backend loaded. Boot animation replacement was not attempted. |
| Storage Cleaner 1.4.0 | Partial | Backend loaded. No files were cleaned; no UI check. |
| HLTB for Deck 2.0.12 | Partial | Passive plugin loaded. Game page content in VR was not checked. |
| Bluetooth 3.1.1 | Partial | Backend logged `Bluetooth plugin loaded`. Pairing and VR controls were not checked. |
| PowerTools 2.0.3 | Not applicable | Manifest asks for root; archive includes x86_64 Ryzen binaries and targets Steam Deck hardware. The Frame user service blocks it. |
| Audio Loader 1.6.1 | Not applicable | Manifest asks for `_root`; the Frame user service blocks it. |
| Wine Cellar 0.2.1 | Broken on ARM64 | Archive includes an x86_64 backend binary. The store installer blocks it before execution. |
| MagicPods 2.0.21 | Broken on ARM64 | Archive includes an x86_64 `magicpodscore` binary. The store installer blocks it before execution. |
| Decky Recorder | Not in current official store | No matching plugin appeared in the official store list on this date; no archive was installed. Frame Control's own headset panel has a separate recorder. |
The table reflects the tested versions only. Other releases can change their
files or behavior. The official store's digest checks the archive in transit;
it does not certify plugin code. Plugins run with the headset user's access.
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
The new store transaction was also exercised on the same real Frame:
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
store update → store remove → managed loader remove**. Status reported the
managed store ID and version while the plugin was installed. The service
remained active after each store change. The battery was 99% before and after.
The test removed its plugin folder, loader, and owned lock. This validates the
store file and service path over SSH, not a visible VR plugin page.
Frame Control's desktop app and opt-in headset panel now show official store
entries. Install, update, and remove need an explicit confirmation. They only
change Frame Control owned plugin folders under
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
back an update if the service restart fails. The current VR shell does not
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
shows installed plugins and store actions. Its separate Framey iframe now hosts
compatible API v2 plugin pages, settings, local routes, and panel toasts.
Steam route patches and system notifications remain unavailable. The panel
still needs a worn-headset visual and controller check. See the
[current compatibility table](frame-plugin-author.md#framey-api-v2-compatibility).
## What was checked on the real headset
The headset was SteamOS `0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`,
`aarch64` (SteamOS 3.9.x family). SSH reached it through the existing `frame`
alias. `/` was mounted read-write as Btrfs; no root filesystem change was
needed. `systemctl --user` was running. The Steam client launched with
`-cef-enable-debugging`, `-vrgamepadui`, and `-steamdeck`; its web helper served
CEF remote debugging on `127.0.0.1:8080`. The usual
`.cef-enable-remote-debugging` marker was absent, yet debugging was active.
CEF listed a `SharedJSContext` target at `steamloopback.host/routes/library/home`
and a separate `SteamVR` target for the VR overlay. The Decky 3.3.0 ARM64
binary started from `/tmp` as the normal user: port `127.0.0.1:1337` answered
HTTP 200, the log reported `Loading Decky frontend!`, and CEF showed
`window.deckyHasLoaded === true` and one registered Decky tab in
`SharedJSContext`. The `SteamVR` target had neither that global nor a Decky tab;
its current body text did not contain Decky. The Steam web helper had
`--disable-quick-menu`. This is evidence of backend and shared-context
injection, not proof of an accessible Decky plugin menu inside VR. A QAM
interaction in the headset and a plugin UI test remain necessary.
Frame Control's per-user manager was then exercised on the real Frame:
**install → status → update → status → remove → status**. The service became
active, the binary matched Decky's published SHA-256, and removal left no
service, binary or listening port 1337. Decky's already injected JavaScript
remained in Steam's running web helper until that helper was restarted. After a
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
temporary trial files and the test's empty managed data folder were removed.
## ProtonDB Badges compatibility test
The [ProtonDB Badges v1.1.0 release](https://github.com/OMGDuke/protondb-decky/releases/tag/v1.1.0)
was tested on the same Frame OS build. It is a popular, passive Decky plugin
(133 GitHub stars at test time) that adds badges to game pages. The published
archive SHA-256 was
`b4c72ff0ffe60b1a14abafe75a62f644cbb6689f398a753a1905c281151d9173`.
Its six archive entries had no links or path traversal, and its manifest declared
`flags: []` and `api_version: 2`. The release contains a frontend bundle and
no backend `main.py`; its source backend only manages its own settings.
The test copied the archive into Frame Control's managed Decky plugin folder,
then installed the official Decky 3.3.0 ARM64 binary as a user service. Decky's
log said `Plugin ProtonDB Badges is passive` and `Loaded ProtonDB Badges`.
`DeckyPluginLoader.plugins` in Steam's `SharedJSContext` contained
`ProtonDB Badges`; `deckyHasLoaded` was true there. The separate `SteamVR`
CEF target had no Decky globals or plugin text, and SteamVR listed no Decky
overlay. The headset was in standby, so no in-headset plugin page or badge
interaction was verified. The backend's unauthenticated root URL returned
HTTP 403, as expected.
The test removed the managed Decky service and binary, the test plugin folder,
and its shared-device lock. The service was no longer running, the plugin list
was empty, the binary and unit were gone, and port 1337 was closed. Decky's
already injected frontend may remain in the running Steam web helper until
Steam normally restarts; the test did not restart Steam or SteamVR.
## Why the official Decky installer is not used
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
selects `PluginLoader-arm64` on `aarch64`, creates `~/homebrew/plugins`, writes
the CEF marker, installs `/etc/systemd/system/plugin_loader.service`, and uses
`sudo systemctl` to enable a **root** service. Its
[uninstaller](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/uninstall.sh)
also uses sudo. These scripts do not call `steamos-readonly`, but they do write
under `/etc`; their root service has broad control over the headset. The
current [Decky loader](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/backend/decky_loader/injector.py)
targets Steam's shared Gamepad UI CEF page, injects a frontend served on port
1337, and patches the standard Quick Access Menu. On Frame, Steam supplies
the CEF target, while visible VR shell integration is unresolved.
Frame Control downloads only Decky's official latest ARM64 release, requires
its published SHA-256 digest, and checks the ELF architecture. It installs a
**user** service named `frame-control-decky.service` in
`~/.config/systemd/user`, with the binary and plugin folder under
`~/.local/share/frame-control/decky`. It never asks for a sudo password, edits
`/etc`, or changes CEF flags. Decky warns that running without root is not
officially supported. Plugins that require root, assume `~/homebrew`, or rely
on Steam Deck hardware may fail even if the loader runs. Frame Control lists
plugins already present in its managed folder and the upstream `~/homebrew`
folder. The opt-in store manager installs compatible archives only into its
own folder. It cannot make Steam Deck QAM plugin pages visible in Frame VR.
The experimental [Framey panel host](framey-loader.md) can render compatible
plugin settings inside Frame Control's separate SteamVR dashboard panel.
The real headset still needs a worn-user visibility and controller check.
The latest tested release was
[Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0),
`PluginLoader-arm64` SHA-256
`8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6`.
This digest comes from that release's GitHub API asset metadata; it detects a
download mismatch, but is not an independent signature.
## Opt in and remove
In Frame Control, open **Tools → Decky Loader on Frame**, read the experimental
notice, check the acknowledgement box, and choose **Install**. **Update**
downloads and verifies the latest ARM64 release again. **Remove managed
Decky** disables and removes only Frame Control's user service and binary.
It preserves plugin files and settings so unrelated user data is not deleted.
Open **Official plugin store** to review a plugin, then confirm each install,
update, or remove. Root and x86-only plugins are blocked. The manager only
removes plugin folders that it installed; it leaves other Decky folders alone.
The desktop app and headset panel share one lock for managed Decky changes.
If another change is in progress, wait for it to finish and try again.
Restart Steam or the headset to clear the frontend already injected into its
current web helper process. No action wipes the headset.
Manual removal if Frame Control is unavailable:
```sh
ssh frame 'systemctl --user disable --now frame-control-decky.service; rm -f ~/.config/systemd/user/frame-control-decky.service ~/.local/share/frame-control/decky/PluginLoader ~/.local/share/frame-control/decky/.frame-control/receipt.json; systemctl --user daemon-reload; systemctl --user reset-failed frame-control-decky.service'
```
That command intentionally keeps `~/.local/share/frame-control/decky/plugins`
and `settings`. Check those folders before deleting them yourself. An upstream
Decky installation is separate; use its own installer to remove it. Frame
Control refuses to install alongside an upstream system service.
## Next compatibility test
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
Quick Access Menu after an opt-in install. If none appears, inspect whether
the VR shell exposes a different menu component and whether Decky's frontend
can target it without patching Steam globally. Confirm the plugin's visible
UI, controller interaction, and persistence across a normal Steam restart.
Until those checks pass,
Frame Control should not describe Decky plugins as usable in VR yet. A worn
headset check is needed for both the Decky menu and the Frame Control SteamVR
panel; only then can their controller interaction and plugin presentation be
confirmed.
@@ -0,0 +1,79 @@
# Auto-sync retry and headset-selection reliability
Date: 25 October 2026. Base: `d79efb4`, Frame Control v0.4.30.
Issue: [#214](https://github.com/saphid/frame-control/issues/214).
Environment: Linux x86_64, Python 3.12.3, Node 24.15.0.
## Reproduced problems
The existing 19 auto-sync tests passed on the base revision. Added regressions
failed before the implementation: an exhausted clip lost its error, explicit
retry did not recover failed screenshots or clips, and switches around listing
or copying continued the old pass. The original callback interface also had no
way to carry the tick's captured device into the server list reservation.
The final fake-source tests exercise these boundaries:
- Fail screenshots or clips three times, keep checking without further copy
attempts, and retain the source error plus an explicit retry instruction.
- Recover the source and use either Resume or Copy everything now without
restarting. Successful copies stay deduplicated; Resume retains the ledger
even after a local file is deleted. Copy everything now retains its documented
ability to restore deleted files and include older media.
- Reset an exhausted budget while the source still fails: another three
attempts are allowed, rather than unlimited background attempts.
- Save an exhausted item by hand and clear its stale failure on the next list.
- Switch after tick captures A, during a list, after screenshot bookkeeping,
between media kinds, before a transfer, or before transfer bookkeeping.
Interrupted passes do not mark the other headset's IDs or continue copying.
- Switch to B exactly at callback entry through the production `_sync_list`
adapter. `working(A)` refuses before calling the source list, leaving both
ledgers and copy callbacks untouched. The same production reservation blocks
a device switch during a list and rejects stale screenshot/clip transfers.
- Execute the production Settings renderer in Node: Resume is available for
copy errors and disk-full pauses, and hidden for a healthy state.
## Implementation boundary
Each list callback now receives the captured device, like the copy callbacks.
The existing server `working(meant)` mechanism checks that intended headset
under `_work_lock` while reserving work; `devices_post` uses the same lock to
refuse switches during that work. The worker also checks for interruption
around lists and transfers. No new device lock or clip destination logic is
introduced. The server diff is confined to `_sync_list`.
The failed-attempt and error maps are in-memory and reset on explicit retry or
restart. The successful-copy ledger and settings persistence retain their
existing format. Disk-full pauses, offline backoff, clip settling and the
three-attempt limit remain covered by the original tests.
## Checks
- `python3 -m unittest discover -s tests -p test_autosync.py -v`: 33 passed.
- `python3 -m unittest discover -s tests -p test_shot_copy.py -v`: 2 passed.
- `python3 -m unittest discover -s tests -p test_clips.py -v`: 34 passed,
including local ffmpeg coverage.
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
36 passed.
- `python3 -m unittest discover -s tests -p test_page_scripts.py -v`: 5 passed.
- `node --test site/test/*.test.mjs`: 2 test files passed.
- `python3 -m py_compile ui/frame_autosync.py ui/server.py tests/test_autosync.py`
and `git diff --check`: passed.
- `python3 -m unittest discover -s tests -v`: 1,525 tests run in 184.876
seconds, passed with 16 skips. Ran with sandbox escalation for loopback
sockets; test isolation still used throwaway app/SSH data and mocked headset
transport. Final output:
```text
Ran 1525 tests in 184.876s
OK (skipped=16)
```
## Limits and handoff
All new regressions use fake sources and mocked transport; they do not prove
real-headset transfer or UI rendering. No headset was accessed: its shared test
lock was held by another task. The Docker fake-Frame end-to-end suite and
Windows/macOS runs were not performed locally. The coordinator owns review and
merge; this task does not release, change PR #113, or publish announcements.
@@ -0,0 +1,36 @@
# Framey loader evidence, 25 October 2026
## Local browser and fake Frame
The official Decky store returned 116 validated entries. ProtonDB Badges was
entry 14, release 1.3.4, SHA-256
`90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358`.
Its published ZIP passed Frame Control's store manifest and path validator. The current
`dist/index.js` bundle was loaded in an opaque-origin iframe in local Chromium
with the Framey React/DFL shim. The [browser screenshot](framey-browser-2026-10-25.png)
shows the plugin's settings panel. A pointer click on **Enable Badge on
Library** issued `set_setting('settings', …)` through the iframe's request
bridge. The local test parent returned a stub backend reply; it did not use a
real headset or Decky backend. A separate fake Frame CEF fixture returned
`get_setting` and `set_setting` responses through Frame Control's CDP bridge;
that fixture does not model the real Decky process.
The disposable fake Frame end-to-end harness passed the store catalogue,
acknowledgement and HTTP auth checks, hash-verified install, update, and
removal through the real Frame Control HTTP server and SSH transaction. The
full fake Frame suite passed 48 tests; the full unit suite passed 1,559 tests
(16 skipped). Unit tests covered unsafe archives,
unmanaged-folder preservation, asset isolation, backend-call scope,
and safe aborts of the worn-headset check.
## Real headset status
Read-only SSH on Frame OS 0.5.6 found battery at 98–99%. SteamVR activity was
initially 3 (standby), briefly 0, then 3 again. During the active probe the
shared `/tmp/frame-test.lock` belonged to `obs-aacapv3evh-20261025`. It later
became free after SteamVR returned to standby. Framey did not acquire, replace,
or remove it. The active probe found no installed Frame Control VR panel or
Framey plugin; the scripted worn check exited before acquiring a lock or
altering the headset. There is **no** wearer-visible VR
menu screenshot, controller-input proof, or live settings-persistence result
from this round.
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -0,0 +1,32 @@
# SteamOS PC OBS host feasibility check, 2026-10-25 UTC
The available x86_64 host, lxso2, runs Ubuntu 24.04.3. It has no `flatpak`
command, OBS installation, SteamOS 3.9.5 VM or physical Steam Machine. This
check therefore does **not** verify that Frame Control or Flatpak OBS runs on
a SteamOS desktop or a read-only-root PC host.
The downloaded v0.4.27 Linux x86_64 AppImage matched GitHub's SHA-256 digest
`42d774653c69b193c3d577743c32b8fe8dfeee7ea1f8cdf7369902962d647b83`.
With `APPIMAGE_EXTRACT_AND_RUN=1`, it launched under Xvfb using an isolated
profile and its bundled server returned HTTP 200. FUSE mounting, physical
display rendering and SteamOS-specific dependencies were not tested.
The v0.4.27 tagged OBS helper then ran against the real Frame under the shared
`/tmp/frame-test.lock`. Frame OS was 0.5.6 and SteamVR activity level was 3
(standby). A receiver bound to `127.0.0.1:15099` on lxso2 accepted 497
MPEG-TS packets, 93,436 bytes. Every datagram had complete 188-byte packets
with sync bytes. `ffprobe` found H.264 video and AAC audio. Closing the
helper's control pipe made it exit 0; no owned remote stream process remained.
Battery read 100% Full before and after, and the owned lock was released. The
temporary `.ts` file was removed. This proves the local UDP sender path on an
Ubuntu host; it does not prove Flatpak OBS could receive it on SteamOS.
The public
[OBS Flatpak manifest](https://github.com/flathub/com.obsproject.Studio/blob/master/com.obsproject.Studio.json)
lists `--share=network`, which permits use of the host network. We read that
manifest but did not install or run the Flatpak. A future check needs a real
SteamOS 3.9.5 desktop host or VM with Flatpak OBS: launch the AppImage without
root changes, start the source, record from the Media Source at
`udp://127.0.0.1:15099`, verify video and audible game audio, then stop and
confirm cleanup. The standby test cannot establish real game audio, A/V sync
or active passthrough in the mirror.
+125
View File
@@ -0,0 +1,125 @@
# OBS end-to-end validation — 25 October 2026
**Passthrough: yes.** Real room pixels behind SteamVR panels appeared in the
live `/dev/video99` mirror, the saved OBS recording, and a decoded frame at
four seconds in a production MP4. The Frame was safely woken with
`vrcmd --handlewakeup`; no camera, power or compositor settings were changed.
This establishes the checked SteamVR mirror, not every game/mode or Steam's
own clips. Treat recordings as potentially showing the room.
## Environment and provenance
- Host: lxso2, Linux x86_64. Real OBS 30.0.2 in disposable Ubuntu 24.04/Xvfb,
using software llvmpipe rendering and the Media Source `ffmpeg_source`.
- Frame: SteamOS 0.5.6, build `20261022.6153909`, SteamVR 2.18.2, reachable
with the existing `ssh frame` connection. Hardware use was serialized with
coordinator mppkd9qczt through `/tmp/frame-test.lock` and a unique owner.
- Released v0.4.30 AppImage SHA-256:
`b1335bb06130f2cce64a0dd40a8cb60a52c68effc258dc4bc35f8c40c822d5bb`.
The long recording started through this app's actual Electron IPC, Python
helper and SSH stream. Robustness checks then used that released runtime
with the final changed resources and repacked `app.asar`; this was not a
newly built AppImage. No OBS plugin or host FFmpeg is required by the app.
- Windows release ZIP SHA-256:
`a393cc1653f14f162b4a3e56803f7fe9bea7b04904d18577b0b2d7d7c2d95b03`.
Checked against release SHA256SUMS; bundled Python 3.12.14.
Machine-readable measurements and the manual stimulus/analysis programs are
in [obs-validation-2026-10-25/](obs-validation-2026-10-25/).
Room images and videos remain private under `/tmp/obs-aacapv3evh` on lxso2;
only their hashes are committed in `private-artifacts.json`.
## Recording, sound, sync and latency
| Check | Measured result | What it establishes |
| --- | --- | --- |
| Real OBS recording | 187.367 s; 15,784,412 bytes; H.264 1280×720 at 30 fps; 5,621 decoded video frames; AAC 48 kHz stereo | More than three minutes through the packaged Linux path |
| OBS dropped frames | 0 encoder skips; 90 render lag/stall frames, approximately 1.6% of recording frames | The software-rendered host was not lossless; this is not a hardware-rendered performance benchmark |
| Audio | 1 kHz stimulus; FFT peak 996.094 Hz at 11.719 Hz bin resolution; peak −13.207 dBFS; 0 clipped samples; measured harmonic energy −81.32 dB relative to fundamental | Non-silent, no numerical clipping or large harmonic distortion in the checked tone; no listening or real-game quality verdict |
| Baseline A/V | Audio leads video by median 426.67 ms; range 360–426.67 ms across 36 pulses | Default OBS setup needs sync checking; not suitable for a claim of automatic accurate sync |
| Live image latency | 1,431.4 ms, n=1; clock exchange RTT 3.409 ms; screenshot polling about 20–35 ms | Overlay submission to OBS source screenshot, not physical headset motion-to-photon latency |
| Production MP4 | 5.121 s; 1920×1080, 30 fps, 153 video frames; AAC stereo; request to saved result 7.435 s | A separate production recorder saved room passthrough pixels too |
The manual stimulus places an owned black/white OpenVR marker over the mirror
and plays a 200 ms, 1 kHz tone every five seconds through the Frame's default
output. `pa_simple_get_latency` estimates when the submitted samples play;
marker submission is aligned to that estimate. This measures software output,
not an externally measured loudspeaker/physical-display event. Most submission
jitter was below 0.2 ms; compositor timing and the 30 fps/10 ms analysis grids
still limit precision. FFmpeg decodes both tracks; the analysis detects marker
edges and groups AAC pre-echo onsets within 100 ms.
A separate initial +400 ms OBS Sync Offset trial left 83.33 ms of audio lead.
Changing the offset during an already active source from 0 to +367 ms did not
improve that run (366.67 ms before, 370 ms after). Do not assume a universal
offset or that a live setting change has taken effect: reopen the source and
verify another recording. Three subsequent fresh-source trials, setting the offset **before activating**
the Media Source, measured 480 ms audio lead at 0 ms, 70 ms lead at +400 ms,
and **0 ms at +480 ms** across three pulses (30 fps video and 10 ms audio
analysis resolution). This proves a setup-specific workaround, not a universal
default. See `calibration-initial.json`. Full trial measurements are retained. Zero OBS
buffering caused missing/distorted sound and was rejected. Zero mux delay
and alternate timestamp experiments did not improve latency and were not
merged. The default capture/encoding settings remain unchanged.
## Robustness and fixes
| Failure or scenario | Fix / result | Proof |
| --- | --- | --- |
| Stop during asynchronous Python discovery could still launch later | Cancel the pending generation; expose Stop while starting | Controller regression test |
| Late stderr containing an earlier ready message changed stopping back to running | Only accept ready while starting; ignore stale child events | Original released block reproduced in `race-before.json`; controller regression |
| A non-responsive SSH process defeated timeout termination | Bounded terminate/wait then kill; SSH connection/keepalive bounds | Real SSH child deliberately stopped with SIGSTOP; clear UI failure in 15.009 s including 5 s forced cleanup; both processes gone |
| Timeout with the app's stdin still open aborted Python at shutdown | Read the control descriptor directly, avoiding a blocked buffered-stdin daemon lock | Previously reproduced fatal shutdown; final real stall shows `The Frame OBS video stopped for 10 seconds`; subprocess regression holds stdin open |
| Three Start/Stop cycles | First packets 0.890 / 0.701 / 0.709 s; Stop to idle 61.7 / 52.7 / 53.0 ms | Final patched packaged runtime, ports 15101–15103, `robustness.json` |
| Restart after stall | First packet 0.691 s | Same runtime and real Frame |
| Quit while streaming | No surviving helper at 2.021 s | Same runtime; final audit found no test FFmpeg/stimulus process |
| Port 15099 occupied | App now offers a validated alternate port and matching copyable URL | OBS occupied 15099 while real packets arrived on alternate ports; UDP cannot identify or guarantee the receiver |
| OBS before/after Start; receiver restarts | Live decoding verified, including recreated sources while relay stays active; an already-open stalled receiver may need hide/show or properties OK | Documented recovery, not automatic OBS control |
| Standby/wake | Safe wake changed a blank mirror to visible room/panels | Private mirror and saved recordings |
A forced SSH stall tests the transport failure and error/cleanup behavior.
It does **not** prove real Wi-Fi loss or full headset suspend/resume. No physical
network interface was disabled and no headset reset was performed. There is
no automatic reconnect: restore connectivity and press Start. Valid blank
standby frames do not trigger a packet timeout.
## Windows and macOS
**Windows controls remain hidden.** A disposable Windows 11 VM ran the final
helper using the released app's bundled Python and a native executable SSH
fixture. It stayed alive for three seconds without a UDP receiver, then sent
1,316-byte packets when a receiver opened. Closing its control pipe returned
exit 0 with no surviving SSH child (`windows-pipe.json`; stop measured below
the VM timer's resolution in the final run). The helper hides the SSH console
and tolerates Winsock's delayed UDP receiver-unreachable error.
The real-headset Windows attempt delivered zero packets through a temporary
host SSH bridge. Native Windows OBS, direct Windows-to-Frame SSH, firewall
behavior and packaged app Start/Stop have therefore not passed end to end.
No local FFmpeg was installed; encoding belongs on the Frame. No firewall
exception, headset authorized key, or Windows scheduled task was installed.
**macOS:** no physical Mac or macOS VM was available. Python tests and the
five controller lifecycle tests run in the macOS CI matrix; these cannot
establish capture performance, audio quality, packaged launch or OBS behavior
on a physical Mac.
## Validation and remaining limits
Local suite: **1,514 Python tests passed, 16 skipped**, 46 app tests and 18
site tests passed. The latest helper additionally passed all 11 focused OBS
tests; the controller passed all five focused lifecycle tests. Cross-platform
CI results belong to the linked PR, not these host measurements.
Still unverified: perceptual listening, real-game audio routing, accurate
default A/V sync, broad latency/performance across hosts, hours-long sessions,
physical Wi-Fi drop/full suspend recovery, physical macOS and native Windows
OBS. The app/docs now explain copyable URL/format, receiver recovery, port
selection, passthrough privacy and OBS Virtual Camera's video-only limit.
The Discord guide provides the corresponding audio-sharing choices.
Cleanup completed: Steam and vrcompositor remained running, battery 98%,
no test FFmpeg/stimulus process remained, UDP ports 15099/15101/15102/15103/15105
were bindable, task guest SSH config and the temporary bridge were removed,
and the owned headset lock was released. The disposable Windows VM was
stopped with its disk retained. See `cleanup.json`.
@@ -0,0 +1,23 @@
import json,subprocess,sys,statistics
from pathlib import Path
import numpy as np
from scipy import signal
video=Path(sys.argv[1]); root=video.parent
v=np.frombuffer(subprocess.check_output(['ffmpeg','-v','error','-i',str(video),'-map','0:v','-vf','scale=64:36','-pix_fmt','rgb24','-f','rawvideo','-']),dtype=np.uint8).reshape(-1,36,64,3)
a=np.frombuffer(subprocess.check_output(['ffmpeg','-v','error','-i',str(video),'-map','0:a','-ac','1','-ar','48000','-f','f32le','-']),dtype='<f4')
# Locate the high contrast square marker, excluding the background's low variations.
gray=v.astype(float).mean(axis=3); spread=np.percentile(gray,98,axis=0)-np.percentile(gray,2,axis=0)
y,x=np.unravel_index(spread.argmax(),spread.shape); track=gray[:,y,x]; lo,hi=np.percentile(track,[2,98]); white=track>(lo+hi)/2;onsets=np.where(np.diff(white.astype(int))==1)[0]+1
rms=np.sqrt(np.mean(a[:len(a)//480*480].reshape(-1,480)**2,axis=1)); audible=rms>.003;beeps=(np.where(np.diff(audible.astype(int))==1)[0]+1)/100
beeps=beeps[np.r_[True,np.diff(beeps)>.1]]
visual=onsets/30; offsets=[]
for t in visual:
if len(beeps):
b=beeps[np.argmin(abs(beeps-t))]
if abs(b-t)<2:offsets.append(float((b-t)*1000))
active=a[abs(a)>.003]; peaks,props=signal.find_peaks(rms,height=.003,distance=100)
# Spectrum of one well inside a tone, with Hann window; harmonic distortion estimate.
start=int((beeps[2]+.05)*48000) if len(beeps)>2 else 0;sample=a[start:start+4096];freq=np.fft.rfftfreq(len(sample),1/48000);power=abs(np.fft.rfft(sample*np.hanning(len(sample))))**2
base=power[(freq>960)&(freq<1040)].sum();harm=sum(power[(freq>h*1000-40)&(freq<h*1000+40)].sum() for h in range(2,10))
result={'duration_s':len(v)/30,'video_frames':len(v),'marker_pixel_xy':[int(x),int(y)],'marker_levels':[float(lo),float(hi)],'visual_markers_s':visual.tolist(),'audio_onsets_s':beeps.tolist(),'audio_minus_video_ms':offsets,'offset_median_ms':statistics.median(offsets) if offsets else None,'offset_min_max_ms':[min(offsets),max(offsets)] if offsets else None,'peak_dbfs':float(20*np.log10(abs(a).max())),'clipped_samples':int((abs(a)>=.999).sum()),'tone_frequency_hz':float(freq[power.argmax()]),'harmonics_to_fundamental_db':float(10*np.log10(harm/base)),'rms_peak':float(rms.max())}
(root/(video.stem+'-analysis.json')).write_text(json.dumps(result,indent=2));np.save(root/(video.stem+'-marker-spread.npy'),spread);print(json.dumps(result,indent=2))
@@ -0,0 +1,136 @@
{
"duration_s": 187.36666666666667,
"video_frames": 5621,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
9.766666666666667,
14.766666666666667,
19.766666666666666,
24.766666666666666,
29.766666666666666,
34.766666666666666,
39.766666666666666,
44.766666666666666,
49.766666666666666,
54.766666666666666,
59.766666666666666,
64.76666666666667,
69.76666666666667,
74.76666666666667,
79.76666666666667,
84.76666666666667,
89.76666666666667,
94.76666666666667,
99.76666666666667,
104.76666666666667,
109.76666666666667,
114.76666666666667,
119.76666666666667,
124.76666666666667,
129.76666666666668,
134.76666666666668,
139.76666666666668,
144.7,
149.7,
154.7,
159.7,
164.7,
169.7,
174.7,
179.7,
184.7
],
"audio_onsets_s": [
9.34,
14.34,
19.34,
24.34,
29.34,
34.34,
39.34,
44.34,
49.34,
54.34,
59.34,
64.34,
69.34,
74.34,
79.34,
84.34,
89.34,
94.34,
99.34,
104.34,
109.34,
114.34,
119.34,
124.34,
129.34,
134.34,
139.34,
144.34,
149.34,
154.34,
159.34,
164.34,
169.34,
174.34,
179.34,
184.34
],
"audio_minus_video_ms": [
-426.66666666666765,
-426.66666666666765,
-426.66666666666583,
-426.66666666666583,
-426.66666666666583,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666623,
-426.6666666666765,
-426.6666666666765,
-426.6666666666765,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852,
-359.9999999999852
],
"offset_median_ms": -426.6666666666623,
"offset_min_max_ms": [
-426.6666666666765,
-359.9999999999852
],
"peak_dbfs": -13.2072114944458,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -81.32048537168609,
"rms_peak": 0.15280573070049286
}
@@ -0,0 +1,123 @@
[
{
"duration_s": 21.033333333333335,
"video_frames": 631,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
4.9,
9.9,
14.9,
19.9
],
"audio_onsets_s": [
4.42,
9.42,
14.42,
19.42
],
"audio_minus_video_ms": [
-480.00000000000045,
-480.00000000000045,
-480.00000000000045,
-479.9999999999969
],
"offset_median_ms": -480.00000000000045,
"offset_min_max_ms": [
-480.00000000000045,
-479.9999999999969
],
"peak_dbfs": -13.202757835388184,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -83.94567523863627,
"rms_peak": 0.15256504714488983,
"offset_ms": 0,
"recording": "2026-10-25 06-31-15.mkv"
},
{
"duration_s": 21.066666666666666,
"video_frames": 632,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
10.6,
15.6,
20.6
],
"audio_onsets_s": [
5.62,
10.53,
15.53,
20.53
],
"audio_minus_video_ms": [
-70.00000000000028,
-70.00000000000028,
-70.00000000000028
],
"offset_median_ms": -70.00000000000028,
"offset_min_max_ms": [
-70.00000000000028,
-70.00000000000028
],
"peak_dbfs": -13.215741157531738,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -83.99685616364809,
"rms_peak": 0.15322808921337128,
"offset_ms": 400,
"recording": "2026-10-25 06-31-46.mkv"
},
{
"duration_s": 21.0,
"video_frames": 630,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
7.6,
12.6,
17.6
],
"audio_onsets_s": [
7.6,
12.6,
17.6
],
"audio_minus_video_ms": [
0.0,
0.0,
0.0
],
"offset_median_ms": 0.0,
"offset_min_max_ms": [
0.0,
0.0
],
"peak_dbfs": -13.219660758972168,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -83.85259564406981,
"rms_peak": 0.15266509354114532,
"offset_ms": 480,
"recording": "2026-10-25 06-32-18.mkv"
}
]
@@ -0,0 +1,22 @@
import json,sys,time,subprocess,os
from pathlib import Path
sys.path.insert(0,'/tmp/obs-aacapv3evh');from control import OBS
r=Path('/tmp/obs-aacapv3evh');o=OBS();
try:o.request('RemoveInput',{'inputName':'Frame mirror'})
except RuntimeError:pass
time.sleep(1)
h=subprocess.Popen([sys.executable,'scripts/obs-headset-source.py','--control-stdin'],stdin=subprocess.PIPE,stderr=(r/'calibration-initial-helper.log').open('w'))
p=subprocess.Popen(['ssh','frame','XDG_RUNTIME_DIR=/run/user/1000 timeout 100 python3 /tmp/obs-aacapv3evh/stimulus.py'],stdout=(r/'calibration-initial-stimulus.jsonl').open('w'),stderr=(r/'calibration-initial-stimulus.log').open('w'))
results=[]
try:
for offset in (0,400,480):
name='Frame calibration '+str(offset)
data=o.request('CreateInput',{'sceneName':'Scene','inputName':name,'inputKind':'ffmpeg_source','inputSettings':{'is_local_file':False,'input':'udp://127.0.0.1:15099','input_format':'mpegts','buffering_mb':2,'restart_on_activate':True},'sceneItemEnabled':False})
o.request('SetInputAudioSyncOffset',{'inputName':name,'inputAudioSyncOffset':offset})
print('setting',o.request('GetInputAudioSyncOffset',{'inputName':name}),flush=True)
o.request('SetSceneItemEnabled',{'sceneName':'Scene','sceneItemId':data['sceneItemId'],'sceneItemEnabled':True})
time.sleep(7);o.request('StartRecord');time.sleep(21);rec=o.request('StopRecord');video=r/Path(rec['outputPath']).name
env=dict(os.environ,PYTHONPATH=str(r/'deps'));a=json.loads(subprocess.check_output([sys.executable,str(r/'analyse.py'),str(video)],env=env));a.update(offset_ms=offset,recording=video.name);results.append(a);print(json.dumps(a),flush=True)
o.request('RemoveInput',{'inputName':name})
finally:
h.stdin.close();h.wait(timeout=10);p.terminate();p.wait(timeout=10);(r/'calibration-initial-result.json').write_text(json.dumps(results,indent=2))
@@ -0,0 +1,88 @@
{
"offset_ms": 367,
"records": [
{
"duration_s": 19.966666666666665,
"video_frames": 599,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
6.133333333333334,
7.266666666666667,
12.266666666666667,
17.266666666666666
],
"audio_onsets_s": [
6.9,
11.9,
16.9
],
"audio_minus_video_ms": [
766.6666666666666,
-366.66666666666623,
-366.66666666666714,
-366.66666666666714
],
"offset_median_ms": -366.6666666666667,
"offset_min_max_ms": [
-366.66666666666714,
766.6666666666666
],
"peak_dbfs": -13.215461730957031,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -84.12980132719696,
"rms_peak": 0.15284518897533417,
"phase": "before",
"recording": "2026-10-25 06-28-11.mkv"
},
{
"duration_s": 20.066666666666666,
"video_frames": 602,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
4.4,
9.4,
14.4,
19.4
],
"audio_onsets_s": [
4.03,
9.03,
14.03,
19.03
],
"audio_minus_video_ms": [
-370.0000000000001,
-370.000000000001,
-370.000000000001,
-369.99999999999744
],
"offset_median_ms": -370.00000000000057,
"offset_min_max_ms": [
-370.000000000001,
-369.99999999999744
],
"peak_dbfs": -13.167834281921387,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -82.16381051643725,
"rms_peak": 0.15364883840084076,
"phase": "after",
"recording": "2026-10-25 06-28-34.mkv"
}
]
}
@@ -0,0 +1,16 @@
{
"bindable_udp_ports": [
15099,
15101,
15102,
15103,
15105
],
"remote_task_ffmpeg_and_stimulus_processes": 0,
"steam_and_vrcompositor_running": true,
"battery_percent": 98,
"headset_lock_released": true,
"guest_task_ssh_host_block_removed": true,
"temporary_ssh_bridge_stopped": true,
"persistent_headset_authentication_changes": false
}
@@ -0,0 +1,46 @@
{
"duration_s": 36.06666666666667,
"video_frames": 1082,
"marker_pixel_xy": [
50,
0
],
"marker_levels": [
0.0,
255.0
],
"visual_markers_s": [
9.433333333333334,
14.433333333333334,
19.433333333333334,
24.433333333333334,
29.433333333333334,
34.43333333333333
],
"audio_onsets_s": [
9.35,
14.35,
19.35,
24.35,
29.35,
34.35
],
"audio_minus_video_ms": [
-83.33333333333393,
-83.33333333333393,
-83.33333333333215,
-83.33333333333215,
-83.33333333333215,
-83.3333333333286
],
"offset_median_ms": -83.33333333333215,
"offset_min_max_ms": [
-83.33333333333393,
-83.3333333333286
],
"peak_dbfs": -13.15299129486084,
"clipped_samples": 0,
"tone_frequency_hz": 996.09375,
"harmonics_to_fundamental_db": -82.4335935832891,
"rms_peak": 0.15312190353870392
}
@@ -0,0 +1,13 @@
{
"clock": {
"rtt": 0.0034094941802322865,
"offset_remote_minus_host": -2894789.9195547644
},
"hits": [
{
"source_index": 1000,
"delay_ms": 1431.418131571263,
"query_ms": 11.743285227566957
}
]
}
@@ -0,0 +1,25 @@
{
"programs": [
],
"streams": [
{
"codec_name": "h264",
"width": 1920,
"height": 1080,
"r_frame_rate": "30/1",
"nb_read_frames": "153"
},
{
"codec_name": "aac",
"sample_rate": "48000",
"channels": 2,
"r_frame_rate": "0/0",
"nb_read_frames": "233"
}
],
"format": {
"duration": "5.121000",
"size": "3932019"
}
}
@@ -0,0 +1,10 @@
{
"elapsed_s": 7.434504003729671,
"result": {
"state": "saved",
"file": "/tmp/obs-aacapv3evh/mp4/rec-20261025-062251.mp4",
"audio": true,
"seconds": 5,
"message": "Saved rec-20261025-062251.mp4 to /tmp OBS evidence"
}
}
@@ -0,0 +1,210 @@
{
"result": {
"outputPath": "/evidence/2026-10-25 06-08-22.mkv"
},
"samples": [
{
"elapsed": 30.000104404054582,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 9068.64453125,
"averageFrameRenderTime": 18.458413,
"cpuUsage": 20.24916849878798,
"memoryUsage": 543.7109375,
"outputSkippedFrames": 0,
"outputTotalFrames": 959,
"renderSkippedFrames": 4,
"renderTotalFrames": 1783,
"webSocketSessionIncomingMessages": 14,
"webSocketSessionOutgoingMessages": 16
},
"record": {
"outputActive": true,
"outputBytes": 2243010,
"outputDuration": 30866,
"outputPaused": false,
"outputTimecode": "00:00:30.866"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 60.002103955019265,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8942.09375,
"averageFrameRenderTime": 13.660508,
"cpuUsage": 23.863888888888887,
"memoryUsage": 544.56640625,
"outputSkippedFrames": 0,
"outputTotalFrames": 1859,
"renderSkippedFrames": 5,
"renderTotalFrames": 2683,
"webSocketSessionIncomingMessages": 16,
"webSocketSessionOutgoingMessages": 18
},
"record": {
"outputActive": true,
"outputBytes": 4951606,
"outputDuration": 60866,
"outputPaused": false,
"outputTimecode": "00:01:00.866"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 90.0040782331489,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8900.6015625,
"averageFrameRenderTime": 12.110281,
"cpuUsage": 23.680555555555557,
"memoryUsage": 545.74609375,
"outputSkippedFrames": 0,
"outputTotalFrames": 2759,
"renderSkippedFrames": 13,
"renderTotalFrames": 3583,
"webSocketSessionIncomingMessages": 18,
"webSocketSessionOutgoingMessages": 20
},
"record": {
"outputActive": true,
"outputBytes": 7552329,
"outputDuration": 90866,
"outputPaused": false,
"outputTimecode": "00:01:30.866"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 120.0062853959389,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8603.08984375,
"averageFrameRenderTime": 14.892247,
"cpuUsage": 24.66111111111111,
"memoryUsage": 546.52734375,
"outputSkippedFrames": 0,
"outputTotalFrames": 3659,
"renderSkippedFrames": 13,
"renderTotalFrames": 4483,
"webSocketSessionIncomingMessages": 20,
"webSocketSessionOutgoingMessages": 22
},
"record": {
"outputActive": true,
"outputBytes": 10124530,
"outputDuration": 120866,
"outputPaused": false,
"outputTimecode": "00:02:00.866"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 150.00849257130176,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8484.58203125,
"averageFrameRenderTime": 15.3434,
"cpuUsage": 23.169444444444444,
"memoryUsage": 548.5546875,
"outputSkippedFrames": 0,
"outputTotalFrames": 4559,
"renderSkippedFrames": 78,
"renderTotalFrames": 5383,
"webSocketSessionIncomingMessages": 22,
"webSocketSessionOutgoingMessages": 24
},
"record": {
"outputActive": true,
"outputBytes": 12643085,
"outputDuration": 150833,
"outputPaused": false,
"outputTimecode": "00:02:30.833"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 180.01099411491305,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8382.75,
"averageFrameRenderTime": 31.402812,
"cpuUsage": 23.717094301899365,
"memoryUsage": 548.51953125,
"outputSkippedFrames": 0,
"outputTotalFrames": 5459,
"renderSkippedFrames": 90,
"renderTotalFrames": 6283,
"webSocketSessionIncomingMessages": 24,
"webSocketSessionOutgoingMessages": 26
},
"record": {
"outputActive": true,
"outputBytes": 15130232,
"outputDuration": 180833,
"outputPaused": false,
"outputTimecode": "00:03:00.833"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
},
{
"elapsed": 185.01674677524716,
"obs": {
"activeFps": 30.000000300000007,
"availableDiskSpace": 8366.79296875,
"averageFrameRenderTime": 22.859219,
"cpuUsage": 23.766666666666666,
"memoryUsage": 510.28515625,
"outputSkippedFrames": 0,
"outputTotalFrames": 5609,
"renderSkippedFrames": 90,
"renderTotalFrames": 6433,
"webSocketSessionIncomingMessages": 26,
"webSocketSessionOutgoingMessages": 28
},
"record": {
"outputActive": true,
"outputBytes": 15580161,
"outputDuration": 185833,
"outputPaused": false,
"outputTimecode": "00:03:05.833"
},
"app": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
}
]
}
@@ -0,0 +1,26 @@
[
{
"name": "2026-10-25 06-08-22.mkv",
"bytes": 15784412,
"sha256": "f2760ed28bf48274cc8c936428edbc389cd3a9f4b9cd2a61e699a04bf4487b50",
"published": false
},
{
"name": "rec-20261025-062251.mp4",
"bytes": 3932019,
"sha256": "c71de1a49b75159c587fddd012361b9e6626a4d5cc41b79696343a17b73c151a",
"published": false
},
{
"name": "mirror-wake.png",
"bytes": 1741004,
"sha256": "0754466948d5b9e8448f9ed67c8582e19802367df6ed4002afc64f42e1763f53",
"published": false
},
{
"name": "private-mp4-frame4.png",
"bytes": 1002068,
"sha256": "08d87bc9ab0e62115b42cca7dfcf7e4db11848a0ede273034a92a69297c6775b",
"published": false
}
]
@@ -0,0 +1,15 @@
{
"baseline": "d79efb4",
"after_stop": {
"state": "stopping",
"alias": "frame",
"error": "",
"supported": true
},
"after_late_stderr": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
}
@@ -0,0 +1,70 @@
[
{
"test": "start-stop",
"cycle": 1,
"port": 15101,
"first_packet_s": 0.890392117202282,
"packet_bytes": 1316,
"stop_s": 0.06173023069277406,
"state": {
"state": "idle",
"alias": "frame",
"port": 15101,
"error": "",
"supported": true
}
},
{
"test": "start-stop",
"cycle": 2,
"port": 15102,
"first_packet_s": 0.7008698149584234,
"packet_bytes": 1316,
"stop_s": 0.0526565620675683,
"state": {
"state": "idle",
"alias": "frame",
"port": 15102,
"error": "",
"supported": true
}
},
{
"test": "start-stop",
"cycle": 3,
"port": 15103,
"first_packet_s": 0.7092029112391174,
"packet_bytes": 1316,
"stop_s": 0.052990387193858624,
"state": {
"state": "idle",
"alias": "frame",
"port": 15103,
"error": "",
"supported": true
}
},
{
"test": "stalled-ssh",
"ssh_pid": 3025339,
"elapsed_s": 15.008651106152683,
"state": {
"state": "failed",
"alias": "frame",
"port": 15105,
"error": "OBS source stopped (1). The Frame OBS video stopped for 10 seconds",
"supported": true
},
"helper_gone": true,
"ssh_gone": true
},
{
"test": "restart-after-stall",
"first_packet_s": 0.6910299891605973
},
{
"test": "quit-streaming",
"checked_after_s": 2.020789209753275,
"surviving_helpers": []
}
]
@@ -0,0 +1,46 @@
import ctypes as C, json, math, os, signal, struct, subprocess, sys, threading, time
# Put frame_media_player.py and its two imports beside this helper on the Frame.
sys.path.insert(0, os.path.dirname(__file__))
from frame_media_player import Overlay, OverlayBusy
os.environ['XDG_RUNTIME_DIR']='/run/user/1000'
class Spec(C.Structure):
_fields_=[('format',C.c_int),('rate',C.c_uint32),('channels',C.c_uint8)]
class Attr(C.Structure):
_fields_=[(k,C.c_uint32) for k in ('maxlength','tlength','prebuf','minreq','fragsize')]
p=C.CDLL('libpulse-simple.so.0'); p.pa_simple_new.restype=C.c_void_p
p.pa_simple_new.argtypes=[C.c_char_p,C.c_char_p,C.c_int,C.c_char_p,C.c_char_p,C.POINTER(Spec),C.c_void_p,C.POINTER(Attr),C.POINTER(C.c_int)]
p.pa_simple_write.argtypes=[C.c_void_p,C.c_void_p,C.c_size_t,C.POINTER(C.c_int)]
p.pa_simple_get_latency.argtypes=[C.c_void_p,C.POINTER(C.c_int)];p.pa_simple_get_latency.restype=C.c_uint64
p.pa_simple_free.argtypes=[C.c_void_p]
err=C.c_int(); spec=Spec(3,48000,2); attr=Attr(0xffffffff,9600,0,1920,0xffffffff)
stream=p.pa_simple_new(None,b'OBS validation',1,None,b'aligned marker',C.byref(spec),None,C.byref(attr),C.byref(err))
if not stream: raise RuntimeError('pulse '+str(err.value))
vr=Overlay(); handle=vr.create('framecontrol.obs.validation.aacapv3evh',2,1.5,order=100)
quit=threading.Event(); events=[]
signal.signal(signal.SIGTERM,lambda *_:quit.set())
def wake():
while not quit.is_set():
subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--handlewakeup'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
quit.wait(1)
threading.Thread(target=wake,daemon=True).start()
try:
previous=False
for i in range(19000):
if quit.is_set():break
tone= i>=500 and (i-500)%500<20
block=b''.join(struct.pack('<hh',int(5000*math.sin(2*math.pi*1000*(i*480+j)/48000)) if tone else 0,int(5000*math.sin(2*math.pi*1000*(i*480+j)/48000)) if tone else 0) for j in range(480))
b=C.create_string_buffer(block)
if p.pa_simple_write(stream,b,len(block),C.byref(err)):raise RuntimeError('write '+str(err.value))
if tone != previous or i==0:
latency=p.pa_simple_get_latency(stream,C.byref(err))/1e6
expected=time.monotonic()+latency-.01
time.sleep(max(0,expected-time.monotonic()))
before=time.monotonic()
try:vr.pixels(handle,(b'\xff\xff\xff\xff' if tone else b'\x00\x00\x00\xff')*64*64,64,64)
except OverlayBusy:continue
after=time.monotonic()
event={'tone':tone,'index':i,'expected_audio':expected,'visual_submit':before,'visual_return':after,'submit_minus_audio_ms':(before-expected)*1000,'pulse_latency_ms':latency*1000}
print(json.dumps(event),flush=True); events.append(event)
previous=tone
finally:
quit.set();vr.close();p.pa_simple_free(stream)
@@ -0,0 +1,10 @@
{
"alive_without_receiver": true,
"platform": "win32",
"has_udp_connreset": false,
"packet_bytes": 1316,
"ssh_pid": 2668,
"stop_seconds": 0.0,
"helper_exit": 0,
"ssh_child_alive": false
}
+43
View File
@@ -0,0 +1,43 @@
{
"checkedAt": "2026-10-25T06:03:45.387452+00:00",
"method": "Read-only Python probe over the existing frame SSH alias; no device changes",
"baselineCommit": "d79efb4730becc781f9a641cc36cb27f6481667c",
"deckyStatus": {
"arch": "aarch64",
"frameOS": "0.5.6",
"variant": "vr",
"build": "20261022.6153909",
"cef": true,
"managed": false,
"running": false,
"version": null,
"upstreamInstall": false,
"plugins": [],
"upstreamPlugins": []
},
"platform": {
"userServiceManager": true,
"rootMountOptions": "rw,relatime,ssd,discard=async,space_cache=v2,subvolid=5,subvol=/",
"homeMountOptions": "rw,relatime",
"homebrewExists": false,
"cefMarkerExists": false,
"cefTargetTitles": [
"SteamVR - valve.steam.gamepadui.frame.menu.53930005 (53930005)",
"SteamVR - valve.steam.gamepadui.bar (53930000)",
"SteamVR",
"SteamVR - Notifications",
"SteamVR - valve.steam.gamepadui.floatingfooter (53930004)",
"SteamVR - valve.steam.gamepadui.volumelevel (53930003)",
"SteamVR - valve.steam.gamepadui.tooltip.53930002 (53930002)",
"SteamVR - valve.steam.gamepadui.barpopup.53930001 (53930001)",
"SteamVR - Keyboard",
"data:text/html,&lt;body&gt;&lt;%2Fbody&gt;",
"SharedJSContext"
]
},
"limits": [
"No Decky install/update/uninstall repeated in this retry",
"No OS update or reboot performed",
"No wearer-visible menu or controller input verified"
]
}
+91
View File
@@ -0,0 +1,91 @@
# Round 2 retry, 25 October 2026
The provider retry completed four further fixes, each in an isolated child
worktree with its own issue, branch, PR, tests, docs and Announcement draft.
The coordinator reviewed each patch, independently ran focused tests, verified
its exact head and complete CI, merged it, then read back GitHub's merged state.
PR #113 was untouched. No social announcements were posted.
## Merged fixes
| Change | PR | Merge commit | Coordinator focused tests | Final CI |
| --- | --- | --- | --- | --- |
| Skip linked backup ancestors and recheck before packing | [#215](https://github.com/saphid/frame-control/pull/215) | `da34d94` | 34 passed | 11 checks passed at `8cc03c8` |
| Clean orphan smoke metadata/shortcuts; POSIX runner without zsh | [#216](https://github.com/saphid/frame-control/pull/216) | `d5fa8fe` | 17 passed | 10 checks passed at `a5daa4a` |
| Separate Steam clips by headset/account/full session ID | [#217](https://github.com/saphid/frame-control/pull/217) | `1255ef2` | 62 passed | 10 checks passed at `7dfe777` |
| Retry failed automatic copies and bind listings to the captured headset | [#218](https://github.com/saphid/frame-control/pull/218) | `fdd60d3` | 33 passed | 10 checks passed at `af2bb17` |
Two initial CI fixture failures were fixed before merging: macOS temporary-path
aliases needed a resolved working directory in the wrapper test; Windows
needed explicit UTF-8 when reading the Settings test's HTML fixture.
Concurrent main changes #208/#209/#210 were preserved. Changelog conflicts
retained both entries. No history was rewritten.
## Integrated validation
At main `1255ef2`, after all four merges:
- `python3 -m unittest discover -s tests`: 1,559 tests, 16 skipped, passed in
157.335 seconds. Loopback networking was enabled for local HTTP fixtures.
- `node --test app/test/*.test.js site/test/*.test.mjs`: 59 tests passed.
- Worker backup validation also passed both Docker recovery-kit E2E tests in
an isolated Compose project, then removed its containers, volumes and network.
- Local proof logs: `/tmp/frame-round2-integrated-tests.log` and
`/tmp/frame-round2-integrated-node.log`. Individual focused logs use
`/tmp/frame-round2-{backup,smoke,clips,autosync}-review.log`.
## Real Frame smoke
The current host is lxso2; its existing direct `ssh frame` alias works.
Another OBS task initially held `/tmp/frame-test.lock`, so only read-only
checks were done until it released the lock. This coordinator then acquired it
atomically and labeled it `frame-round2-vdw4yh1xv8-20261025`.
The merged `scripts/frame-smoke.sh` ran directly without zsh at 06:38:37 UTC.
All 13 steps passed on SteamOS 0.5.6, BUILD_ID `20261022.6153909`. The ARM64
and Proton titles ran. The x86-64 Linux title reported its runtime missing,
an explicitly accepted smoke outcome rather than a successful execution.
Installs/removals succeeded; the Windows title's remove
step stopped five test-owned PIDs. Both final process sweeps were empty.
Independent readback found no reserved title folders or metadata, SteamVR's
PID was unchanged, and the three observed gamescope focus properties matched
before/after. Battery went from 99% Charging to 100% Full. Only this task's
lock was removed, and its absence was verified.
[Sanitized structured proof](round2-smoke-2026-10-25.json). Raw local results:
`/tmp/frame-round2-smoke-results/2026-10-25T063837Z-20261022.6153909.json`;
console log `/tmp/frame-round2-headset-smoke.log`; state readbacks
`/tmp/frame-round2-smoke-before.txt` and `/tmp/frame-round2-smoke-after.txt`.
The headset was unworn. Visible rendering/controller input was not verified.
No pairing, password/security change, reboot or factory reset was performed.
The new backup/clip/auto-sync paths have local fixtures and cross-platform CI;
this smoke does not establish a new real-data backup/clip/UI test. Synthetic
orphan metadata was not seeded on the real device. Backup path checks retain
a documented race with concurrent filesystem changes.
## Prior round-two work reconciled
The failed coordinator checkout was still at v0.4.7. It was first updated to
main `d79efb4`, already v0.4.30. Earlier PRs had completed clipboard testing
(#124), original smoke process cleanup (#126), Windows VM capture (#129),
battery health (#125), setup doctor (#127), and read-only storage (#128).
Later evidence already covered real clip export (#147/#149/#154/#156) and
Return to Game Mode (#143/#157). These remain earlier evidence, not tests
performed by this retry. Physical Windows GPU/input and current physical
macOS checks remain incomplete; see the current support matrix.
Decision: fix reproduced data/cleanup/retry defects rather than automate game
moves without a verified transaction API or promise unverified phone-background
sync. Prepare v0.4.31; publication still requires its Linux AppImage install test.
## Delegation provenance
All implementations completed. Task IDs use prefix
`node:delegated-task:command%3Amcp%3Ad9bc35d8-0e9c-42c2-b798-25bd8af94d81%3Adelegate-task%3A`
followed by one of:
- `frame-r2-retry-smoke-orphans-20261025-r1`
- `frame-r2-backup-symlink-20261025-r1`
- `frame-r2-clip-collision-20261025-r1`
- `frame-r2-autosync-retry-20261025-r1`
+123
View File
@@ -0,0 +1,123 @@
{
"source_commit": "1255ef2bbf2e2e5fd86f8b984b592bed56f20780",
"started": "2026-10-25T06:38:37Z",
"os_release": {
"VERSION_ID": "0.5.6",
"VARIANT_ID": "vr",
"BUILD_ID": "20261022.6153909"
},
"passed": 13,
"failed": 0,
"paired": false,
"devkit_utils_there_before": true,
"battery_before": {
"capacity": 99,
"statuses": [
"Charging",
"Charging"
]
},
"battery_after": {
"capacity": 100,
"statuses": [
"Full",
"Full"
]
},
"steamvr_pid_before": 124586,
"steamvr_pid_after": 124586,
"observed_focus_properties_unchanged": true,
"reserved_files_and_processes_absent_after": true,
"own_shared_lock_released": true,
"headset_worn": false,
"steps": [
{
"step": "cleanup: leftovers from earlier runs",
"ok": true,
"seconds": 7.8,
"stopped_process_counts": [
0,
0
]
},
{
"step": "devkit service: properties.json",
"ok": true,
"seconds": 0.0
},
{
"step": "status",
"ok": true,
"seconds": 0.6
},
{
"step": "arm64: install",
"ok": true,
"seconds": 7.7
},
{
"step": "arm64: launch",
"ok": true,
"seconds": 3.3,
"outcome": "running"
},
{
"step": "arm64: remove",
"ok": true,
"seconds": 6.4,
"stopped_process_counts": [
2,
0
]
},
{
"step": "x86_64: install",
"ok": true,
"seconds": 7.6
},
{
"step": "x86_64: launch",
"ok": true,
"seconds": 3.2,
"outcome": "runtime missing"
},
{
"step": "x86_64: remove",
"ok": true,
"seconds": 6.2,
"stopped_process_counts": [
0,
0
]
},
{
"step": "exe: install",
"ok": true,
"seconds": 7.5
},
{
"step": "exe: launch",
"ok": true,
"seconds": 3.3,
"outcome": "running"
},
{
"step": "exe: remove",
"ok": true,
"seconds": 6.5,
"stopped_process_counts": [
5,
0
]
},
{
"step": "cleanup: everything this run installed",
"ok": true,
"seconds": 7.4,
"stopped_process_counts": [
0,
0
]
}
]
}
@@ -0,0 +1,25 @@
# Screenshot Save on a real Frame, 25 October 2026
After PR #192 merged, Frame Control's production `list_shots()` and
`save_shots()` paths were run from lxso2 against the real Frame. The test held
the shared `/tmp/frame-test.lock` with an owner marker and removed only its own
lock. A separate locked check earlier in this round found SteamVR at activity
level 3 (standby). No new screenshot was created.
- The Frame listed seven existing Steam screenshots.
- One existing screenshot was copied to a temporary folder on the PC. Its
local size matched the reported remote size: **294,885 bytes**. The new
destination had four components below the temporary root: headset, Steam
account, game ID and filename.
- Saving the same screenshot a second time returned zero new copies. A fresh
list then marked that screenshot as saved.
- The temporary PC folder and its private image were removed at the end. The
source screenshot on the Frame was only read. Battery was **100% Full**
before and after.
This verifies one real screenshot transfer and the repeat-save behavior. It
does **not** reproduce two different real screenshots with the same filename;
the account/game/headset collision case is covered by the fake-source test in
`tests/test_shot_copy.py`. Windows and macOS physical-PC saves were not tested.
Active passthrough in the recording mirror remains unknown because the Frame
was in standby.
@@ -0,0 +1,70 @@
# Steam clip export collision fixtures, 25 October 2026
Base: `d79efb4` (Frame Control v0.4.30). Issue:
[#212](https://github.com/saphid/frame-control/issues/212).
Host: Linux 6.8.0-139-generic, Python 3.12.3, ffmpeg 6.1.1.
## Baseline reproduction
Loaded the unmodified `ui/frame_clips.py` from `d79efb4` into a temporary
Python module and exported three fake sources into a temporary directory:
```
1/clips/clip_620/video/bg
2/clips/clip_620/video/bg
1/clips/clip_620/video/other
```
Each source's payload was its own ID. All three exports returned the same
destination, `clip_620.mp4`; listing marked all three saved. The final file
contained only the last source ID. No SSH or headset was used.
## Regression coverage
`tests/test_clips.py` exports five IDs on each of two fake headsets, including
equal clip names across accounts, nested sessions with equal leaf names,
different leaf names under one clip and the background/clip distinction.
All ten payloads survive at distinct destinations. Listing marks only the
identities already exported. A pre-existing flat MP4 retains its original
bytes and does not mark any identity saved. Normal export still returns
`message` and `file`, removes its remote temporary export and clears local
staging; a failed re-export preserves the completed file.
`tests/test_clip_copy.py` exercises the server's list and manual export
functions and its actual auto-sync callbacks with fake transport. A manual
copy is recognised by auto-sync, all other account/session identities copy,
and switching to a second headset copies its own sources without overwriting
the first. A delayed manual job refuses a changed headset with status 409.
Both local phone mode and local headset runtime reject Steam clip list,
export and auto-sync requests with status 400. The separate `Recorder` path
is unchanged.
## Validation
- `PYTHONPATH=tests python3 -m unittest test_clips test_clip_copy test_autosync test_shot_copy test_headset_actions -v`
— 66 tests passed in 17.288 seconds, no skips. Includes real ffmpeg/ffprobe
export of a generated 65-second DASH fixture and recording stop/cleanup.
- `PYTHONPATH=tests python3 -m unittest test_server -v`
— 44 tests passed in 12.315 seconds, no skips.
- `node --test site/test/*.test.mjs` — 2 tests passed.
- `python3 -m unittest discover -s tests -v` — 1,518 tests run in
181.025 seconds; OK, 16 skipped for unavailable platform/tool prerequisites.
- `python3 -m py_compile ui/frame_clips.py ui/server.py tests/test_clips.py tests/test_clip_copy.py`
and `git diff --check` — passed.
The server and full Python suites require localhost sockets and were run
outside the network sandbox. Tests import `tests/sandbox.py` to isolate app
data, SSH settings, home directories and service URLs.
## Limits and upgrade behaviour
No real headset was accessed: the shared device lock is held by other work.
This is Linux fixture and server coverage, not a fresh packaged-app or
Windows/macOS verification. Existing real-headset export evidence in the
recording guide predates this destination change.
Old flat exports cannot prove headset/account/session identity and are left
untouched. Previously remembered auto-sync IDs retain their existing
watermark/history behaviour; **Copy everything now** or manual **Export**
creates an identified copy. No release, version bump, merge or social post
is included. PR #113 is outside this task.
+81
View File
@@ -0,0 +1,81 @@
# Frame Control extensions
Extensions add a local panel to Frame Control's Tools page. They are plain HTML,
CSS and JavaScript. Frame Control discovers them from its per-user data folder;
there is no build step, server process, or headset installation. Open an extension
to run it. Closing it unloads its page.
## Install the example
Copy [`examples/extensions/headset-glance`](../examples/extensions/headset-glance)
to the `extensions` folder shown on Tools → Extensions, then select **Refresh**
and **Open Headset Glance**. The folder is normally:
- Linux: `~/.local/share/frame-control/extensions`
- macOS: `~/Library/Application Support/Frame Control/extensions`
- Windows: `%APPDATA%\Frame Control\extensions`
To remove it, close the panel and delete only its `headset-glance` folder. The
desktop app reads the folder on its computer. The iPhone app and the opt-in
[SteamVR headset panel](headset-app.md) read the folder on the Frame. To use an
extension in VR, copy its folder to
`steamos@frame:~/.local/share/frame-control/extensions/`, then select
**Extensions → Refresh extensions → Open** in the headset panel. The iframe,
read-only bridge and asset rules are the same there.
## Make an extension
Create `extensions/your-id/manifest.json` and `index.html`. IDs use lowercase
letters, digits and hyphens, start with a letter, and are at most 40 characters.
The manifest ID must match the folder name:
```json
{
"id": "your-id",
"name": "Your feature",
"version": "1.0.0",
"apiVersion": 1,
"description": "What the panel does.",
"entry": "index.html"
}
```
`apiVersion` selects the Frame Control bridge contract, independently of your
extension's own `version`. This app supports integer `1`; a missing field means
`1` for existing extensions. Unsupported versions appear as errors on Refresh,
and their assets are not served. Declare it in new extensions so a future API
change can fail clearly instead of running incompatible code. The runtime
`capabilities` reply below reports the bridge version too.
Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS,
JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder.
Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel
uses an iframe with `sandbox="allow-scripts"`; its code has an opaque origin,
cannot call Frame Control's HTTP API directly, and cannot navigate the app's
top-level window. Content Security Policy restricts scripts, images, and
`fetch` requests, but an extension can still navigate its own iframe to an
external site. Treat third-party extensions as untrusted code and review them
before installation.
The version 1 bridge uses `postMessage`. Send a request to `parent` and match
the numeric `id` in the reply. The [example JavaScript](../examples/extensions/headset-glance/main.js)
shows a complete client:
```js
parent.postMessage({type: 'frame-control:request', id: 1, action: 'status'}, '*');
// Reply: {type: 'frame-control:response', id: 1, value: {...}}
```
Supported actions:
| Action | Value |
| --- | --- |
| `capabilities` | `{apiVersion: 1, actions: ['capabilities', 'status']}` |
| `status` | `{connected, os: {version, build}, battery: {percent}, steamvr}` |
`status` is the app's latest successful status snapshot. When that reading fails,
`connected` is false and its fields are empty. No write actions are currently exposed. Future APIs
should use explicit, narrow actions and keep the iframe isolated. A third-party
extension is code you choose to run locally: inspect its files before copying
them into the folder. A Decky plugin is a separate Steam client plugin and does
not use this API.
+66
View File
@@ -0,0 +1,66 @@
# Write a plugin for Frame
There are two paths. Choose the one that matches your feature.
## Frame Control extension
Use a Frame Control extension for a panel, status view, or guide that does not
need to change the headset. Put `extension.json` and `index.html` in one folder
under `~/.local/share/frame-control/extensions`. The opt-in headset panel lists
the folder and opens it in a sandboxed iframe. The version 1 bridge has only
`capabilities` and read-only `status`. It cannot call Steam, change settings,
or run privileged code. See [the extension API](extensions.md) and its example.
## Decky plugin
Use a Decky plugin only if you need Decky's backend or Gamepad UI integration.
Build an official-format ZIP with one top-level folder, `plugin.json`, and a
frontend bundle in `dist/index.js`. A Python `main.py` backend is optional.
Test on ARM64. If you ship an ELF helper, build it for AArch64 (ELF machine
183). Avoid root, `sudo`, Steam Deck TDP controls, fixed x86 binaries, and
paths under `/etc`. Frame Control's managed loader is a normal user service;
it blocks root plugins and x86 archives. Keep data under your plugin folder or
the user's home directory, and provide a removal path.
The current Frame SteamVR shell does not expose Decky's Steam Deck Quick Access
Menu. A backend `Loaded` log only proves backend startup. Test any frontend
page, setting, notification, route, and controller input while wearing a Frame
before describing it as working in VR. Until a QAM bridge is verified, a Frame
Control extension is the simplest route for a self-contained read-only VR panel.
Frame Control now has an experimental [Framey panel host](framey-loader.md)
for API v2 Decky frontend bundles. It renders common settings controls in the
separate SteamVR dashboard panel, but Steam library and store page patches
remain unavailable. Wearer-visible use is not yet verified.
The [settings sample](../examples/framey-settings/README.md) shows an official
Decky JSX build, backend persistence, a toggle and a local toast. Framey offers
only official-store-owned plugins in its VR panel.
To test safely, use a separate Frame Control managed Decky install, verify the
ZIP digest from the official store, install one plugin at a time, and inspect
`journalctl --user -u frame-control-decky.service`. Remove only your own
plugin folder when you finish. See the current [compatibility table](decky-frame.md).
## Framey API v2 compatibility
Framey hosts a plugin's frontend in an isolated iframe in the Frame Control
SteamVR panel. The table describes the current implementation and its tests;
none of these frontend controls has passed a worn-headset check yet.
| Decky API or UI feature | Framey behavior | Verified in |
| --- | --- | --- |
| `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test |
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls | Local browser and host unit test |
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
| `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack` | Local routes inside this plugin panel only | Host unit test |
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
| `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey |
Keep routes local to the panel and give every operation a visible result. A
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
Frame Control store; only paths registered by that same plugin can navigate.
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
+143
View File
@@ -0,0 +1,143 @@
# Framey loader on Steam Frame
Framey is an experimental bridge for Decky store plugins inside Frame Control's
SteamVR dashboard panel. It is opt in. The panel and Decky backend are ordinary
user services under `~/.local/share/frame-control`; neither needs root or a
read-write SteamOS root filesystem. Decky's standard Quick Access menu still
does not appear in the separate SteamVR CEF target.
## Use in the headset
Install **Frame Control in VR** and **Decky Loader on Frame** from Frame Control
on your computer. Open the Frame Control tab in SteamVR, then choose **Decky
plugins → Open official store**. Search the catalogue, confirm an install, and
choose **Open panel** from the installed plugin list. The panel contains the
plugin's own controls and settings. Update and Remove affect only plugin
folders that Frame Control's store manager installed.
Removing a plugin keeps its Decky settings. Removing the Frame Control headset
panel or Decky backend keeps plugin folders until each is removed separately.
Frame Control's existing official-store manager downloads a published ZIP from
Decky's content-addressed CDN, verifies its store SHA-256 hash and manifest,
rejects unsafe ZIP members, path collisions, special files and root plugins,
and swaps one managed plugin
folder with rollback on failure. It rejects non-ARM64 ELF and Windows binaries.
The Decky user service restarts after a file change. Framey's panel host accepts
only API v2 bundles with `dist/index.js`; other installed plugins remain
manageable but have no Framey panel. The store manager does not currently
screen a plugin's runtime network downloads.
## What the compatibility host supplies
The plugin bundle runs in an opaque-origin iframe in the Frame Control SteamVR
panel. It receives React 18.3.1, a small `DFL` panel-control shim, and the
Decky API v2 `call`/`callable` interface. Backend methods are sent by the
authenticated parent to Steam's existing `SharedJSContext` CDP client, then
through Decky's *existing* websocket connection. Framey never opens a second
Decky websocket, because Decky allows only one frontend connection. The iframe
does not receive Frame Control's UI key. React and ReactDOM are bundled for
offline use under their MIT licenses in `ui/vendor`.
The host currently supplies sections, rows, buttons, toggles, dropdowns,
scroll containers, a modal, locale information, local panel routes and toasts,
and backend calls for the plugin that owns the panel. Steam library/store page
patches, global Steam UI components, file pickers, and privileged root methods
have no VR target. A plugin that depends on them can render only part of its UI
or show an incompatibility message. A Decky plugin's Python backend still runs as
the headset user when Decky loads it, so review third-party code before opting
in. The panel iframe can make HTTPS requests subject to ordinary browser CORS.
ProtonDB Badges' current API v2 bundle rendered its settings in a local Chromium
browser with a stubbed backend reply on 25 October 2026. Its controls accepted
pointer input. This does **not** verify SteamVR visibility, controller input,
live Decky settings persistence, or badges on Steam game pages. The last item
has no supported injection target in the Frame Control panel.
The [browser and fake Frame evidence](evidence/framey-browser-2026-10-25.md)
records exactly what was exercised.
The fake Frame integration test now writes a Decky setting through Framey's
scoped backend endpoint, reads it in a new CEF request, updates the plugin,
then checks it again. It also confirms removal blocks method calls and a
reinstall can read the retained setting. This tests the bridge and fixture;
real Decky settings persistence on a worn Frame is still unverified.
The in-headset **Open official store** view loads the full official catalogue.
Search matches names, authors, and descriptions. Filters show non-root plugins
or installed plugins, and Previous/Next buttons browse 20 results per page.
The root label is a compatibility warning; other APIs are checked only when a
plugin is opened. The store still needs the managed Decky user service running
before its Install or Update buttons are enabled. If a plugin prevents Decky
from starting, Remove remains available for Frame Control-owned plugins while
the managed service is stopped; it does not restart the service.
Install, Update, and Remove show the current job stage in the headset panel.
The store verifies each downloaded archive and the headset applies it with a
rollback if Decky cannot start. The panel remembers the current job ID for this
browser session, so **Check result** can resume polling after a navigation or
temporary connection error. Refresh the installed list before starting another
operation after an error. If Frame Control restarts, it cannot recover the old
job result. The panel clears that job ID and asks you to refresh the installed
list before making another change.
## For plugin authors
Start with the [Framey settings sample](../examples/framey-settings/README.md)
for a buildable Decky API v2 panel and Python settings backend.
Publish a normal [Decky store](https://plugins.deckbrew.xyz/) API v2 release
with `plugin.json`, a single `dist/index.js` ESM frontend entry point, and a
Python backend if needed. Give the frontend a `content` React element returned
by `definePlugin`, and build settings as panel controls from `@decky/ui`.
Backend calls through `@decky/api` reach only that plugin's own backend.
For best VR compatibility, keep the panel self-contained and use `PanelSection`,
`PanelSectionRow`, `ButtonItem`, `ToggleField`, and `DropdownItem`. Handle a
missing Steam UI route, store overlay, or `SteamClient` object gracefully.
Avoid root, remote binary downloads, Steam library patches, and assumptions
about Steam Deck hardware. Declare settings defaults so a fresh user service
can render without pre-existing files. Test your ZIP with the store validator
and the fake Frame flow before asking users to install it.
An API v2 plugin's normal `content` is its default VR panel. Framey also shows
an optional `settings` React element under a **Settings** tab and an optional
`titleView` above it. For one-tap headset controls, return
`frameyQuickActions: [{label: 'Refresh data', onClick: refreshData}]` from the
plugin initializer. Framey displays up to eight actions and reports a pending,
success, or failure state. These descriptor fields are Framey additions; Decky
ignores them. Use a plugin backend method through `@decky/api` for work needing
persistent state, and keep a quick action safe to repeat.
See the [API v2 compatibility table](frame-plugin-author.md#framey-api-v2-compatibility)
for tested behavior and remaining limits.
The [Frame Control extension API](extensions.md) is an alternative for a
Frame-specific panel. It needs only a local manifest, HTML, and the read-only
`status` bridge; it does not need Decky.
## Removal
Use **Remove** on each installed Framey plugin, then **Remove managed Decky**
and **Remove** under Frame Control in VR. These operations remove only
Frame Control-owned folders and services. Settings, the Chromium profile, and
other users' plugins are preserved. To remove one managed plugin manually,
first inspect its `.frame-control-store.json` receipt in
`~/.local/share/frame-control/decky/plugins/<folder>`, then delete only that
folder and run `systemctl --user try-restart frame-control-decky.service`.
The Decky and headset service manual removal commands are in
[Decky on Frame](decky-frame.md#opt-in-and-remove) and
[Frame Control in the headset](headset-app.md#remove).
## Fast worn-headset check
With an already installed Framey plugin and someone wearing the headset, run
`python3 scripts/framey-worn-check.py` from a paired computer. The script
atomically acquires `/tmp/frame-test.lock` before reading battery or SteamVR
activity. If another job owns the lock, it makes no sensor reads. It releases
its own lock without starting the interaction when the headset is in standby.
Within 35 seconds, use a controller
to open a managed plugin panel and touch one setting. The script records a
fresh trusted pointer event, the panel-ready event, SteamVR overlay visibility,
and a stereo screenshot with varied pixels. It deletes its own short-lived
challenge and releases only its own lock. It records the screenshot hash and
color count, then removes the private temporary image from both devices.
Pixel variety alone cannot prove the UI text was legible; a person must still
confirm the view in the headset. The script does not install or remove a plugin,
alter Steam settings, or wake the headset.
+102
View File
@@ -0,0 +1,102 @@
# Frame Control in the headset
**Experimental, Frame OS 0.5.6.** Tools → Frame Control in VR installs a local
panel in SteamVR's dashboard. It is a way to reach Frame Control while wearing
the headset, independent of Decky's Quick Access Menu. The panel starts only
after you opt in; its server binds to `127.0.0.1` and it uses a normal user
service. No `sudo`, root filesystem change, or Steam client patch is needed.
The companion copies its bundled UI over the existing trusted SSH connection.
On the Frame, `frame-control-headset.service` runs a loopback server and one
Chromium app window on gamescope. The window receives a dedicated `STEAM_GAME`
id, so SteamVR registers `valve.steam.desktopgame.2001947813` as a dashboard
panel. **Show in SteamVR** requests that dashboard tab; the wearer can select
it again from SteamVR's dashboard. Closing the panel stops its browser and
server. The service is not enabled at boot.
The page shows battery, free home storage and SteamVR status.
The **Large status screen** uses large read-only values for battery, SteamVR,
free home storage and Frame OS, with a refresh button and clear unknown values.
It is an at-a-glance view, not a crash diagnosis or a Decky plugin page.
The headset must still be worn to verify that a person can see and use it.
The page can request SteamVR's main Game Mode dashboard, list and force quit a running game after an
unsaved-progress confirmation, save an OpenVR screenshot to
`~/Pictures/FrameControl`, and record the SteamVR mirror to
`~/Videos/FrameControl`. Recordings have a 20-minute limit and use a separate
work folder from desktop Frame Control's recorder; they may have no audio if
the monitor source is unavailable. Captures made in standby can be dark.
The page also discovers extensions from
`~/.local/share/frame-control/extensions` and opens them in sandboxed iframes.
It offers the version 1 read-only `capabilities` and `status` bridge from
[the extension guide](extensions.md). Installing an extension still means
copying its folder to the Frame; the panel does not download third-party code.
The opt-in [Framey loader](framey-loader.md) hosts compatible Decky plugin
panels and settings in a separate sandboxed iframe on this SteamVR page. The
existing official Decky store manager installs and removes their code. The
frontend host is browser-tested, but wearer-visible VR use remains unverified.
The panel also lists Decky plugins and can install, update, or remove compatible
plugins from the official store when the opt-in managed loader is running.
Each change asks for confirmation. The Frame VR shell does not show Decky's
Steam Deck Quick Access Menu, so Decky plugin pages, settings, routes, and
toasts are not available in this panel yet. The [compatibility table](decky-frame.md)
separates backend loading from VR use. Authors can use the
[Frame plugin and extension guide](frame-plugin-author.md).
The per-run UI key is passed in the URL fragment, removed from the address bar after load,
and sent in the `X-Frame-UI` header. The key is absent from the HTTP request
line and server log. The usual host/origin and anti-framing checks still apply.
## Real Frame test, 2026-10-25
On Frame OS 0.5.6, build `20261022.6153909`, the opt-in install started the
user service, served `/headset`, read live `/api/status` from the browser, and
registered a mapped Chromium window with SteamVR under the dedicated overlay
key. The server rejected a request with the wrong UI key. A read-only status
probe reported the user service running and the overlay registered.
The unworn headset was in standby (`activity_level` 3). SteamVR reported the
new overlay as `not_visible` even after `ShowDashboard`. An OpenVR stereo
screenshot was a uniform dark frame. The documented temporary wake attempt
(`power.turnOffScreensTimeout` 5 → 3600, `--handlewakeup`, `ShowDashboard`)
still left the overlay `not_visible`; the setting was restored to 5 and the
dashboard hidden. The X window's `xwd` image was black too. These observations
prove panel creation and page/API execution, **not wearer-visible pixels or
laser/controller input**. A worn-headset check is still required before
describing the panel as visually verified.
The test then removed the managed service and app bundle. Its unit became
inactive, port 47813 closed, the SteamVR overlay disappeared, and the Frame's
battery was 100%. Steam and SteamVR were left running. The browser profile and
extensions folder were deliberately kept.
A second test on the same build installed the expanded panel with the
`headset-glance` example extension. The headset's authenticated loopback API
returned live status (99% battery), discovered and served the extension, and
listed no running games. The Game Mode dashboard request succeeded; an invalid
force-quit app ID was rejected with HTTP 400. Screenshot and recording actions
saved a PNG and a 3.6 MB MP4 in the headset user's folders. The recording
reported an audio source. The per-run UI key was absent from the server log.
After another wake and dashboard request, SteamVR still reported the panel
`not_visible`. No wearer-visible screenshot or controller interaction was
obtained. The test removed its example extension and captured media, then
uninstalled the service. The unit, port, bundle and overlay were absent after
removal; Steam and SteamVR remained running.
## Remove
Use **Tools → Frame Control in VR → Remove**. If the companion is unavailable,
run:
```sh
ssh frame 'systemctl --user stop frame-control-headset.service; rm -f ~/.config/systemd/user/frame-control-headset.service; systemctl --user daemon-reload; rm -rf ~/.local/share/frame-control/headset/bundle; rm -f ~/.local/share/frame-control/headset/receipt.json ~/.local/share/frame-control/headset/server.log'
```
That command removes only Frame Control's managed panel code. The Chromium
profile remains in `~/.local/share/frame-control/headset/profile` so local
extension data is not discarded. Inspect and delete that folder separately if
you no longer want it. Third-party extensions are in
`~/.local/share/frame-control/extensions` and are never removed by this tool.
+1
View File
@@ -31,6 +31,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
| **Passthrough in the mirror: yes on SteamOS 0.5.6**, build `20261022.6153909`, SteamVR 2.18.2. After `vrcmd --handlewakeup`, `/dev/video99` showed real room pixels behind panels. They also appeared in OBS's saved recording and a five-second production MP4. No camera setting was changed, and the private room images were not published. Other games/modes and Steam clips were not established. **Verified 2026-10-25.** | [OBS validation](evidence/obs-validation-2026-10-25.md) |
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
| **Testing VR apps without wearing the headset.** In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. `vrcmd` (in `/opt/steamvr/bin/linuxarm64`) settings use `section.key`: `vrcmd --set-settings-bool power.pauseCompositorOnStandby 0` and `vrcmd --set-settings-float power.turnOffScreensTimeout 3600`, then `vrcmd --handlewakeup`, keep the compositor running, and the scene app becomes visible. If it stays `visible-blurred`, the Steam dashboard is open: `SteamClient.OpenVR.VROverlay.HideDashboard()` in Steam's `SharedJSContext` (CDP on 8080) closes it. The headset view then captures with `ui/frame_vrshot.py`. Restore afterwards with `--set-settings-bool power.pauseCompositorOnStandby 1` and `--set-settings-float power.turnOffScreensTimeout 5`. The bool setter reads `true` as false, so use 1/0. A Steam launch that stalls in standby at `ShowInterstitials` or `CreatingProcess` (see `console_log.txt`) continues with `SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>")`. **Verified 2026-09-27.** | Proving VR output remotely, [webxr-chromium.md](webxr-chromium.md) |
+79 -9
View File
@@ -1,7 +1,7 @@
# Headset view in OBS
Frame Control can send SteamVR's headset mirror and the Frame's output audio to
OBS on a Windows, Linux or macOS computer. The helper uses the same `/dev/video99` video
OBS on a Linux or macOS computer. A checkout helper is also available for Windows tests. The helper uses the same `/dev/video99` video
source and default-output monitor as **Capture & Live → Record**. It sends a
local MPEG-TS stream; no port is opened to the LAN or internet.
@@ -11,7 +11,10 @@ local MPEG-TS stream; no port is opened to the LAN or internet.
**Start OBS source**. Keep Frame Control open; **Stop OBS source**, switching
headsets, or quitting the app closes its SSH stream. It uses the selected
headset and the app's bundled helper.
3. In OBS, add a **Media Source**, clear **Local File**, set **Input** to
3. Expand **OBS setup** in the app to copy the Input URL and format. The port
can be changed before Start if another program uses 15099. Use that same
port in OBS. The sender cannot identify which program is listening.
4. In OBS, add a **Media Source**, clear **Local File**, set **Input** to
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. The source
carries video and AAC audio. Use OBS's audio mixer to choose monitoring and
recording levels.
@@ -24,9 +27,12 @@ run:
```
Ctrl+C stops the checkout helper. The app closes a control pipe when you click
Stop or quit; the helper then closes its SSH child, including on Windows. On
Windows, install Python 3 and OpenSSH Client if Frame Control cannot find them.
The Windows app Start/Stop path still needs a physical Windows/Frame test.
Stop or quit; the helper then closes its SSH child, including on Windows. Encoding runs on the Frame; the computer does not need FFmpeg. The desktop
app bundles Python. A source checkout needs Python 3.8+ and OpenSSH Client.
The Windows button is hidden: native Windows/Frame streaming and OBS have
not passed an end-to-end test. A Windows 11 VM passed the helper control-pipe,
late UDP receiver and child-cleanup fixture; its real-headset bridge delivered
zero packets. These are separate results, not native Windows support.
If no first packet arrives within 20 seconds, or an active stream sends no
packet for 10 seconds, the helper closes SSH and shows an error instead of
leaving **Start OBS source** waiting or a frozen source running indefinitely.
@@ -43,18 +49,58 @@ udp://127.0.0.1:15099` in a second terminal while the helper is running.
This is the composited, undistorted one-eye **SteamVR mirror**, not a raw
passthrough camera feed. The mirror may be blank when the headset is idle or
unworn. Whether active passthrough pixels appear in it is still unverified.
unworn. **Room passthrough is verified** in this mirror and the saved OBS/MP4 outputs
on SteamOS 0.5.6 after a safe SteamVR wake. Check the picture before sharing;
other games and modes may differ.
The helper does not send a microphone. It is designed for one local OBS
receiver. **Verified 2026-10-25:** the helper bundled in the Linux AppImage
sent a real Frame stream to OBS 30's Media Source. OBS recorded 37.6 seconds
with H.264 video and AAC audio; a brief 440 Hz tone played to the Frame's
default output was present in the saved audio. The headset was in standby, so
the video showed the standby mirror. Nobody listened to OBS's monitoring output,
and audio/video sync and active passthrough remain unverified. See the
the video showed the standby mirror. That initial check did not assess listening quality, A/V sync or active
passthrough. The full validation below supersedes those gaps with measurements
and a positive passthrough result, while listening remains unverified. See the
[test evidence](evidence/obs-media-source-real-2026-10-25.md).
The [follow-up Linux/Frame transport check](evidence/obs-followup-2026-10-25.md)
measured packet continuity and a short MP4 recording on the current SteamOS
build. The headset was still in standby.
build. The newer [full OBS validation](evidence/obs-validation-2026-10-25.md)
records more than three minutes and measures audio, sync, latency and cleanup.
## SteamOS / Steam Machine PC host
Use **Desktop Mode** on the SteamOS PC. Download the Linux x86_64 AppImage to
your home folder. Make it executable and open it there; SteamOS has a
read-only system root, so these steps do not use `apt` or change `/usr`:
```sh
chmod +x ~/Downloads/Frame-Control-linux-x86_64.AppImage
~/Downloads/Frame-Control-linux-x86_64.AppImage
```
If AppImage mounting reports a FUSE error, try
`APPIMAGE_EXTRACT_AND_RUN=1 ~/Downloads/Frame-Control-linux-x86_64.AppImage`.
This extracts to writable temporary space and needs enough free disk space.
Set up the Frame's SSH connection in Frame Control, then use **Start OBS
source** before you add or activate OBS's Media Source. Install OBS as the
`com.obsproject.Studio` Flatpak through Discover or your normal user Flatpak
setup. In that OBS Media Source, clear **Local File**, set **Input** to
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. Make a short
recording and check both picture and sound before a stream. Use **Stop OBS
source** in Frame Control when done.
The OBS Flatpak's published manifest includes `--share=network`, which is
needed for its local UDP receiver. Check your installed permissions with
`flatpak info --show-permissions com.obsproject.Studio` if OBS receives no
packets. Frame Control's helper sends to loopback on this PC; it does not
require an open LAN port or an OBS plugin. The PC does not need a system
`ffmpeg` package for the helper; encoding runs on the Frame.
**Test limit:** The v0.4.27 AppImage launched on an Ubuntu 24.04 x86_64 host
under Xvfb. Its helper sent H.264 and AAC from a real Frame to a local UDP
receiver on that host. No SteamOS 3.9.5 PC, Steam Machine, Flatpak OBS runtime,
read-only-root host, or physical display was available for this check. The
SteamOS steps and Flatpak receiver therefore remain **untested on SteamOS**.
See the [test record](evidence/obs-steamos-host-2026-10-25.md).
## Known issue: game audio in OBS
@@ -72,3 +118,27 @@ The real Frame/OBS check above proved that a synthetic tone from the Frame's
output reached the Media Source recording. It did **not** use a running game,
listen to the audio, reproduce the reported Game Capture failure, or measure
audio/video sync. Real game audio while the headset is awake remains unverified.
## Check sync and recover a blank source
Make a short recording of a visible click, clap or other simultaneous marker
and sound before streaming. Open **Advanced Audio Properties** in OBS. If audio
arrives early, hide/deactivate this Media Source, add a positive **Sync Offset
(ms)** in Advanced Audio Properties, then show/reactivate it, record again,
and check the result. Changing the offset during playback did not correct
the tested OBS 30 source. Setting it before activation did: a +480 ms offset
reduced a 480 ms lead to 0 ms within the test resolution in three pulses. The 2026-10-25 Linux software-rendering test measured
about 0.4 seconds of audio lead with default OBS settings. This is a measured
setup result, not a universal offset. Do not apply it blindly to another host.
Start Frame Control's source before adding or activating the OBS Media Source.
After Stop/Start or a connection failure, hide and show the Media Source, or
reopen its properties and click OK, to reopen its UDP receiver. Keep only one
receiver on a port. If another application owns 15099, stop the relay, choose
another port in **OBS setup**, and change the OBS Input to match. Sending UDP
packets does not confirm that OBS has received or decoded them.
A stalled stream ends with an error. Wake or reconnect the Frame and press
Start again; automatic reconnection is not implemented. Standby can produce
valid but blank frames, so it does not necessarily trigger a packet timeout.
A fully suspended Frame cannot be woken over an unavailable SSH connection.
+30 -9
View File
@@ -1,10 +1,29 @@
# Open questions and on-device checks
Research as of 2026-09-25, eight days after the Frame's retail release
(2026-09-18). Most first-party detail comes from Valve's Steamworks developer
pages. Searches of Reddit and the Steam forums turned up **almost no
end-user reports** about SSH, desktop streaming, or macOS. Treat that as
"not documented yet", not "doesn't work".
The September sections below record early checks after the Frame's retail
release (2026-09-18). They are a historical test log, not a current setup
guide. For current connection steps, see [SteamOS October 2026
compatibility](steamos-2026-10-compat.md). For the charge-limit result, see
[charge-limit.md](charge-limit.md).
## Current Frame OS 0.5.6 status (2026-10-25)
- `frame.local` no longer resolves on the checked 0.5.6 beta build
(`20261022.6153909`). Avahi was active but did not publish addresses. The
configured Tailscale SSH alias worked. Frame Control can use a saved IP on
the same network after verifying the pinned SSH key, or a manual IP/SSH
alias. Same-LAN fallback scanning was **not** tested on this Tailscale route.
See the [live compatibility check](evidence/steamos-compat-live-2026-10-25.md).
- Read-only checks on that build found no SteamOS Manager
`BatteryChargeLimit1` interface or `charge_control_*` attribute. The vendor
charging switches are root-owned and managed by Valve's charger service.
No charging control was written. An 80% limit is **not supported** by Frame
Control; see [charge-limit.md](charge-limit.md) and the
[headset check](evidence/round3-headset-2026-10-25.md).
- Passthrough in the MP4 and OBS mirror is **verified on 0.5.6**: after a safe
SteamVR wake, room pixels appeared behind panels in the actual mirror and
saved outputs. Per-game behavior and Steam's own clips remain unverified;
see the [OBS validation](evidence/obs-validation-2026-10-25.md).
## Verified on device (2026-09-25)
@@ -14,7 +33,9 @@ build 20260922.6101926, kernel 6.18, aarch64):
- **1–2.** Developer Mode + Set User Password gave working SSH with no terminal
steps. `sshd` is enabled and active. The user is `steamos` (in `wheel`) and
the hostname is `frame`.
- **3.** `frame.local` resolves from the Mac; `avahi-daemon` is active.
- **3.** `frame.local` resolved from the Mac on the September build;
`avahi-daemon` was active. This result does not apply to the checked 0.5.6
beta build; see the current status above.
- **5.** `/etc/ssh/sshd_config` has `Include /etc/ssh/sshd_config.d/*.conf`.
The existing drop-ins are `20-systemd-userdb.conf` and `99-archlinux.conf`, so
`01-frame-keys-only.conf` would sort first as intended. (`--harden` itself
@@ -148,9 +169,9 @@ check in the headset:
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
no backend in the Frame's device config, and the battery node is a
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
on a headset.
`max1720x` fuel gauge with no `charge_control_end_threshold`. Read-only checks
on the real Frame confirmed this on 0.5.6 beta; no charge setting was written.
See [charge-limit.md](charge-limit.md).
## Unconfirmed claims made in these docs
+141
View File
@@ -0,0 +1,141 @@
# Plugin and modding research
Checked **2026-10-25**. Decision: keep the experimental, reversible Decky manager
and ship Frame Control's own sandboxed panel API as the supported way to add
local features. Both implementations already landed in the replacement job
while the original job was unavailable. This report consolidates their evidence
and the original research questions; it does not claim another loader was built.
## Can Decky run on Frame?
**Its ARM64 backend and Steam shared-context injection work on the tested build.
A usable Decky menu inside VR remains unverified.** Do not equate an active
service or a JavaScript plugin registration with a working headset interface.
| Requirement | Evidence | Consequence |
| --- | --- | --- |
| ARM64 Linux binary | Upstream [3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0) has `PluginLoader-arm64`; Frame reports `aarch64` | ARM is not itself a blocker. Individual plugins may still ship x86 binaries or assume Deck hardware. |
| Steam Gamepad UI and CEF | Loopback `127.0.0.1:8080/json/list` exposes `SharedJSContext` plus distinct SteamVR pages | The shared context exists; the VR shell is a separate integration target. |
| CEF enable marker | `~/.steam/steam/.cef-enable-remote-debugging` is absent, but CEF already answers | No marker or Steam launch flag change is needed on this build. |
| User service manager | `systemctl --user show-environment` succeeds | A per-user service works without installing an upstream root service. The manager is degraded by a stale failed Decky unit, although its unit file is gone. |
| Filesystem | `/` currently mounts `rw` on Btrfs, `/home` mounts `rw` | This is an observation of this device, not a stock-image guarantee. Do not disable SteamOS read-only protection or install packages in `/usr`. |
| Homebrew folder | `~/homebrew` is absent | It is an installer convention, not a preinstalled SteamOS plugin API. Frame Control uses a separate managed directory. |
| Visible menu | Earlier tests registered Decky's tab only in shared JavaScript; SteamVR had no Decky overlay; headset was in standby | QAM visibility, controller input and a real plugin's visible result remain unverified. |
The fresh [read-only probe](evidence/plugins-2026-10-25.json) records SteamOS
`0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`. It confirms CEF, architecture,
mounts, user-service availability, and absence of a running managed or upstream
Decky installation. This retry did not install anything or restart Steam.
The replacement job's initial [test record](decky-frame.md) reports:
- Decky 3.3.0 started as the normal `steamos` user and injected into
`SharedJSContext`.
- Install → status → update → status → remove → status succeeded on the Frame.
Removal left the unit, binary and port 1337 absent, while preserving user data.
- ProtonDB Badges 1.1.0 loaded as a passive plugin and appeared in Decky's
shared-context registry. Its badge or menu was not seen inside VR.
- Injected frontend code can outlive backend removal until Steam's web helper
restarts. An earlier helper-restart test cleared the globals; this retry did
not repeat it.
These are prior documented results, separate from the fresh read-only probe.
The coordinator then ran a separate, locked
[official-store compatibility audit](decky-frame.md#official-store-plugin-audit-2026-10-25)
on the same Frame OS build. Seven plugins logged backend loads. A managed
ProtonDB Badges 1.3.4 store install, update and remove also succeeded. The
headset stayed unworn, so these results still do not establish VR UI use.
## Installer and update choice
The upstream [installer at e42a3c8](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
escalates through sudo, creates `~/homebrew/plugins` and `services`, writes the
CEF marker and a system unit under `/etc/systemd/system`, and starts Decky as
root. Its architecture switch supports `aarch64|arm64`. Running this installer
is unnecessary for a compatibility check and grants more access than a panel
extension needs.
Frame Control's [manager](../ui/frame_decky.py) instead downloads the official
ARM64 asset, checks GitHub's published SHA-256 and the ELF machine, and writes
only under `~/.local/share/frame-control/decky` and `~/.config/systemd/user`.
It refuses an upstream system-service installation, requires active CEF before
install/update, and restores the previous binary, receipt and unit if applying
an update fails. Existing tests exercise that rollback with a failed service
start. These checks do not prove every possible OS or Steam update is safe.
The binary is downloaded on demand, not bundled. Plugin discovery lists the
managed directory and upstream `~/homebrew/plugins`. The later
[store manager](../ui/frame_decky_store.py) exposes official entries in the PC
app and opt-in headset panel. It verifies archive digests, blocks root and
non-ARM64 binaries, and changes only its own plugin folders. That is file and
backend support, not a working VR plugin page. Decky plugins can execute
backend code and are not isolated by Frame Control's HTML extension sandbox.
Home-directory state is expected to survive SteamOS image updates, based on
SteamOS's layout; **an actual OS update and reboot persistence test has not been
performed**. Steam frontend changes, CEF availability, libc requirements and
plugin hardware assumptions can still break Decky. On failure, remove the user
service using [the documented removal command](decky-frame.md#opt-in-and-remove);
its removal path does not require a working CEF target. Test the sequence again
on each supported OS build before expanding support claims.
## Other paths
| Path | What it enables | Current limits and choice |
| --- | --- | --- |
| Decky plugins | Steam frontend hooks, optional Python backends | Seven official-store plugins logged backend loads on ARM64. Store install/update/remove worked for ProtonDB Badges. VR pages and controller input remain unverified. Keep experimental. |
| [CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader) / [DeckThemes](https://docs.deckthemes.com/) | Stylesheets applied to Steam UI | CSS Loader 2.1.3 backend loaded on Frame. No theme was applied or visually tested. Deck themes do not establish compatibility with Frame's separate VR shell. |
| User home / SteamOS homebrew | User services, user Flatpaks, files and helpers | Use owned folders and explicit uninstall. A folder named `homebrew` supplies no discovery or security boundary by itself. No rootfs changes needed for the chosen path. |
| Android / Lepton | Sideloaded apps, game-specific content and per-app settings | Frame Control already manages APKs and ADB. Lepton runs Android containers, not Steam's CEF UI; an Android mod does not add a Steam dashboard tab. No new Android mod was tested here. See [APKs](apks.md), [VR APKs](vr-apks.md), and [game mods](mods.md). |
| Frame Control extensions | Third-party HTML/CSS/JS panels with a small read-only bridge | Shipped in [#194](https://github.com/saphid/frame-control/pull/194), with a manifest, local folder, sandbox, example and [author guide](extensions.md). No Python imports, arbitrary SSH commands or write actions are exposed. |
| Frame Control's headset panel | Runs the local tools and extensions as a SteamVR panel | Shipped in [#198](https://github.com/saphid/frame-control/pull/198), expanded in [#201](https://github.com/saphid/frame-control/pull/201). Page/API execution and uninstall were tested; wearer-visible pixels and controller interaction remain unverified. See [test record](headset-app.md). |
The extension iframe has `sandbox="allow-scripts"`, an opaque origin, restrictive
CSP and a `postMessage` bridge accepting only `capabilities` and a reduced
`status` snapshot from the active frame. It is useful for local dashboards and
small tools today. It cannot replace the full Decky API. Third-party code still
needs review: an iframe may navigate itself externally, and local installation
does not establish publisher trust.
Extensions are loaded only when opened and are removed by closing their panel
and deleting their own directory. They modify no SteamOS files. The
[compatibility follow-up](https://github.com/saphid/frame-control/pull/209) adds
manifest `apiVersion` validation during discovery and asset serving, with
existing manifests defaulting to version 1. It rejects incompatible panels
even when requested through a direct asset URL.
## Licences: separate download versus bundling
Decky's [licence](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/LICENSE)
is GPL version 2. Frame Control is MIT-licensed. The integration contains its
own service-management code and downloads the upstream executable as a separate
program. It does not incorporate Decky source or ship its binary in Frame
Control installers. Keep the upstream licence/source links visible to users
who opt in; installing a separate GPL program does not by itself relicense
Frame Control's independent code.
If a future release bundles or mirrors Decky binaries, the distributor must
preserve copyright/licence notices and satisfy GPLv2's corresponding-source
requirements for the exact binary, including modifications and build scripts
as applicable. A link to an evolving default branch is not a substitute for
meeting those distribution obligations. Mere aggregation and deriving or
linking a combined work are different questions; reassess the boundary if
Decky code is incorporated. Each plugin, theme and bundled dependency has its
own licence and assets to check independently. The existing
[third-party notices](../THIRD_PARTY_NOTICES.md) describe software already
distributed with Frame Control.
## Next checks
1. On a worn Frame, establish an accessible Decky menu or implement an adapter
for the VR shell. Verify a plugin's visible result and controller navigation
before describing store-installed plugins as usable inside VR.
2. Verify the Frame Control panel and example extension visually, including
opening, refreshing and closing with the controllers.
3. Test a normal Steam restart, reboot and SteamOS update with a disposable
managed install; confirm removal still works with CEF unavailable. Retain
the experimental label until those checks pass.
The release coordinator owns those ongoing headset-integration checks. This
retry leaves the headset untouched and hands over the research and compatibility
follow-up instead of running concurrent loader experiments.
+62 -17
View File
@@ -52,28 +52,49 @@ Recording and **Live** can run together; both read the same device.
### Does Record include passthrough?
**Record captures SteamVR's `system.HeadsetView` mirror from `/dev/video99`.**
Frame Control does not open the headset's passthrough cameras or add a separate
camera layer to the MP4. If SteamVR puts passthrough into that mirror, it will
appear in the recording; Frame Control cannot force or promise that result.
Steam's own **Steam clips** use a separate game recording path and may differ.
**Yes, the MP4 recording and OBS mirror can include room passthrough.** On
25 October 2026, SteamOS 0.5.6 build `20261022.6153909`, a safe SteamVR wake
produced real room pixels behind panels in `/dev/video99`. Room pixels also
appeared in decoded frames of both the OBS recording and a five-second MP4
made with the packaged production recorder. The private room images are not
published. See the [test record](evidence/obs-validation-2026-10-25.md).
On 25 October 2026, the Frame was in standby. A frame captured from
`/dev/video99` was black, as expected in standby, so this check **did not
establish whether active passthrough appears in the mirror**. A wearer-visible
passthrough session and its simultaneous mirror frame are still needed for a
yes-or-no answer about passthrough pixels. See the
[live compatibility evidence](evidence/steamos-compat-live-2026-10-25.md).
Both paths read SteamVR's `system.HeadsetView` mirror. Frame Control does not
open raw passthrough cameras or add a camera layer. The observed mirror includes
SteamVR's composited room and panels; it is not limited to a game's image.
This does not promise that every game or passthrough mode appears the same way.
The mirror can be blank in standby. Steam's own **Steam clips** use a separate
game recording path and were not checked for passthrough in this test.
## Steam clips
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
clips and background recordings, newest first, with the game, length and size.
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
**Export** joins one into a single `.mp4` under `~/Videos/SteamFrame`, named after
its clip folder (for example `clip_620_20261020_202134.mp4`), however long it is.
Steam's own export is reported to stop at about 60 seconds. Exports run in the
background; ones already on your computer are marked.
Each export keeps its headset, Steam account and complete recording session ID:
```
~/Videos/SteamFrame/headset-<id>/<account>/clips/<clip>/video/<session>/<clip>.mp4
~/Videos/SteamFrame/headset-<id>/<account>/video/<session>/<session>.mp4
```
The headset folder uses the same short device-identity hash as screenshots.
All folders in the Steam clip ID are kept, including nested sessions, so equal
clip names across accounts, headsets or sessions keep separate files. Manual
**Export** and automatic copying use the same path and saved marker. Exporting
the same identified session again replaces only that session's export.
Existing exports in the old flat `~/Videos/SteamFrame/<clip>.mp4` layout stay
untouched. Their source identity cannot be proved, so they do not mark a clip
as saved. Export again or use **Copy everything now** to make an identified
copy. Automatic copying still remembers IDs copied before the upgrade; use
**Copy everything now** to copy those again. This destination change has local
fixture coverage; it has not been tested on a real headset.
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
recording session.
@@ -107,6 +128,18 @@ about once a minute and copies what's new with the same code as **Save** and
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
`~/Videos/SteamFrame`.
Each newly saved screenshot lives under
`~/Pictures/SteamFrame/headset-<id>/<Steam account>/<game ID>/<filename>`.
The headset folder uses a short hash of Frame Control's device identity.
Steam can use the same screenshot filename for two games or accounts; these
folders keep both originals. Existing screenshots in the old flat
`~/Pictures/SteamFrame/<filename>` layout stay where they are. They cannot be
matched reliably to one headset and game, so **Copy everything now** can create
a new, correctly identified copy. It never overwrites the old file.
One real Frame screenshot was copied through this path on 25 October 2026;
see the [test record](evidence/screenshot-copy-2026-10-25.md). The collision
case was tested with separate fake sources, not two real same-name screenshots.
- **Only what's new.** It copies what you take after turning it on, so the
shots and clips already on the headset don't all arrive at once. **Copy
everything now** copies everything that isn't on this computer yet, once.
@@ -125,18 +158,29 @@ about once a minute and copies what's new with the same code as **Save** and
- **When this computer is full** (under 512 MB free, or a copy runs out of
space) it pauses and says so in Settings and above the lists. Free some space
and press **Resume**.
- **With several headsets** it copies from the one you're connected to.
- **When one file keeps failing** it tries at most three times, then leaves
the problem visible in Settings. Fix the source problem and press **Resume**
to allow another three attempts without restarting Frame Control. **Copy
everything now** also resets failed attempts and includes older files.
Resume keeps the history of successful copies, so it does not recopy them.
- **With several headsets** each check and copy belongs to the headset selected
when the check began. Switching between lists or copies stops that pass;
another headset's saved files cannot become part of its copy history.
Settings shows when it last checked, how many files it last copied and when,
and the last problem it had. The screenshot and clip lists say when it's on.
Retry and switch handling have offline regression coverage with fake sources;
see the [test record](evidence/autosync-reliability-2026-10-25.md).
## For developers
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
copying, in `ui/frame_autosync.py`, does):
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
computer's side; the server passes how to reach the Frame.
- `list_clips(run_frame, device_id=...)` and
`export_clip(id, run_frame, run, fetch, device_id=...)` are the computer's
side; the server passes how to reach the Frame and its pinned device identity.
Both use `clip_local_path(id, device_id, folder=None)` for the destination.
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
run as `python3 - list` / `python3 - export ID` with the file on stdin.
- `Recorder` runs one recording from Start to the copy.
@@ -159,7 +203,8 @@ The API, with the same guards as every other request:
They aren't available when the server runs on the Frame itself (the iPhone
app), since they save to a computer.
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
Tests: `tests/test_clips.py`, `tests/test_clip_copy.py` for server destinations
and collision coverage, and `tests/test_autosync.py` for automatic
copying (with a fake headset). They run the recording's shell under bash with a
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
+1 -1
View File
@@ -17,4 +17,4 @@ Frame Control sends the Frame's **output audio**, not its microphone. Discord
audio routing differs by computer and requires a separate virtual audio device
if you want game sound in the call. This guide has not been verified in a real
Discord call. The SteamVR mirror can be blank while the headset is idle, and
active passthrough in that mirror remains unverified.
the mirror can include real room passthrough behind panels. Check the scene before sharing.
+43 -4
View File
@@ -12,6 +12,21 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
## Unit tests
Automatic screenshot and clip copying has offline coverage in
`tests/test_autosync.py`:
```sh
python3 -m unittest discover -s tests -p test_autosync.py -v
```
Fake sources exercise the three-attempt limit, visible exhausted errors,
Resume/Copy everything now recovery, successful-copy deduplication, disk-full
pauses and headset changes around lists and transfers. Server tests use the
production `working(meant)` reservation and listing/copy adapters; a Node test
executes the Settings renderer to check that Resume is available after an error.
These tests use no headset. See the
[auto-sync reliability test record](evidence/autosync-reliability-2026-10-25.md).
The gamescope keyboard/trackpad route has focused suites in `test_input.py`,
`test_touch.py` and `test_service_regressions.py`. They cover pad translation,
ASCII/modifier keys, Unicode queuing/retries, stale panel targets, release
@@ -188,6 +203,11 @@ 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
```
The wrapper uses POSIX `sh` and Python 3 on macOS or Linux; zsh is not required.
It resolves the checkout path independently of the current working directory.
`/path/to/checkout/scripts/frame-smoke.sh --help` is safe to run offline and
does not contact the headset.
It checks `properties.json` and the status, then installs, launches and
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
@@ -208,6 +228,14 @@ isn't reachable.
Smoke process cleanup runs before and after title removal and during the
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
Each leftover sweep checks all six IDs, even if no title directory or install
record remains. It removes their exact `-argv.json`, `-env.json`,
`-settings.json` and `-framecontrol.json` files, syncs Steam's shortcuts, then
syncs again to verify that none of those IDs is still registered. Missing
devkit utilities are copied before syncing and removed at the end if they
were absent before the run. Metadata or shortcuts that remain, or a reported
sync failure, fail cleanup. Similarly named titles and their metadata are
outside the deletion boundary.
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
an exact title directory in `/proc` cwd/exe or an individual absolute command
argument; similarly named folders and shell command substrings do not count.
@@ -231,12 +259,23 @@ The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
remove step stopped five test-owned wrapper/Proton PIDs before removing the
title; the following process sweep found none. The final cleanup found no
test titles or processes, the test desktop was closed, and the battery
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
the same entry point instead.
remained at 99%. That historical run used the Python entry point directly;
the current POSIX wrapper has local offline coverage on a PATH without zsh.
The current runner and all-six-ID cleanup sweeps passed another 13-step real
Frame run on 25 October, build `20261022.6153909`, at source commit `1255ef2`.
The Windows title's remove step stopped five test-owned Proton/wrapper PIDs;
the final sweep and independent readback found no reserved files or processes.
SteamVR stayed running, observed focus properties matched before/after, and
this task's shared lock was released. The headset was unworn. This run did
not seed synthetic orphan metadata or shortcuts; those cases use local
fixtures. [Sanitized results](evidence/round2-smoke-2026-10-25.json).
The local regression suite runs real Linux processes in temporary fake title
directories (including deleted folders and a TERM-resistant wrapper); it checks
exit, escalation and survival of unrelated processes without SSH or a headset:
exit, escalation and survival of unrelated processes. Local filesystem commands
with mocked SSH/Steam also cover orphan metadata, shortcut-only leftovers,
sync failures and exact ID boundaries. Wrapper tests check offline help,
argument forwarding and exit status from another working directory. None of
these local tests contacts a headset:
```sh
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
+16
View File
@@ -25,3 +25,19 @@ uses the cable. The Wireless Adapter and Steam streaming can follow a different
path. No cable is attached to the Linux test host, so this exact UI has only
synthetic route/SSH tests. Windows and Linux physical USB-C behavior, and a
fresh wired readback on the current Frame OS, remain unverified.
## If SSH works but the game stream stops
If the stream fails on USB, Wi-Fi and the Wireless Adapter, but this doctor
reports a working SSH route, check SteamVR on the **PC**. Frame Control's
`frame.local` reconnect only restores its headset management connection. It
does not restart PC SteamVR or repair the game stream. On a Windows or Linux
PC, save your work and close the VR game. Exit SteamVR from Steam on that PC,
then start it again. This can show whether a PC SteamVR session is stuck; it
does not fix the cause of repeated drops. SteamVR on macOS is not a supported
PC streaming host.
Frame Control has no verified cross-platform command that safely stops only
the intended PC SteamVR session. A forced restart could close a running game
and lose unsaved progress. For that reason the app gives manual steps and does
not add a one-click restart button.
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
<title>Headset Glance</title>
<main>
<h1>Headset Glance</h1>
<p id="summary">Reading the headset status…</p>
<button id="refresh">Refresh</button>
</main>
<script src="main.js"></script>
</html>
@@ -0,0 +1,29 @@
let nextId = 0;
const pending = new Map();
window.addEventListener('message', event => {
if (event.source !== parent || event.data?.type !== 'frame-control:response') return;
const resolve = pending.get(event.data.id);
if (resolve) {
pending.delete(event.data.id);
resolve(event.data.value);
}
});
function request(action) {
return new Promise(resolve => {
const id = ++nextId;
pending.set(id, resolve);
parent.postMessage({type: 'frame-control:request', id, action}, '*');
});
}
async function refresh() {
const status = await request('status');
document.getElementById('summary').textContent = status.connected
? `Frame OS ${status.os.version || 'unknown'} · Battery ${status.battery.percent ?? '?'}% · SteamVR ${status.steamvr ? 'running' : 'stopped'}`
: 'No headset status is available yet.';
}
document.getElementById('refresh').addEventListener('click', refresh);
refresh();
@@ -0,0 +1,8 @@
{
"id": "headset-glance",
"name": "Headset Glance",
"version": "1.0.0",
"apiVersion": 1,
"description": "Shows a small read-only summary of the connected Steam Frame.",
"entry": "index.html"
}
@@ -0,0 +1,4 @@
body { margin: 0; background: #101820; color: #dcdedf; font: 16px system-ui, sans-serif; }
main { padding: 24px; }
h1 { font-size: 20px; }
button { border: 0; border-radius: 4px; padding: 8px 14px; background: #1a9fff; color: #fff; cursor: pointer; }
+1
View File
@@ -0,0 +1 @@
node_modules/
+37
View File
@@ -0,0 +1,37 @@
# Framey settings sample
This is a small Decky API v2 plugin for authors. Its panel uses the official
`@decky/api`, `@decky/ui`, and `@decky/rollup` packages. A toggle calls a Python
backend method, stores one boolean under `decky.decky_SETTINGS_DIR`,
and reads it back when the panel opens. The backend rejects other keys and
values. The frontend also shows a local toast after a successful save.
## Build
From this directory, run:
```sh
pnpm install --frozen-lockfile
pnpm build
pnpm exec tsc --noEmit
```
The committed `dist/index.js` is the built frontend. For a Decky release ZIP,
put `plugin.json`, `main.py`, and `dist/index.js` inside one top-level plugin
folder. Publish it through the official Decky store if you want Frame Control's
managed installer to offer it. Framey deliberately opens only plugins that the
official store installed and marked as owned; copying this example into the
plugin directory does not enable its VR panel.
## Test and adapt
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
repository root. It checks backend persistence and evaluates the built bundle
against Framey's actual host script. The fake Frame store flow is exercised by
the repository's e2e suite. No worn-headset visibility or controller-input test
has passed for this sample yet.
The plugin needs no root access or system changes. Its settings remain after
the managed plugin folder is removed, following Decky's settings behavior.
For a production plugin, add migration and recovery handling for damaged
settings files, and test every UI path in a worn headset.
+71
View File
@@ -0,0 +1,71 @@
const manifest = {"name":"Framey Settings Sample"};
const API_VERSION = 2;
const internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;
if (!internalAPIConnection) {
throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');
}
let api;
try {
api = internalAPIConnection.connect(API_VERSION, manifest.name);
}
catch {
api = internalAPIConnection.connect(1, manifest.name);
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);
}
if (api._version != API_VERSION) {
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);
}
const callable = api.callable;
const toaster = api.toaster;
const definePlugin = (fn) => {
return (...args) => {
return fn(...args);
};
};
const getSetting = callable('get_setting');
const setSetting = callable('set_setting');
function Content() {
const [enabled, setEnabled] = SP_REACT.useState(false);
const [busy, setBusy] = SP_REACT.useState(true);
const [message, setMessage] = SP_REACT.useState('Loading setting…');
async function refresh() {
setBusy(true);
try {
const value = await getSetting('enabled', false);
setEnabled(value);
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
}
catch (error) {
setMessage(`Could not read setting: ${String(error)}`);
}
finally {
setBusy(false);
}
}
SP_REACT.useEffect(() => { void refresh(); }, []);
async function change(value) {
setBusy(true);
try {
await setSetting('enabled', value);
setEnabled(value);
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
toaster.toast({ title: 'Framey sample', body: 'Setting saved' });
}
catch (error) {
setMessage(`Could not save setting: ${String(error)}`);
}
finally {
setBusy(false);
}
}
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
}
var index = definePlugin(() => ({
name: 'Framey Settings Sample',
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
content: SP_JSX.jsx(Content, {}),
}));
export { index as default };
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => ({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WAAA,EAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,8CAAA,EAAA,GAAA,CAAA,QAAA,CAAA,6BAAA,CAAA,CAAA;AACA;AAEA,MAAA,QAAA,GAAA,GAAA,CAAA,QAAA;AAIA,MAAA,OAAA,GAAA,GAAA,CAAA,OAAA;AAQA,MAAA,YAAA,GAAA,CAAA,EAAA,KAAA;AACA,IAAA,OAAA,CAAA,GAAA,IAAA,KAAA;AACA,QAAA,OAAA,EAAA,CAAA,GAAA,IAAA,CAAA;AACA,IAAA,CAAA;AACA,CAAA;;ACnCA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AACA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AAEA,SAAA,OAAA,Line truncated
+41
View File
@@ -0,0 +1,41 @@
"""Small, unprivileged Decky backend for the Framey author example."""
import json
import os
from pathlib import Path
import tempfile
import decky
def settings_file():
# Decky provides this per-plugin path through its official Python module.
return Path(decky.decky_SETTINGS_DIR) / 'settings.json'
class Plugin:
async def get_setting(self, key: str, default=False):
if key != 'enabled':
raise ValueError('Unknown setting')
try:
stored = json.loads(settings_file().read_text())
except FileNotFoundError:
return default
return stored.get(key, default)
async def set_setting(self, key: str, value: bool):
if key != 'enabled' or type(value) is not bool:
raise ValueError('Expected an enabled boolean')
target = settings_file()
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = None
try:
with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=target.parent,
prefix='.settings-', delete=False) as output:
temporary = Path(output.name)
json.dump({'enabled': value}, output)
output.write('\n')
temporary.chmod(0o600)
os.replace(temporary, target)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return True
+20
View File
@@ -0,0 +1,20 @@
{
"name": "framey-settings-sample",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "rollup -c"
},
"dependencies": {
"@decky/api": "^1.1.3",
"@decky/ui": "^4.11.0",
"react": "^19.1.1"
},
"devDependencies": {
"@decky/rollup": "^1.0.2",
"@types/react": "^19.1.1",
"rollup": "^4.53.3",
"typescript": "^5.6.2"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "Framey Settings Sample",
"author": "Frame Control contributors",
"flags": [],
"api_version": 2
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
import deckyPlugin from '@decky/rollup';
export default deckyPlugin({});
+56
View File
@@ -0,0 +1,56 @@
import {callable, definePlugin, toaster} from '@decky/api';
import {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';
import {useEffect, useState} from 'react';
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
const setSetting = callable<[key: string, value: boolean], boolean>('set_setting');
function Content() {
const [enabled, setEnabled] = useState(false);
const [busy, setBusy] = useState(true);
const [message, setMessage] = useState('Loading setting…');
async function refresh() {
setBusy(true);
try {
const value = await getSetting('enabled', false);
setEnabled(value);
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
} catch (error) {
setMessage(`Could not read setting: ${String(error)}`);
} finally {
setBusy(false);
}
}
useEffect(() => {void refresh();}, []);
async function change(value: boolean) {
setBusy(true);
try {
await setSetting('enabled', value);
setEnabled(value);
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
toaster.toast({title: 'Framey sample', body: 'Setting saved'});
} catch (error) {
setMessage(`Could not save setting: ${String(error)}`);
} finally {
setBusy(false);
}
}
return <PanelSection title="Framey settings sample">
<PanelSectionRow>
<ToggleField label="Example setting" description="Stored by the Decky Python backend"
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
</PanelSectionRow>
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
</PanelSection>;
}
export default definePlugin(() => ({
name: 'Framey Settings Sample',
icon: <span aria-hidden="true">⚙</span>,
content: <Content />,
}));
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"outDir": "dist",
"module": "ESNext",
"target": "ES2020",
"jsx": "react-jsx",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}
+4 -3
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env zsh
#!/bin/sh
# Mac or Linux: the headset smoke test. Installs, launches and removes tiny
# test titles on the Frame (the `frame` alias) and records the results with
# its BUILD_ID under tests/smoke/results/. See docs/testing.md.
#
# Usage: scripts/frame-smoke.sh [--pair] (--pair needs you in the headset to approve)
set -euo pipefail
exec python3 "${0:A:h:h}/tests/smoke/frame_smoke.py" "$@"
set -eu
smoke_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd -P)
exec python3 "$smoke_root/tests/smoke/frame_smoke.py" "$@"
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Bounded, opt-in real Frame check. Run only while someone wears the headset.
Requires an already installed Frame Control VR panel, Decky user service, and
one Framey-managed plugin. It never installs, restarts, or removes a service.
"""
import datetime
import hashlib
import json
from pathlib import Path
import re
import secrets
import shlex
import subprocess
import sys
import time
ROOT = Path(__file__).resolve().parents[1]
SSH = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame']
LOCK = '/tmp/frame-test.lock'
def remote(command, stdin=None, timeout=15):
run = subprocess.run([*SSH, command], input=stdin, capture_output=True,
text=True, timeout=timeout)
if run.returncode:
raise RuntimeError((run.stderr or run.stdout).strip() or f'SSH exited {run.returncode}')
return run.stdout
PROBE = r'''
import json, pathlib, subprocess
power=[]
for item in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
try:
if (item.parent/'type').read_text().strip() == 'Battery':
power.append({'name':item.parent.name,'percent':int(item.read_text().strip())})
except (OSError,ValueError): pass
stats=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--stats'],capture_output=True,text=True,timeout=5)
try: activity=json.loads(stats.stdout)[0]['activity_level'] if stats.returncode == 0 else None
except (ValueError,KeyError,IndexError,TypeError): activity=None
panels=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--overlays'],capture_output=True,text=True,timeout=5)
panel=next((line.strip() for line in panels.stdout.splitlines() if 'valve.steam.desktopgame.2001947813' in line),None)
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
plugins=[]
if base.is_dir() and not base.is_symlink():
for folder in base.iterdir():
if folder.is_symlink(): continue
try:
p=json.loads((folder/'.frame-control-store.json').read_text())
manifest=json.loads((folder/'plugin.json').read_text())
if isinstance(p.get('storeId'),int): plugins.append({'id':p['storeId'],'name':manifest['name']})
except (OSError,ValueError,KeyError): pass
service=subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],capture_output=True,text=True)
print(json.dumps({'battery':power,'activity':activity,'panel':panel,
'panelService':service.returncode==0,'plugins':plugins}))
'''
OVERLAY_PROBE = r'''
import ctypes as C, json
vr=C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
vr.VR_InitInternal2.argtypes=[C.POINTER(C.c_int),C.c_int,C.c_char_p]
vr.VR_GetGenericInterface.argtypes=[C.c_char_p,C.POINTER(C.c_int)]
vr.VR_GetGenericInterface.restype=C.c_void_p
error=C.c_int()
vr.VR_InitInternal2(C.byref(error),3,None)
result={'visible':None,'dashboardVisible':None,'error':error.value}
if not error.value:
try:
table=C.cast(vr.VR_GetGenericInterface(b'FnTable:IVROverlay_028',C.byref(error)),C.POINTER(C.c_void_p))
if not error.value and table:
handle=C.c_uint64()
find=C.CFUNCTYPE(C.c_int,C.c_char_p,C.POINTER(C.c_uint64))(table[0])
if find(b'valve.steam.desktopgame.2001947813',C.byref(handle))==0:
result['visible']=bool(C.CFUNCTYPE(C.c_bool,C.c_uint64)(table[45])(handle))
result['dashboardVisible']=bool(C.CFUNCTYPE(C.c_bool)(table[68])())
finally: vr.VR_ShutdownInternal()
print(json.dumps(result))
'''
def probe():
return json.loads(remote('python3 -', stdin=PROBE))
def check_file():
code = "import pathlib;print((pathlib.Path.home()/'.local/share/frame-control/headset/worn-check.json').read_text())"
return json.loads(remote('python3 -c ' + shlex.quote(code)))
def color_count(text):
return len(set(re.findall(r'#[0-9A-Fa-f]{6,8}', text)))
def main():
result = {'time':datetime.datetime.now(datetime.timezone.utc).isoformat(), 'proof':'worn-headset check'}
token = 'framey-' + secrets.token_hex(12)
locked = False
challenge = False
try:
# Do not read even standby status until this task owns the device lock.
remote('mkdir ' + LOCK + ' && { printf %s ' + shlex.quote(token) +
' > ' + LOCK + '/owner || { rm -f ' + LOCK + '/owner; rmdir ' + LOCK + '; exit 1; }; }')
locked = True
before = probe()
result['before'] = before
if not before['battery'] or min(x['percent'] for x in before['battery']) < 20:
raise RuntimeError('Battery capacity unavailable or below 20%; no device session started')
if before['activity'] is None or before['activity'] >= 3:
raise RuntimeError('SteamVR is in standby; no worn-headset check started')
if not before['panelService'] or not before['plugins']:
raise RuntimeError('Install and open the Frame Control panel and a Framey plugin before this check')
after_lock = probe()
if after_lock['activity'] >= 3 or min(x['percent'] for x in after_lock['battery']) < 20:
raise RuntimeError('Activity or battery changed after acquiring the lock')
state = json.dumps({'nonce':token,'started':time.time()})
code = "import pathlib; p=pathlib.Path.home()/'.local/share/frame-control/headset/worn-check.json'; p.write_text(" + repr(state) + ")"
remote('python3 -c ' + shlex.quote(code))
challenge = True
print('In the worn headset: open SteamVR dashboard → Frame Control → a managed Decky plugin → Open panel. Touch a setting with the controller. Waiting up to 35 seconds.', flush=True)
deadline = time.monotonic() + 35
while time.monotonic() < deadline:
state = check_file()
if state.get('nonce') == token and state.get('panel') and state.get('input'):
break
time.sleep(1)
result['interaction'] = state
result['after'] = probe()
result['worn'] = isinstance(result['after']['activity'], int) and result['after']['activity'] < 3
result['overlay'] = json.loads(remote('python3 -', stdin=OVERLAY_PROBE, timeout=15))
result['panelVisible'] = result['overlay']['visible'] is True
installed_ids = {p['id'] for p in before['plugins']}
result['controllerInput'] = bool(state.get('input', {}).get('at', 0) > state.get('started', 0))
result['pluginPanelReady'] = bool(state.get('panel', {}).get('at', 0) > state.get('started', 0)
and state['panel'].get('pluginId') in installed_ids)
if result['panelVisible'] and result['pluginPanelReady']:
shot = json.loads(remote('python3 -', stdin=(ROOT/'ui/frame_vrshot.py').read_text(), timeout=20))['path']
local = Path('/tmp')/('framey-worn-' + token + '.png')
try:
with local.open('wb') as out:
copy = subprocess.run([*SSH, 'cat ' + shlex.quote(shot)], stdout=out, stderr=subprocess.PIPE, timeout=20)
if copy.returncode: raise RuntimeError('Could not copy the SteamVR screenshot')
result['screenshotSha256'] = hashlib.sha256(local.read_bytes()).hexdigest()
pixels = subprocess.run(['convert', str(local), '-resize', '64x64!', 'txt:-'],
capture_output=True, text=True, timeout=15)
result['screenshotColors'] = color_count(pixels.stdout) if pixels.returncode == 0 else None
finally:
local.unlink(missing_ok=True)
remote('rm -f ' + shlex.quote(shot))
result['passed'] = (result['worn'] and result['panelVisible'] and result['controllerInput'] and
result['pluginPanelReady'] and (result.get('screenshotColors') or 0) > 8)
except Exception as exc:
result['error'] = str(exc)
finally:
if challenge:
code = "import json,pathlib; p=pathlib.Path.home()/'.local/share/frame-control/headset/worn-check.json'; d=json.loads(p.read_text()); p.unlink() if d.get('nonce')==" + repr(token) + " else None"
try: remote('python3 -c ' + shlex.quote(code))
except Exception as exc: result['challengeCleanupError'] = str(exc)
if locked:
try:
remote('test "$(cat ' + LOCK + '/owner)" = ' + shlex.quote(token) +
' && rm ' + LOCK + '/owner && rmdir ' + LOCK)
result['lockReleased'] = True
except Exception as exc: result['lockCleanupError'] = str(exc)
print(json.dumps(result, indent=2))
return 0 if result.get('passed') else 1
if __name__ == '__main__':
sys.exit(main())
+37 -17
View File
@@ -5,6 +5,7 @@ OBS opens udp://127.0.0.1:15099 with input format mpegts. Nothing is bound to
the network: this process sends MPEG-TS packets to the computer's loopback.
"""
import argparse
import os
import shlex
import signal
import socket
@@ -18,6 +19,7 @@ PACKET = 188
DATAGRAM = PACKET * 7
STARTUP_TIMEOUT = 20
STREAM_STALL = 10
CHILD_STOP_TIMEOUT = 5
def remote_command(height=720, fps=30, audio=True):
@@ -58,19 +60,34 @@ def relay(source, destination, on_packet=None):
return total
def watch_control(stream, proc, stopped):
def stop_child(proc):
"""Bound cleanup even if a stalled SSH process does not handle termination."""
if proc.poll() is not None:
return
try:
proc.terminate()
proc.wait(timeout=CHILD_STOP_TIMEOUT)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
except ProcessLookupError:
pass # another shutdown path already reaped it
def watch_control(fd, proc, stopped):
"""A desktop app closes its pipe to stop SSH, including on Windows.
Windows TerminateProcess does not run Python's finally block. The pipe also
closes if the app crashes, so the SSH connection is closed in either case.
Read the descriptor directly: a blocked daemon must not hold sys.stdin's
buffered-I/O lock when a timeout makes the main thread exit.
"""
try:
stream.read(1)
os.read(fd, 1)
except OSError:
pass
stopped.set()
if proc.poll() is None:
proc.terminate()
stop_child(proc)
class StreamWatchdog:
@@ -111,8 +128,7 @@ class StreamWatchdog:
self.condition.wait(left)
else:
return
if self.proc.poll() is None:
self.proc.terminate()
stop_child(self.proc)
def main(argv=None):
@@ -129,16 +145,19 @@ def main(argv=None):
if not args.frame or args.frame.startswith('-') or any(c.isspace() for c in args.frame):
parser.error('frame must be one SSH host or alias')
command = remote_command(args.height, args.fps, not args.no_audio)
ssh = ['ssh', '-T', '-o', 'BatchMode=yes', args.frame, 'bash', '-lc', shlex.quote(command)]
ssh = ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10',
'-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2',
args.frame, 'bash', '-lc', shlex.quote(command)]
target = ('127.0.0.1', args.port)
print(f'Sending Frame view to udp://127.0.0.1:{args.port} for OBS; press Ctrl+C to stop.', file=sys.stderr)
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp:
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
options = {'creationflags': subprocess.CREATE_NO_WINDOW} if sys.platform == 'win32' else {}
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, **options)
stopped = threading.Event()
watchdog = StreamWatchdog(proc)
if args.control_stdin:
threading.Thread(target=watch_control, args=(sys.stdin.buffer, proc, stopped), daemon=True).start()
threading.Thread(target=watch_control, args=(sys.stdin.fileno(), proc, stopped), daemon=True).start()
try:
amount = relay(proc.stdout, _Sender(udp, target), watchdog.packet)
code = proc.wait(timeout=10)
@@ -155,13 +174,7 @@ def main(argv=None):
finally:
watchdog.close()
proc.stdout.close()
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
stop_child(proc)
class _Sender:
@@ -170,7 +183,14 @@ class _Sender:
self.ready = False
def send(self, data):
self.sock.sendto(data, self.target)
try:
self.sock.sendto(data, self.target)
except ConnectionResetError:
# Winsock can report a previous ICMP "port unreachable" here while
# OBS is closed. Keep sending so opening the receiver can recover.
if sys.platform != 'win32':
raise
return
if not self.ready:
self.ready = True
print('Frame Control OBS stream is ready', file=sys.stderr, flush=True)
+133
View File
@@ -0,0 +1,133 @@
"""Verified Decky-store-shaped install flow through the real HTTP server and fake Frame SSH."""
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import io
import json
import os
import shlex
import subprocess
import threading
import zipfile
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh
require() # Skip the module outside the disposable fake Frame, like its peers.
def archive(version):
raw = io.BytesIO()
with zipfile.ZipFile(raw, 'w') as bundle:
bundle.writestr('sample-panel/plugin.json', json.dumps({'name':'Sample Panel','api_version':2,'flags':[]}))
bundle.writestr('sample-panel/dist/index.js',
f"export default () => ({{content: SP_REACT.createElement('p', null, 'Sample {version}')}});")
bundle.writestr('sample-panel/README.txt', ''.join(hashlib.sha256(f'{version}-{n}'.encode()).hexdigest() for n in range(35)))
return raw.getvalue()
ARTIFACTS = {hashlib.sha256(a).hexdigest(): a for a in (archive('1.0.0'), archive('1.1.0'))}
HASHES = list(ARTIFACTS)
class StoreFixture(BaseHTTPRequestHandler):
release = 0
def log_message(self, *_):
pass
def do_GET(self):
if self.path == '/plugins':
body = json.dumps([{'id':123,'name':'Sample Panel','author':'Fixture',
'description':'A harmless panel','tags':[],
'versions':[{'name':['1.0.0','1.1.0'][self.release],
'hash':HASHES[self.release]}]}]).encode()
content_type = 'application/json'
elif self.path.startswith('/versions/') and self.path.endswith('.zip'):
body = ARTIFACTS.get(self.path.split('/')[-1][:-4])
content_type = 'application/zip'
else:
body = None
self.send_response(200 if body is not None else 404)
self.send_header('Content-Type', content_type if body is not None else 'text/plain')
self.end_headers()
self.wfile.write(body or b'not found')
class FrameyFlow(FrameTestCase):
@classmethod
def setUpClass(cls):
require()
StoreFixture.release = 0
cls.fixture = ThreadingHTTPServer(('127.0.0.1', 0), StoreFixture)
cls.thread = threading.Thread(target=cls.fixture.serve_forever, daemon=True)
cls.thread.start()
port = cls.fixture.server_port
cls.previous = {key:os.environ.get(key) for key in ('FRAMEY_STORE_URL','FRAMEY_CDN_URL')}
os.environ['FRAMEY_STORE_URL'] = f'http://127.0.0.1:{port}/plugins'
os.environ['FRAMEY_CDN_URL'] = f'http://127.0.0.1:{port}/versions/'
Server.stop()
super().setUpClass()
@classmethod
def tearDownClass(cls):
Server.stop()
cls.fixture.shutdown()
for key, value in cls.previous.items():
if value is None: os.environ.pop(key, None)
else: os.environ[key] = value
def backend(self):
code = '''import json,pathlib
h=pathlib.Path.home(); b=h/'.local/share/frame-control/decky/.frame-control'; b.mkdir(parents=True,exist_ok=True)
(b/'receipt.json').write_text(json.dumps({'managedBy':'Frame Control','version':'v3.3.0'}))
(b.parent/'plugins').mkdir(exist_ok=True)
u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=True,exist_ok=True); u.write_text('[Service]')'''
ssh('python3 -c ' + shlex.quote(code))
ssh('mkdir -p ~/.local/share/frame-control/headset/bundle/ui')
source = (ROOT/'ui/frame_steam.py').read_bytes()
copy = subprocess.run(['ssh','frame','cat > ~/.local/share/frame-control/headset/bundle/ui/frame_steam.py'],
input=source, capture_output=True, timeout=20)
self.assertEqual(copy.returncode, 0, copy.stderr)
def test_store_install_update_remove_and_guards(self):
self.backend()
status, _, _ = api('GET', '/api/decky/store', headers={'X-Frame-UI':''})
self.assertEqual(status, 403)
catalogue = ok('GET', '/api/decky/store')['plugins']
self.assertEqual((catalogue[0]['version'], catalogue[0]['sha256']), ('1.0.0', HASHES[0]))
status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':123})
self.assertEqual(status, 400) # Third-party code needs explicit acknowledgement.
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':123,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
status, _, _ = api('POST', '/api/framey/method', {'id':123,'method':'get_setting','args':[]},
headers={'X-Frame-UI':''})
self.assertEqual(status, 403)
status, _, _ = api('POST', '/api/framey/method', {'id':123,'method':'x;alert(1)','args':[]})
self.assertEqual(status, 400)
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
self.assertTrue(ok('POST', '/api/framey/method',
{'id':123,'method':'set_setting','args':['theme','light']})['result'])
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
StoreFixture.release = 1
job = finished(ok('POST', '/api/decky/plugin',
{'action':'update','id':123,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':123,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'], [])
self.assertEqual(ssh('test -e ~/.local/share/frame-control/decky/plugins/sample-panel && echo yes || echo no').strip(), 'no')
status, _, _ = api('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})
self.assertEqual(status, 400) # No method calls while the plugin is removed.
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':123,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
+10 -1
View File
@@ -3,9 +3,11 @@
Stable NRestarts=0 and inactive keep-awake are harness assumptions, not observed
device behavior. Real Frame query forms come from the historical doctor runbook
(2026-09-28, BUILD_ID 20260925.6191901). No service control is emulated here.
(2026-09-28, BUILD_ID 20260925.6191901). The Framey e2e fixture emulates only
the Decky test unit's active/start/stop answers; no real service is launched.
"""
import sys
from pathlib import Path
args = sys.argv[1:]
if args in (['--user', 'show', 'steam.service', '-p', 'NRestarts', '--value'],
@@ -13,5 +15,12 @@ if args in (['--user', 'show', 'steam.service', '-p', 'NRestarts', '--value'],
print('0')
elif args == ['--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value']:
print('inactive')
elif args in (['--user', 'is-active', 'frame-control-decky.service'],
['--user', 'is-active', '--quiet', 'frame-control-decky.service'],
['--user', 'stop', 'frame-control-decky.service'],
['--user', 'start', 'frame-control-decky.service']) and \
(Path.home()/'.config/systemd/user/frame-control-decky.service').is_file():
if args[1] == 'is-active' and '--quiet' not in args:
print('active')
else:
sys.exit(1)
@@ -25,6 +25,21 @@ function newShortcutId(steam) {
}
function build(steam) {
// Synthetic Decky API for Framey's CDP bridge test. This does not model the
// real loader or prove frontend visibility on a headset.
const deckyBackend = {async call(route, plugin, method, ...args) {
if (route !== 'loader/call_plugin_method' || plugin !== 'Sample Panel')
throw Error('unexpected Decky plugin route');
const settings = (steam.decky_settings ||= {});
const owned = (settings[plugin] ||= {});
if (method === 'get_setting')
return Object.hasOwn(owned, args[0]) ? owned[args[0]] : args[1];
if (method === 'set_setting') {
owned[args[0]] = args[1];
return true;
}
throw Error('method unavailable in fake Decky backend');
}};
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: a.app_type ?? 1,
@@ -66,6 +81,7 @@ function build(steam) {
value,
});
return {
window: {DeckyBackend: deckyBackend}, DeckyBackend: deckyBackend,
...require('./canvas_stub').surface(),
collectionStore: {
GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); },
+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface;\n})();');
assert.notEqual(testSource,source);
const states=[];
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
useState:value=>[value,next=>states.push(next)], useEffect(){},isValidElement:value=>!!value?.type};
class Element {constructor(){this.children=[];this.textContent='';this.attributes={};}
append(...children){this.children.push(...children);}setAttribute(key,value){this.attributes[key]=value;}
remove(){this.removed=true;}}
const elements=new Map();
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)};
const context=vm.createContext({window,document:{getElementById:id=>{
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()},
navigator:{language:'en'}, parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(testSource,context);
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
const jsx=window.SP_JSX.jsx('p',{id:'message',children:'Saved'},'message-key');
assert.equal(jsx.type,'p');
assert.equal(jsx.props.id,'message');
assert.equal(jsx.props.key,'message-key');
assert.deepEqual(jsx.children,['Saved']);
const fragments=window.SP_JSX.jsxs(window.SP_JSX.Fragment,{children:['one','two']});
assert.equal(fragments.type,React.Fragment);
assert.deepEqual(fragments.children,[['one','two']]);
const nodes=(tree,type)=>[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))];
let called=0;
const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{type:'title'},
frameyQuickActions:[{label:'Refresh data',onClick:async()=>{called++;return 'Fresh';}},
{label:'invalid'},...Array.from({length:9},(_,i)=>({label:`Action ${i}`,onClick:()=>{}}))]};
const tree=window.__surface({plugin});
const buttons=nodes(tree,'button');
assert.equal(buttons.length,9); // Panel, Settings, and seven valid actions inside the first eight entries.
assert.equal(nodes(tree,'nav')[0].props['aria-label'],'Plugin views');
assert.equal(nodes(tree,'section')[0].props['aria-label'],'Plugin quick actions');
assert.equal(nodes(tree,'header').length,1);
await buttons[2].props.onClick();
assert.equal(called,1);
assert.deepEqual(states.slice(-3),[0,'Refresh data…','Fresh',null].slice(-3));
assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav').length,1);
// API v2 backend calls remain request-scoped and errors return to the caller.
const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test');
const reply=api.call('get_setting','mode','default');
assert.equal(messages[0].method,'get_setting');
assert.deepEqual(Array.from(messages[0].args),['mode','default']);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:messages[0].id,result:'dark'}});
assert.equal(await reply,'dark');
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
const toast=api.toaster.toast({title:'Saved',body:'Mode updated',duration:4000});
const toastNode=elements.get('framey-toasts').children[0];
assert.equal(toastNode.attributes.role,'status');
assert.equal(toastNode.children[0].textContent,'Saved');
assert.equal(toastNode.children[1].textContent,'Mode updated');
toast.dismiss();assert.equal(toastNode.removed,true);
function LocalRoute(){return React.createElement('p',null,'Route content');}
const undo=api.routerHook.addRoute('/plugin/details',LocalRoute);
assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),true);
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
assert.equal(window.DFL.Navigation.Navigate('/decky/store'),false);
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const host=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8')
.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js',import.meta.url),'utf8');
assert.match(bundle,/SP_JSX\.jsx/);
const React={Fragment:Symbol('fragment'),Component:class {},
createElement:(type,props,...children)=>({type,props:props||{},children}),
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
const context=vm.createContext({window,document:{getElementById:()=>({})},
navigator:{language:'en'},parent:{postMessage(){}},addEventListener(){},
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(host,context);
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
vm.runInContext(bundle.replace(/export \{ index as default \};/, 'window.sample = index;'),context);
const plugin=window.sample();
assert.equal(plugin.name,'Framey Settings Sample');
assert.equal(typeof plugin.content.type,'function');
const content=plugin.content.type();
assert.equal(content.type,window.DFL.PanelSection);
assert.equal(content.props.title,'Framey settings sample');
assert.equal(content.children[0][0].type,window.DFL.PanelSectionRow);
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const html = readFileSync(new URL('../../ui/headset.html', import.meta.url), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
class Element {
constructor(id='') {this.id=id; this.value=''; this.textContent=''; this.children=[]; this.disabled=false; this.hidden=false;}
append(...nodes) {this.children.push(...nodes);}
replaceChildren(...nodes) {this.children=nodes; this.textContent='';}
scrollIntoView() {}
removeAttribute(name) {delete this[name];}
}
const elements = new Map();
elements.set('deckyStoreFilter',Object.assign(new Element('deckyStoreFilter'),{value:'all'}));
const document = {getElementById(id) {if(!elements.has(id)) elements.set(id,new Element(id)); return elements.get(id);},
createElement() {return new Element();}, hidden:false};
const session = new Map();
const ctx = vm.createContext({document, location:{hash:'',pathname:'/'},
sessionStorage:{getItem:key=>session.get(key)||'',setItem:(key,value)=>session.set(key,value),removeItem:key=>session.delete(key)},
history:{replaceState(){}}, confirm:()=>true,
window:{addEventListener(){}}, setInterval(){}, setTimeout(){},
fetch(){throw Error('No network expected');}, console});
vm.runInContext(script,ctx);
const el=id=>document.getElementById(id);
const fixtures=Array.from({length:43},(_,i)=>({id:i+1,name:`Plugin ${i+1}`,author:i===42?'Special Author':'Maker',
description:'A panel',version:'1.0',root:i===0}));
ctx.fixtures=fixtures;
vm.runInContext('deckyStore=fixtures; deckyState={running:true,plugins:[{storeId:2,version:"1.0"}]}; showDeckyStore()',ctx);
assert.equal(el('deckyStoreSummary').textContent,'43 plugins found');
assert.equal(el('deckyStoreList').children.length,20);
assert.equal(el('deckyStorePage').textContent,'Page 1 of 3');
el('deckyStoreNext').onclick();
assert.equal(el('deckyStoreList').children.length,20);
el('deckyStoreNext').onclick();
assert.equal(el('deckyStoreList').children.length,3);
assert.equal(el('deckyStoreNext').disabled,true);
el('deckySearch').value='special author'; el('deckySearch').oninput();
assert.equal(el('deckyStoreSummary').textContent,'1 plugin found');
assert.match(el('deckyStoreList').children[0].children[0].textContent,/Plugin 43/);
assert.equal(el('deckyStorePage').textContent,'Page 1 of 1');
el('deckySearch').value=''; el('deckyStoreFilter').value='installed'; el('deckyStoreFilter').onchange();
assert.equal(el('deckyStoreSummary').textContent,'1 plugin found');
assert.match(el('deckyStoreList').children[0].children[0].textContent,/Plugin 2/);
el('deckyStoreFilter').value='compatible'; el('deckyStoreFilter').onchange();
assert.equal(el('deckyStoreSummary').textContent,'42 plugins found');
el('deckySearch').value='no matching plugin'; el('deckySearch').oninput();
assert.equal(el('deckyStoreList').textContent,'No plugins match this search.');
// The page polls the actual job endpoint and keeps a separate visible result.
ctx.mockApi=async path => {
if(path==='/api/decky/plugin')return {job:'job-1'};
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
if(path==='/api/decky')return {running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2"})',ctx);
assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2');
assert.equal(el('deckyJobProgress').value,100);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
// A stopped Decky service must still offer recovery by removing its owned plugin.
ctx.mockApi=async path => path==='/api/decky' ?
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
Promise.reject(Error(`unexpected ${path}`));
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('deckyInstalled').children[0].children[2].disabled,false);
el('deckySearch').value='';el('deckyStoreFilter').value='all';
vm.runInContext('showDeckyStore()',ctx);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
ctx.mockApi=async path => {
if(path==='/api/decky/plugin')return {job:'job-2'};
if(path.startsWith('/api/job'))return {done:true,error:'archive digest mismatch'};
if(path==='/api/decky')return {running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2"})',ctx);
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
assert.equal(session.has('frameyPluginJob'),false);
// A saved job does not survive a server restart. Clear it instead of offering
// Check result forever for an ID that the new server cannot know.
session.set('frameyPluginJob',JSON.stringify({job:'old-job',action:'install',name:'Plugin 2'}));
ctx.mockApi=async path => {
if(path.startsWith('/api/job'))throw Error('no such job (the app may have restarted)');
if(path==='/api/decky')return {running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
+10 -8
View File
@@ -203,23 +203,23 @@ def cleanup(smoke):
never removed. Raises if any owned process, file or shortcut stays.
"""
problems = []
try:
ids = {t['id'] for t in frame_titles.list_titles() if t['id'] in stop_processes.IDS}
except frame_android.FrameError as e:
ids = set()
problems.append(f'could not list titles: {e}')
ids |= smoke.installed
# Directory listings and this run's install tracker cannot reveal orphan
# metadata or shortcuts. Always sweep the exact, reserved IDs; never let
# arbitrary entries in smoke.installed expand the deletion boundary.
ids = stop_processes.IDS
# Scan every reserved ID, even when a prior run removed its folder and
# shortcut but left Proton running. Stop before removing any more files.
stopped = stop_titles(stop_processes.IDS)
gone = set()
for gid in sorted(ids):
try:
metadata = ' '.join(f'~/devkit-game/{gid}-{k}.json'
for k in ('argv', 'env', 'settings', 'framecontrol'))
if ssh(f'test -d ~/devkit-game/{gid} && echo yes || true').strip() == 'yes':
frame_titles.remove(gid)
# Also when remove() stopped part-way, or only the json files are left.
ssh(f"rm -f {' '.join(f'~/devkit-game/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))}")
if ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip():
ssh(f'rm -f {metadata}')
if ssh(f'ls -d ~/devkit-game/{gid} {metadata} 2>/dev/null || true').strip():
problems.append(f'{gid} is still on the Frame')
else:
gone.add(gid)
@@ -230,6 +230,8 @@ def cleanup(smoke):
# It logs a failed sync and exits 0, so read its log; a second run lists
# what Steam still has registered, which must not include ours.
try:
# A shortcut-only leftover (or a first run) may have no utilities.
frame_titles.ensure_utils()
command = frame_titles.devkit_script('delete') + ' 2>&1'
first = ssh(command, timeout=120)
second = ssh(command, timeout=120)
+55 -3
View File
@@ -16,6 +16,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_android_import as backup
import server
from apk_fixtures import apk, manifest
from test_quest import apk_bytes
PKG = 'com.Armature.VR4'
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
@@ -66,6 +67,41 @@ class BackupTests(unittest.TestCase):
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
def test_supported_split_set_uses_manifest_base_and_existing_merge_path(self):
base = self.root / 'not-named-base.apk'
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
split = self.root / 'arm64.apk'
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
files={'lib/arm64-v8a/libgame.so': b'game'}))
plan = backup.inspect([str(split), str(base)])
self.assertEqual(plan['apk'], base)
self.assertEqual(plan['splits'], [split])
self.assertEqual(backup.public(plan)['splits'], ['arm64.apk'])
with patch.object(backup.quest, '_running', return_value=False), \
patch.object(backup.android, 'read_meta', return_value=None), \
patch.object(backup.android, 'install', return_value=META) as install:
backup.install(plan)
self.assertEqual(install.call_args.kwargs['splits'], [str(split)])
def test_split_mismatch_and_unsupported_resource_fail_before_frame_access(self):
base = self.root / 'base.apk'
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
split = self.root / 'split.apk'
with patch.object(backup.android, 'ssh') as ssh:
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=211,
files={'lib/arm64-v8a/libgame.so': b'game'}))
with self.assertRaisesRegex(backup.android.FrameError, 'version cannot be verified'):
backup.inspect([str(base), str(split)])
split.write_bytes(apk_bytes(PKG, split='config.xxhdpi', version_code=210,
files={'res/drawable/icon.png': b'png'}))
with self.assertRaisesRegex(backup.android.FrameError, 'screen-density or language resources'):
backup.inspect([str(base), str(split)])
split.write_bytes(apk_bytes(PKG, split='config.x86_64', version_code=210,
files={'lib/x86_64/libgame.so': b'game'}))
with self.assertRaisesRegex(backup.android.FrameError, 'another device'):
backup.inspect([str(base), str(split)])
ssh.assert_not_called()
def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self):
with patch.object(backup.android, 'read_meta', return_value=None):
with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'):
@@ -204,6 +240,21 @@ class BundleTests(unittest.TestCase):
self.assertTrue(all(not Path(p).exists() for p in folders))
self.assertTrue(source.exists())
def test_bundle_with_supported_split_uses_same_preview_and_cleanup(self):
source = self.bundle({
'base.apk': apk_bytes(PKG, split_required=True, version_code=210),
'config.arm64.apk': apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
files={'lib/arm64-v8a/libgame.so': b'game'}),
}, suffix='.apks')
with patch.object(backup.android, 'ssh') as ssh:
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
ssh.assert_not_called()
self.assertEqual(read['plan']['apk'], 'base.apk')
self.assertEqual(read['plan']['splits'], ['config.arm64.apk'])
folders = list(server._android_backups[read['token']]['dirs'])
server.android_backup({'action': 'discard', 'token': read['token']})
self.assertTrue(all(not Path(p).exists() for p in folders))
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
source = self.bundle()
token = server.android_backup({'action': 'begin'})['token']
@@ -221,7 +272,7 @@ class BundleTests(unittest.TestCase):
def test_reject_layouts_before_any_headset_access_and_cleanup_failed_stage(self):
cases = [
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'Split APK'),
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'exactly one base'),
({'base.apk': self.apk.read_bytes(), f'Android/data/{PKG}/save': b'private'}, 'restore'),
({'base.apk': self.apk.read_bytes(), 'main.1.org.other.game.obb': b'wrong'}, 'does not belong'),
({'base.apk': self.apk.read_bytes(), '../outside.obb': b'bad'}, 'unsafe'),
@@ -229,7 +280,7 @@ class BundleTests(unittest.TestCase):
({'base.apk': self.apk.read_bytes(), 'CON.apk': b'bad'}, 'unsafe'),
({'base.apk': self.apk.read_bytes(), 'other.exe': b'bad'}, 'Unsupported'),
({'base.apk': self.apk.read_bytes(), 'BASE.apk': b'bad'}, 'duplicate'),
({'manifest.json': b'{}'}, 'standalone APK'),
({'manifest.json': b'{}'}, 'base APK'),
]
for entries, message in cases:
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
@@ -277,7 +328,8 @@ class BundleTests(unittest.TestCase):
('x' * len('isSplitRequired')).encode('utf-16-le'))
self.apk.write_bytes(apk({'AndroidManifest.xml': data}))
with self.subTest(attrs=attrs):
with self.assertRaisesRegex(backup.android.FrameError, 'needs split APKs'):
message = 'exactly one base' if attrs.get('split') else 'needs split APKs'
with self.assertRaisesRegex(backup.android.FrameError, message):
backup.inspect([str(self.apk)])
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
+227 -2
View File
@@ -7,11 +7,13 @@ import sandbox # noqa: F401 (first: keeps tests off real data and services)
import errno
import json
import shutil
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import frame_autosync as fa # noqa: E402
@@ -35,13 +37,15 @@ class FakeFrame:
self.device, self.busy, self.down = "frame-a", False, False
self.save_error = self.export_error = None
def list_shots(self):
def list_shots(self, device):
assert device == self.device
self.calls.append("list_shots")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
return {"shots": list(self.shots)}
def list_clips(self):
def list_clips(self, device):
assert device == self.device
self.calls.append("list_clips")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
@@ -263,6 +267,134 @@ class AutoSyncTest(unittest.TestCase):
for _ in range(fa.TRIES + 2):
self.sync.tick()
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
self.assertIn("ffmpeg couldn't join", self.sync.state()["error"] or "")
self.assertIn("Resume", self.sync.state()["error"] or "")
def test_explicit_retry_recovers_without_recopying_successful_items(self):
for kind in ("shots", "clips"):
for action in ("resume", "copy_all"):
with self.subTest(kind=kind, action=action):
self.frame = FakeFrame()
f = self.frame
sync = self.make()
sync.update({"screenshots": True, "clips": True})
f.shots, f.clips = [shot(1, NOW + 5)], [clip(1, NOW + 5)]
error_attr = "save_error" if kind == "shots" else "export_error"
copy_call = "save_shots" if kind == "shots" else "export_clip"
setattr(f, error_attr, RuntimeError("source not ready"))
for _ in range(fa.TRIES + 2):
sync.tick()
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
self.assertIn("source not ready", sync.state()["error"] or "")
setattr(f, error_attr, None)
sync.tick() # recovery alone must not create unlimited automatic retries
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
getattr(sync, action)()
sync.tick()
self.assertEqual(f.saved, [("frame-a", [shot(1, 0)["id"]])])
self.assertEqual(f.exported, [("frame-a", clip(1, 0)["id"])])
self.assertIsNone(sync.state()["error"])
# Resume preserves the per-headset ledger even if a local file was deleted.
for item in f.shots + f.clips:
item["saved"] = False
sync.resume()
sync.tick()
self.assertEqual((len(f.saved), len(f.exported)), (1, 1))
self.sync.path.unlink(missing_ok=True)
def test_explicit_retry_is_still_bounded(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("bad source")
for action in ("resume", "copy_all"):
for _ in range(fa.TRIES + 2):
self.sync.tick()
before = self.frame.calls.count("export_clip")
getattr(self.sync, action)()
for _ in range(fa.TRIES + 2):
self.sync.tick()
self.assertEqual(self.frame.calls.count("export_clip") - before, fa.TRIES)
self.assertIn("bad source", self.sync.state()["error"] or "")
def test_failed_item_saved_by_hand_clears_error(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("bad source")
for _ in range(fa.TRIES + 1):
self.sync.tick()
self.frame.clips[0]["saved"] = True
self.sync.tick()
self.assertIsNone(self.sync.state()["error"])
self.assertIn(clip(1, 0)["id"], self.sync._synced("frame-a", "clips"))
def test_switch_after_capture_does_not_list_the_new_headset(self):
self.sync.update({"screenshots": True, "clips": True})
def switched():
self.frame.device = "frame-b"
return False
self.sync.busy = switched # tick has already captured frame-a
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual(self.frame.calls, [])
def test_list_callbacks_receive_the_captured_device(self):
self.sync.update({"screenshots": True, "clips": True})
self.sync.list_shots = mock.Mock(return_value={"shots": []})
self.sync.list_clips = mock.Mock(return_value={"clips": []})
self.sync.tick()
self.sync.list_shots.assert_called_once_with("frame-a")
self.sync.list_clips.assert_called_once_with("frame-a")
def test_switch_during_listing_does_not_mark_or_copy_its_results(self):
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
with self.subTest(kind=kind):
self.frame.device = "frame-a"
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
def listing(*_args):
self.frame.device = "frame-b"
return {kind: [dict(item, saved=True), dict(item, id="uncopied", saved=False)]}
setattr(self.sync, "list_" + kind, listing)
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual(self.sync._synced("frame-a", kind), set())
self.assertEqual(self.sync._synced("frame-b", kind), set())
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
def test_switch_between_kinds_does_not_read_clips_or_mark_transfer(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5)]
self.frame.clips = [clip(1, NOW + 5, saved=True)]
save = self.sync.save_shots
def then_switch(device, ids):
save(device, ids)
self.frame.device = "frame-b"
self.sync.save_shots = then_switch
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertNotIn("list_clips", self.frame.calls)
self.assertEqual(self.sync._synced("frame-a", "clips"), set())
self.assertEqual(self.sync._synced("frame-a", "shots"), set())
def test_switch_after_bookkeeping_stops_before_next_kind(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5, saved=True)]
mark = self.sync._mark
def then_switch(device, kind, ids):
mark(device, kind, ids)
self.frame.device = "frame-b"
self.sync._mark = then_switch
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertNotIn("list_clips", self.frame.calls)
def test_switch_before_transfer_does_not_copy(self):
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
with self.subTest(kind=kind):
self.frame.device = "frame-a"
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
setattr(self.frame, kind, [item])
def switched_space(_folder):
self.frame.device = "frame-b"
return 100 * GB
self.sync.space = switched_space
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
def test_worker_stops(self):
stop = threading.Event()
@@ -275,6 +407,75 @@ class AutoSyncTest(unittest.TestCase):
class Server(unittest.TestCase):
def test_switch_at_list_callback_entry_does_not_read_or_mark_the_new_headset(self):
import server
with tempfile.TemporaryDirectory() as folder:
for kind in ("shots", "clips"):
with self.subTest(kind=kind):
active = {"id": "frame-a"}
link = mock.Mock()
link.active_device.side_effect = lambda: dict(active)
listing = mock.Mock(return_value={kind: [
shot(1, NOW + 5, saved=True) if kind == "shots" else clip(1, NOW + 5, saved=True)]})
def switch_then_list(device):
active["id"] = "frame-b"
return server._sync_list(listing)(device)
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
sync = fa.AutoSync(switch_then_list, mock.Mock(), switch_then_list, mock.Mock(),
lambda: active["id"], lambda: False,
path=Path(folder) / (kind + ".json"), clock=lambda: NOW)
sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
sync.tick()
listing.assert_not_called()
sync.save_shots.assert_not_called()
sync.export_clip.assert_not_called()
self.assertEqual(sync._synced("frame-a", kind), set())
self.assertEqual(sync._synced("frame-b", kind), set())
self.assertIn("switched headsets", sync.state()["error"])
self.assertEqual(server._work[0], 0)
def test_listing_rejects_switch_before_reserving_work(self):
import server
link = mock.Mock()
link.active_device.return_value = {"id": "frame-b"}
listing = mock.Mock(return_value={"shots": [shot(1, NOW, saved=True)]})
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
with self.assertRaisesRegex(server.Failure, "switched headsets"):
server._sync_list(listing)("frame-a")
listing.assert_not_called()
self.assertEqual(server._work[0], 0)
def test_listing_reserves_work_on_the_captured_device(self):
import server
link = mock.Mock()
link.active_device.return_value = {"id": "frame-a", "name": "Frame A"}
def listing():
self.assertGreater(server._work[0], 0)
with self.assertRaises(server.Failure):
server.devices_post({"action": "use", "id": "frame-b"})
return {"shots": []}
with mock.patch.object(server, "LINK", link), \
mock.patch.object(server, "PRIVATE", False), mock.patch.object(server, "_work", [0]):
self.assertEqual(server._sync_list(listing)("frame-a"), {"shots": []})
self.assertEqual(server._work[0], 0)
def test_copy_rejects_switch_after_listing(self):
import server
link = mock.Mock()
link.active_device.return_value = {"id": "frame-a"}
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]), \
mock.patch.object(server, "save_shots") as save, \
mock.patch.object(server.frame_clips, "export_clip") as export:
server._sync_list(lambda: {"shots": []})("frame-a")
link.active_device.return_value = {"id": "frame-b"}
for copy in (lambda: server._sync_shots("frame-a", [shot(1, NOW)["id"]]),
lambda: server._sync_clip("frame-a", clip(1, NOW)["id"])):
with self.assertRaisesRegex(server.Failure, "switched headsets"):
copy()
save.assert_not_called()
export.assert_not_called()
self.assertEqual(server._work[0], 0)
def test_route_and_settings(self):
import server
self.assertIn("/api/autosync", server.POST)
@@ -287,5 +488,29 @@ class Server(unittest.TestCase):
server.auto_sync({"action": "now"}) # nothing connected
@unittest.skipUnless(shutil.which("node"), "Node exercises the Settings renderer")
class Settings(unittest.TestCase):
def test_resume_is_available_after_a_copy_error(self):
page = (Path(__file__).resolve().parents[1] / "ui/index.html").read_text(encoding="utf-8")
script = page.split("const autoSync =", 1)[1].split("async function loadAutoSync", 1)[0]
harness = r'''
const assert = require('node:assert/strict');
const nodes = {};
const $ = id => nodes[id] ||= {};
const HOST = {mobile: false};
const esc = value => value;
'''
checks = r'''
for (const state of [{error: 'Could not copy clip'}, {paused: 'Disk full'}, {}]) {
renderAutoSync(state);
assert.equal($('syncResume').hidden, !(state.error || state.paused));
if (state.error) assert.match($('syncStatus').textContent, /Could not copy clip/);
}
'''
result = subprocess.run(["node", "-e", harness + "const autoSync =" + script + checks],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -113,6 +113,108 @@ class Remote(unittest.TestCase):
os.symlink(str(outside), str(base / 'linked-file'))
self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f])
def packed(self, part):
out = io.BytesIO()
remote.pack(part, out)
out.seek(0)
with tarfile.open(fileobj=out, mode='r:gz') as archive:
return {m.name: archive.extractfile(m).read() for m in archive}
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
def test_backup_rejects_every_ancestor_of_a_save_root(self):
save = USER % 1234 + '/AppData/Roaming/Game/fixture.dat'
# Each component below HOME, including the selected root itself.
components = (USER % 1234 + '/AppData/Roaming').split('/')
for end in range(1, len(components) + 1):
ancestor = '/'.join(components[:end])
with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp:
home = Path(tmp) / 'home'
manifest(home, 1234, 'Synthetic game')
path = write(home, save, b'synthetic fixture')
linked = home / ancestor
outside = Path(tmp) / 'outside/.ssh'
outside.parent.mkdir()
linked.rename(outside)
linked.symlink_to(outside, target_is_directory=True)
# Only synthetic bytes: the lexical archive path hides .ssh.
self.assertEqual(path.read_bytes(), b'synthetic fixture')
with patch.object(remote, 'HOME', home):
self.assertEqual(self.files('saves:1234'), [])
self.assertNotIn('saves:1234', [p['id'] for p in remote.plan()['parts']])
self.assertEqual(self.packed('saves:1234'), {})
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
def test_pack_rechecks_links_changed_after_discovery(self):
save = USER % 1234 + '/Documents/Game/fixture.dat'
for ancestor in (remote.COMPAT + '/1234', USER % 1234 + '/Documents', save):
with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp:
home = Path(tmp) / 'home'
write(home, save, b'original save')
linked = home / ancestor
outside = Path(tmp) / 'outside/.ssh'
outside.parent.mkdir()
part_files = remote.part_files
def discover_then_link(part):
result = part_files(part)
self.assertEqual(result[0], [(save, len(b'original save'))])
linked.rename(outside)
linked.symlink_to(outside, target_is_directory=outside.is_dir())
(home / save).write_bytes(b'synthetic redirected bytes')
return result
with patch.object(remote, 'HOME', home), \
patch.object(remote, 'part_files', side_effect=discover_then_link):
self.assertEqual(self.packed('saves:1234'), {})
def test_legitimate_steam_layout_and_reference_files_still_pack(self):
frame_home(self.home)
manifest(self.home, 2800000002, 'Synthetic non-Steam game')
save = USER % 2800000002 + '/Saved Games/fixture.dat'
write(self.home, save, b'non-Steam save')
controller = STEAM + '/steamapps/common/Steam Controller Configs/555/config/layout.vdf'
write(self.home, controller, b'controller layout')
for part in ('saves:1234', 'saves:2800000002', 'saves:linux',
'settings:steam', 'settings:system'):
with self.subTest(part=part):
expected = {rel: (self.home / rel).read_bytes() for rel in self.files(part)}
self.assertTrue(expected)
self.assertEqual(self.packed(part), expected)
self.assertEqual(self.packed('saves:2800000002'), {save: b'non-Steam save'})
self.assertIn(controller, self.packed('settings:steam'))
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
def test_settings_discovery_and_manifest_reads_reject_links(self):
frame_home(self.home)
for ancestor, part in (('.config', 'settings:system'),
(STEAM + '/userdata', 'settings:steam')):
with self.subTest(ancestor=ancestor):
linked = self.home / ancestor
moved = linked.with_name(linked.name + '-original')
linked.rename(moved)
linked.symlink_to(moved, target_is_directory=True)
self.assertEqual(self.files(part), [])
self.assertEqual(self.packed(part), {})
if part == 'settings:steam':
self.assertEqual(remote.accounts(), [])
appmanifest = self.home / STEAM / 'steamapps/appmanifest_1234.acf'
moved = appmanifest.with_suffix('.original')
appmanifest.rename(moved)
appmanifest.symlink_to(moved)
with patch.object(Path, 'read_text', side_effect=AssertionError('must not read a link')):
self.assertIsNone(remote.game_name('1234'))
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
def test_home_alias_and_unused_steam_alias_preserve_normal_backups(self):
frame_home(self.home)
alias = self.home / 'home-alias'
alias.symlink_to(self.home, target_is_directory=True)
(self.home / '.steam/steam').symlink_to(self.home / STEAM, target_is_directory=True)
expected = self.packed('saves:1234')
with patch.object(remote, 'HOME', alias):
self.assertEqual(self.packed('saves:1234'), expected)
self.assertEqual(remote.game_name('1234'), 'Test Game')
def test_big_files_are_listed_as_skipped(self):
frame_home(self.home)
with patch.object(remote, 'MAX_FILE', 3):
+115
View File
@@ -0,0 +1,115 @@
"""Clip destinations through the server and automatic copying, without a headset."""
import sandbox # noqa: F401
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import frame_autosync
import frame_clips
import server
IDS = ["1/clips/clip_620/video/bg", "2/clips/clip_620/video/bg",
"1/clips/clip_620/video/other", "1/clips/other/video/bg"]
NOW = 1800000000
class ClipCopyTest(unittest.TestCase):
def setUp(self):
self.folder = Path(tempfile.mkdtemp(prefix="frame-clip-copy-"))
self.addCleanup(shutil.rmtree, self.folder, ignore_errors=True)
self.device = "frame-a"
self.link = mock.Mock()
self.link.active_device.side_effect = lambda: {"id": self.device, "name": self.device}
self.link.snapshot.return_value = {"phase": "connected"}
self.exports = []
self.payload = None
for name, value in (("LINK", self.link), ("LOCAL", False)):
patch = mock.patch.object(server, name, value)
patch.start()
self.addCleanup(patch.stop)
patch = mock.patch.object(frame_clips, "VIDEOS_DIR", self.folder)
patch.start()
self.addCleanup(patch.stop)
for name, fn in (("_clips_frame", self.frame), ("fetch_file", self.fetch),
("ssh", lambda *a, **k: "")):
patch = mock.patch.object(server, name, side_effect=fn)
patch.start()
self.addCleanup(patch.stop)
(self.folder / "clip_620.mp4").write_bytes(b"legacy export")
def frame(self, args, timeout):
if args == ["list"]:
return {"clips": [{"id": cid, "kind": "clip", "time": NOW + 5, "idle": 60} for cid in IDS]}
self.exports.append((self.device, args[1]))
self.payload = f"{self.device}: {args[1]}".encode()
return {"path": f"{frame_clips.WORK}/export-0123456789abcdef/clip_620.mp4",
"size": len(self.payload)}
def fetch(self, remote, target):
target.write_bytes(self.payload)
def test_manual_export_lists_only_matching_identity_and_keeps_job_shape(self):
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()) as job:
for device in ("frame-a", "frame-b"):
self.device = device
self.assertFalse(any(c["saved"] for c in server.list_clips()["clips"]))
result = server.export_clip({"id": IDS[0]})
self.assertEqual(set(result), {"message", "file"})
self.assertEqual(Path(result["file"]).read_bytes(), f"{device}: {IDS[0]}".encode())
self.assertEqual([c["saved"] for c in server.list_clips()["clips"]], [True, False, False, False])
self.assertEqual(job.call_args.args[0], "Export clip_620.mp4")
first = frame_clips.clip_local_path(IDS[0], "frame-a", self.folder)
self.assertEqual(first.read_bytes(), f"frame-a: {IDS[0]}".encode())
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
def test_auto_sync_uses_same_identity_as_manual_exports(self):
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()):
server.export_clip({"id": IDS[0]})
sync = frame_autosync.AutoSync(
server._sync_list(server.list_shots), server._sync_shots,
server._sync_list(server.list_clips), server._sync_clip,
server._sync_device, lambda: False, shots_folder=self.folder / "shots",
clips_folder=self.folder, path=self.folder / "auto-sync.json", clock=lambda: NOW,
space=lambda folder: 100 * 1024**3)
sync.update({"clips": True})
sync.tick()
self.assertEqual(self.exports, [("frame-a", cid) for cid in IDS])
self.device = "frame-b"
sync.tick()
self.assertEqual(self.exports, [(device, cid) for device in ("frame-a", "frame-b") for cid in IDS])
sync.tick()
self.assertEqual(len(self.exports), 8)
for device in ("frame-a", "frame-b"):
for cid in IDS:
target = frame_clips.clip_local_path(cid, device, self.folder)
self.assertEqual(target.read_bytes(), f"{device}: {cid}".encode())
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
def test_delayed_job_refuses_a_different_headset(self):
with mock.patch.object(server, "start_job", return_value={"job": "queued"}) as job:
self.assertEqual(server.export_clip({"id": IDS[0]}), {"job": "queued"})
self.device = "frame-b"
with self.assertRaises(server.Failure) as error:
job.call_args.args[1]()
self.assertEqual(error.exception.status, 409)
self.assertEqual(self.exports, [])
def test_local_and_headset_modes_reject_steam_clip_exports(self):
for headset in (False, True):
with self.subTest(headset=headset), mock.patch.object(server, "LOCAL", True), \
mock.patch.object(server, "HEADSET_RUNTIME", headset):
for action in (server.list_clips, lambda: server.export_clip({"id": IDS[0]}),
lambda: server.auto_sync({"action": "now"})):
with self.assertRaises(server.Failure) as error:
action()
self.assertEqual(error.exception.status, 400)
self.assertEqual(self.exports, [])
if __name__ == "__main__":
unittest.main()
+65 -8
View File
@@ -71,6 +71,17 @@ class Ids(unittest.TestCase):
with self.subTest(bad=bad), self.assertRaises(ValueError):
clips.remove_command(bad)
def test_local_path_keeps_validated_identity_and_hashes_device_names(self):
folder = Path("exports")
target = clips.clip_local_path("123/clips/clip/video/session", "../../device:name", folder)
self.assertRegex(target.parts[1], r"^headset-[0-9a-f]{12}$")
self.assertEqual(target.parts[2:], ("123", "clips", "clip", "video", "session", "clip.mp4"))
for bad in (None, "", 42):
with self.subTest(device=bad), self.assertRaises(ValueError):
clips.clip_local_path("123/video/bg", bad, folder)
with self.assertRaises(ValueError):
clips.clip_local_path("../clips/clip", "frame-a", folder)
def test_recording_names_checked(self):
for fn in (clips.finish_command, clips.cleanup_command, lambda n: clips.record_command(n, True)):
for bad in ("rec-1", "rec-20261020-201950; rm -rf ~", "../rec-20261020-201950", "*"):
@@ -156,10 +167,10 @@ class Sessions(unittest.TestCase):
def test_listing_marks_what_is_already_here(self):
here = self.dir / "here"
write(here / "clip_620_20261020_202134.mp4")
write(clips.clip_local_path("1/clips/clip_620_20261020_202134/video/bg", "frame-a", here))
frame = lambda args, timeout: {"clips": [{"id": "1/clips/clip_620_20261020_202134/video/bg"},
{"id": "1/video/bg_620_20261020_201950"}]}
got = clips.list_clips(frame, folder=here)
got = clips.list_clips(frame, folder=here, device_id="frame-a")
self.assertEqual([c["saved"] for c in got["clips"]], [True, False])
self.assertEqual(got["folder"], str(here))
@@ -261,12 +272,16 @@ class ComputerSide(unittest.TestCase):
frame = lambda args, timeout: calls.append(("frame", args)) or {"path": path, "size": 3}
run = lambda cmd, timeout: calls.append(("run", cmd))
fetch = lambda remote, local: calls.append(("fetch", remote)) or Path(local).write_bytes(b"mp4")
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir)
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir, device_id="frame-a")
self.assertEqual(got["message"], "Saved clip_620.mp4 to ~/Videos/SteamFrame")
self.assertEqual((self.dir / "clip_620.mp4").read_bytes(), b"mp4")
target = clips.clip_local_path("1/clips/clip_620/video/bg", "frame-a", self.dir)
self.assertEqual(got["file"], str(target))
self.assertEqual(set(got), {"message", "file"})
self.assertEqual(target.read_bytes(), b"mp4")
self.assertEqual([c[0] for c in calls], ["frame", "fetch", "run"])
self.assertEqual(calls[2][1], clips.remove_command(path))
self.assertEqual([p.name for p in self.dir.iterdir()], ["clip_620.mp4"])
self.assertEqual(list(self.dir.rglob("*.mp4")), [target])
self.assertFalse(list(self.dir.glob(".incoming-*")))
def test_failed_copy_leaves_no_partial_file_and_still_cleans_up(self):
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
@@ -277,20 +292,62 @@ class ComputerSide(unittest.TestCase):
raise RuntimeError("connection lost")
with self.assertRaisesRegex(RuntimeError, "connection lost"):
clips.export_clip("1/clips/clip_620/video/bg", lambda a, t: {"path": path, "size": 4},
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir)
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir, device_id="frame-a")
self.assertEqual(list(self.dir.iterdir()), [])
self.assertEqual(ran, [clips.remove_command(path)])
def test_same_names_keep_each_account_headset_kind_and_nested_session(self):
first = "1/clips/clip_620/video/bg"
ids = [first, "2/clips/clip_620/video/bg", "1/clips/clip_620/video/other",
"1/clips/clip_620/other/bg", "1/video/clip_620"]
legacy = write(self.dir / "clip_620.mp4", b"legacy export")
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
frame = lambda args, timeout: {"clips": [{"id": cid} for cid in ids]}
stored = {}
for device in ("frame-a", "frame-b"):
self.assertFalse(any(c["saved"] for c in clips.list_clips(
frame, folder=self.dir, device_id=device)["clips"]))
for index, cid in enumerate(ids):
payload = f"{device} session {index}".encode()
result = clips.export_clip(cid, lambda a, t: {"path": remote, "size": len(payload)},
lambda c, t: None, lambda r, p: p.write_bytes(payload),
folder=self.dir, device_id=device)
stored[Path(result["file"])] = payload
listing = clips.list_clips(frame, folder=self.dir, device_id=device)
self.assertEqual([c["saved"] for c in listing["clips"]],
[n <= index for n in range(len(ids))])
self.assertEqual(listing["folder"], str(self.dir))
self.assertEqual(len(stored), len(ids) * 2)
for path, payload in stored.items():
self.assertEqual(path.read_bytes(), payload)
self.assertEqual(legacy.read_bytes(), b"legacy export")
self.assertFalse(list(self.dir.glob(".incoming-*")))
def test_failed_reexport_preserves_completed_export(self):
cid = "1/clips/clip_620/video/bg"
target = write(clips.clip_local_path(cid, "frame-a", self.dir), b"completed")
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
def fetch(remote, local):
local.write_bytes(b"partial")
raise RuntimeError("connection lost")
with self.assertRaisesRegex(RuntimeError, "connection lost"):
clips.export_clip(cid, lambda a, t: {"path": remote}, lambda c, t: None, fetch,
folder=self.dir, device_id="frame-a")
self.assertEqual(target.read_bytes(), b"completed")
self.assertFalse(list(self.dir.glob(".incoming-*")))
def test_bad_id_never_reaches_the_frame(self):
never = mock.Mock(side_effect=AssertionError("reached the Frame"))
with self.assertRaises(ValueError):
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir)
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir, device_id="frame-a")
def test_unexpected_path_from_the_frame_refused(self):
never = mock.Mock(side_effect=AssertionError("deleted or copied"))
with self.assertRaises(ValueError):
clips.export_clip("1/video/bg", lambda a, t: {"path": ".ssh/id_ed25519", "size": 1}, never, never,
folder=self.dir)
folder=self.dir, device_id="frame-a")
class Commands(unittest.TestCase):
+107
View File
@@ -0,0 +1,107 @@
"""Extension compatibility and filesystem boundaries, without running plugin code."""
import sandbox # noqa: F401
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_extensions
class ExtensionFiles(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name) / 'extensions'
self.root.mkdir()
self.patch = patch.object(frame_extensions, 'root', return_value=self.root)
self.patch.start()
self.addCleanup(self.patch.stop)
def extension(self, name='test-panel', **fields):
folder = self.root / name
folder.mkdir(exist_ok=True)
manifest = {'id': name, 'name': 'Test panel', 'version': '1.0.0',
'description': 'A read-only panel', 'entry': 'index.html', **fields}
(folder / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
(folder / 'index.html').write_text('<script src="main.js"></script>', encoding='utf-8')
(folder / 'main.js').write_text('/* extension code */', encoding='utf-8')
return folder
def test_existing_and_explicit_v1_manifests_are_compatible(self):
self.extension('legacy')
self.extension('explicit', apiVersion=1)
result = frame_extensions.list_extensions()
self.assertEqual(result['errors'], [])
self.assertEqual([(x['id'], x['apiVersion']) for x in result['extensions']],
[('explicit', 1), ('legacy', 1)])
self.assertEqual(frame_extensions.asset('explicit', 'main.js'),
(b'/* extension code */', 'text/javascript; charset=utf-8'))
def test_unsupported_or_malformed_api_cannot_be_loaded_directly(self):
for version in (2, 0, -1, '1', None, True, 1.0, [], {}):
with self.subTest(apiVersion=version):
self.extension(apiVersion=version)
result = frame_extensions.list_extensions()
self.assertEqual(result['extensions'], [])
self.assertIn('unsupported apiVersion', result['errors'][0]['error'])
with self.assertRaisesRegex(ValueError, 'unsupported apiVersion'):
frame_extensions.asset('test-panel', 'index.html')
def test_one_incompatible_extension_does_not_hide_compatible_panels(self):
self.extension('future', apiVersion=2)
self.extension('working', apiVersion=1)
result = frame_extensions.list_extensions()
self.assertEqual([x['id'] for x in result['extensions']], ['working'])
self.assertEqual(result['errors'][0]['folder'], 'future')
def test_sample_is_discoverable_and_its_assets_are_served(self):
source = Path(__file__).resolve().parents[1] / 'examples/extensions/headset-glance'
shutil.copytree(source, self.root / source.name)
result = frame_extensions.list_extensions()
self.assertEqual(result['errors'], [])
self.assertEqual(result['extensions'][0]['id'], source.name)
for asset in ('index.html', 'main.js', 'style.css'):
self.assertEqual(frame_extensions.asset(source.name, asset)[0],
(source / asset).read_bytes())
def test_private_nested_and_traversal_paths_are_rejected(self):
self.extension()
for name in ('manifest.json', '../outside.js', 'sub/main.js', '.hidden.js'):
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
frame_extensions.asset('test-panel', name)
with self.assertRaises(FileNotFoundError):
frame_extensions.asset('../test-panel', 'main.js')
def test_oversized_assets_are_not_read(self):
folder = self.extension()
with (folder / 'large.js').open('wb') as output:
output.truncate(5 * 1024 * 1024 + 1)
with self.assertRaises(FileNotFoundError):
frame_extensions.asset('test-panel', 'large.js')
def test_symlinks_cannot_expose_files_outside_a_panel(self):
folder = self.extension()
outside = Path(self.tmp.name) / 'outside.js'
outside.write_text('private file', encoding='utf-8')
try:
(folder / 'escape.js').symlink_to(outside)
(self.root / 'alias').symlink_to(folder, target_is_directory=True)
except OSError:
self.skipTest('symlinks unavailable for this user')
with self.assertRaises(FileNotFoundError):
frame_extensions.asset('test-panel', 'escape.js')
with self.assertRaises(ValueError):
frame_extensions.asset('alias', 'main.js')
(folder / 'manifest.json').unlink()
(folder / 'manifest.json').symlink_to(outside)
with self.assertRaises(ValueError):
frame_extensions.asset('test-panel', 'main.js')
if __name__ == '__main__':
unittest.main()
+94
View File
@@ -0,0 +1,94 @@
"""Decky release validation and the removable user-service transaction."""
import sandbox # noqa: F401
import hashlib
import io
import json
import os
import secrets
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_decky
def arm_binary(fill=b'A'):
data = bytearray(b'\x7fELF' + fill * 2044)
data[18:20] = (183).to_bytes(2, 'little')
return bytes(data)
class DeckyRelease(unittest.TestCase):
def test_official_arm_release_digest_is_checked(self):
data = arm_binary()
digest = hashlib.sha256(data).hexdigest()
url = f'https://github.com/{frame_decky.REPO}/releases/download/v3.3.0/PluginLoader-arm64'
info = {'tag_name':'v3.3.0', 'assets':[{'name':'PluginLoader-arm64', 'size':len(data),
'digest':'sha256:'+digest, 'browser_download_url':url}]}
with patch.object(frame_decky.urllib.request, 'urlopen', side_effect=[io.BytesIO(json.dumps(info).encode()), io.BytesIO(data)]):
self.assertEqual(frame_decky.release_binary(), ('v3.3.0', digest, data))
info['assets'][0]['digest'] = 'sha256:' + '0'*64
with patch.object(frame_decky.urllib.request, 'urlopen', side_effect=[io.BytesIO(json.dumps(info).encode()), io.BytesIO(data)]):
with self.assertRaisesRegex(ValueError, 'digest'):
frame_decky.release_binary()
def test_install_rejects_incompatible_headset_before_download(self):
state = {'arch':'x86_64','variant':'vr','cef':True,'managed':False,'upstreamInstall':False}
with patch.object(frame_decky, 'release_binary') as download:
with self.assertRaisesRegex(ValueError, 'Steam Frame'):
frame_decky.change(lambda *a, **k: json.dumps(state), 'install')
download.assert_not_called()
@unittest.skipIf(os.name == 'nt', 'remote service script runs on Linux')
class DeckyServiceScript(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
home = Path(self.tmp.name)
fake = home / 'systemctl'
fake.write_text('#!/bin/sh\nif [ "$FRAME_DECKY_TEST_FAIL" = "1" ] && [ "$2" = "enable" ]; then echo blocked >&2; exit 1; fi\nexit 0\n')
fake.chmod(0o700)
self.env = {**os.environ, 'HOME': self.tmp.name, 'PATH': self.tmp.name + os.pathsep + os.environ['PATH']}
self.base = home / '.local/share/frame-control/decky'
def apply(self, action, data=None, version='v3.3.0', fail=False):
token = secrets.token_hex(12)
stage = Path('/tmp/frame-control-decky-' + token)
if data is not None:
stage.write_bytes(data)
digest = hashlib.sha256(data or b'').hexdigest()
env = {**self.env, 'FRAME_DECKY_TEST_FAIL': '1' if fail else '0'}
try:
return subprocess.run([sys.executable, '-', action, token, digest, version],
input=frame_decky.APPLY, capture_output=True, text=True, env=env, timeout=15)
finally:
stage.unlink(missing_ok=True)
def test_install_update_rollback_and_remove(self):
first = arm_binary(b'A')
result = self.apply('install', first)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
receipt = self.base / '.frame-control/receipt.json'
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
self.assertTrue((Path(self.tmp.name) / '.config/systemd/user/frame-control-decky.service').is_file())
second = arm_binary(b'B')
result = self.apply('update', second, version='v3.3.1', fail=True)
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
result = self.apply('update', second, version='v3.3.1')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.base / 'PluginLoader').read_bytes(), second)
result = self.apply('remove')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((self.base / 'PluginLoader').exists())
self.assertFalse(receipt.exists())
self.assertTrue((self.base / 'plugins').is_dir())
+215
View File
@@ -0,0 +1,215 @@
"""Official Decky store transport and owned plugin transaction."""
import sandbox # noqa: F401
import hashlib
import io
import json
import os
from pathlib import Path
import secrets
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import warnings
import zipfile
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_decky_store
import frame_decky
def archive(flags=(), extra=None):
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w') as bundle:
bundle.writestr('Example/plugin.json', json.dumps({'name': 'Example', 'flags': list(flags)}))
bundle.writestr('Example/dist/index.js', 'export default {};' + ' ' * 1200)
if extra:
bundle.writestr(*extra)
data = stream.getvalue()
return data, hashlib.sha256(data).hexdigest()
class ArchiveTests(unittest.TestCase):
def test_catalog_selects_valid_visible_latest_versions(self):
records = [{'id': 42, 'name': 'Example', 'author': 'Panel Author', 'visible': True, 'versions':
[{'name': '1.0', 'hash': 'a' * 64}], 'tags': []},
{'id': 43, 'name': 'Hidden', 'visible': False, 'versions':
[{'name': '1.0', 'hash': 'b' * 64}]},
{'id': 44, 'name': 'Unsafe', 'visible': True, 'versions':
[{'name': '1.0; touch /tmp/bad', 'hash': 'c' * 64}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([(p['id'], p['author']) for p in frame_decky_store.catalog()], [(42, 'Panel Author')])
def test_accepts_hashed_non_root_archive(self):
data, digest = archive()
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
with self.assertRaisesRegex(ValueError, 'SHA-256'):
frame_decky_store.archive_info(data, '0' * 64, 'Example')
def test_rejects_root_x86_and_escape(self):
for data in (archive(flags=['root'])[0],
archive(extra=('Example/bin/helper', b'\x7fELF' + b'\x00' * 14 + (62).to_bytes(2, 'little')))[0],
archive(extra=('../outside', b'oops'))[0]):
with self.subTest(data=hashlib.sha256(data).hexdigest()[:8]):
with self.assertRaises(ValueError):
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
def test_rejects_zip_path_collisions_and_non_object_manifest(self):
for name in ('Example/dist/index.js', 'Example/./dist/index.js',
'Example//dist/index.js'):
with self.subTest(name=name), warnings.catch_warnings():
warnings.simplefilter('ignore', UserWarning)
data, digest = archive(extra=(name, 'replacement'))
with self.assertRaisesRegex(ValueError, 'Unsafe|duplicate'):
frame_decky_store.archive_info(data, digest, 'Example')
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w') as bundle:
bundle.writestr('Example/plugin.json', '[]')
bundle.writestr('Example/dist/index.js', ' ' * 1200)
data = stream.getvalue()
with self.assertRaisesRegex(ValueError, 'manifest'):
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux')
class StoreTransaction(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
home = Path(self.tmp.name)
unit = home / '.config/systemd/user/frame-control-decky.service'
unit.parent.mkdir(parents=True)
unit.write_text('[Service]\n')
fake = home / 'systemctl'
fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 0; fi\nexit 0\n')
fake.chmod(0o700)
self.env = {**os.environ, 'HOME': self.tmp.name,
'PATH': self.tmp.name + os.pathsep + os.environ['PATH']}
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
self.folder.parent.mkdir(parents=True)
def apply(self, action, data=None):
token = secrets.token_hex(12)
source = Path('/tmp/frame-control-decky-plugin-' + token)
if data is not None:
source.write_bytes(data)
digest = hashlib.sha256(data or b'').hexdigest()
try:
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0'],
input=frame_decky_store.APPLY, text=True, capture_output=True,
env=self.env, timeout=15)
finally:
source.unlink(missing_ok=True)
def test_install_update_remove_only_owned_folder(self):
first, _ = archive()
result = self.apply('install', first)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads((self.folder / '.frame-control-store.json').read_text())['storeId'], 42)
second, _ = archive(extra=('Example/data.txt', 'new'))
result = self.apply('update', second)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((self.folder / 'data.txt').read_text(), 'new')
result = self.apply('remove')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(self.folder.exists())
self.folder.mkdir()
(self.folder / 'plugin.json').write_text('{}')
result = self.apply('remove')
self.assertNotEqual(result.returncode, 0)
self.assertTrue(self.folder.exists())
external = Path(self.tmp.name) / 'other-plugin'
external.mkdir()
(external / '.frame-control-store.json').write_text('{"storeId":42}')
for item in self.folder.iterdir(): item.unlink()
self.folder.rmdir()
self.folder.symlink_to(external, target_is_directory=True)
result = self.apply('remove')
self.assertNotEqual(result.returncode, 0)
self.assertTrue(external.exists())
def test_remove_while_service_is_stopped_does_not_restart_it(self):
data, _ = archive()
self.assertEqual(self.apply('install', data).returncode, 0)
fake = Path(self.tmp.name) / 'systemctl'
fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 1; fi\n'
'echo "$*" >> "$HOME/service-calls"\nexit 0\n')
result = self.apply('remove')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(self.folder.exists())
self.assertEqual((Path(self.tmp.name) / 'service-calls').read_text().splitlines(),
['--user stop frame-control-decky.service'])
def test_shared_lock_rejects_plugin_and_loader_changes(self):
import fcntl
lock_path = Path(self.tmp.name) / '.local/share/frame-control/decky-transaction.lock'
lock_path.parent.mkdir(parents=True, exist_ok=True)
data, _ = archive()
with lock_path.open('w+b') as held:
fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
plugin = self.apply('install', data)
loader = subprocess.run([sys.executable, '-', 'remove', 'none', 'none', 'none'],
input=frame_decky.APPLY, text=True, capture_output=True,
env=self.env, timeout=15)
self.assertNotEqual(plugin.returncode, 0)
self.assertNotEqual(loader.returncode, 0)
self.assertIn('Another Decky change is in progress', plugin.stderr)
self.assertIn('Another Decky change is in progress', loader.stderr)
self.assertFalse(self.folder.exists())
installed = self.apply('install', data)
self.assertEqual(installed.returncode, 0, installed.stderr)
self.assertEqual(self.apply('remove').returncode, 0)
def test_remote_apply_rejects_colliding_zip_paths(self):
for name in ('Example/dist/index.js', 'Example/./dist/index.js'):
with self.subTest(name=name), warnings.catch_warnings():
warnings.simplefilter('ignore', UserWarning)
data, _ = archive(extra=(name, 'replacement'))
result = self.apply('install', data)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.folder.exists())
class StoreHost(unittest.TestCase):
def test_stopped_managed_service_allows_removing_owned_plugin(self):
state = {'managed': True, 'running': False, 'plugins':
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
calls = []
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog') as network:
result = frame_decky_store.change(lambda command, **kwargs: calls.append(command) or '{}',
'remove', 42)
self.assertEqual(result, {})
self.assertEqual(len(calls), 1)
network.assert_not_called()
def test_renamed_store_plugin_cannot_be_installed_twice(self):
state = {'managed': True, 'running': True, 'plugins':
[{'storeId': 42, 'folder': 'OldFolder', 'name': 'Old Name', 'version': '1.0'}]}
catalog = [{'id': 42, 'name': 'New Name', 'root': False, 'sha256': 'a' * 64, 'version': '2.0'}]
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog', return_value=catalog) as network, \
patch.object(frame_decky_store.urllib.request, 'urlopen', side_effect=AssertionError('No download expected')):
with self.assertRaisesRegex(ValueError, 'already installed'):
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
'install', 42)
network.assert_not_called()
def test_owned_plugin_can_be_removed_without_store_network(self):
state = {'managed': True, 'running': True, 'plugins':
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
def ssh(command, **kwargs):
return '{}'
progress = []
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog') as network:
frame_decky_store.change(ssh, 'remove', 42, lambda stage, percent: progress.append((stage, percent)))
network.assert_not_called()
self.assertEqual(progress, [('Checking installed plugins', 5), ('Removing plugin on Frame', 80)])
if __name__ == '__main__':
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""Headset panel packaging, ownership checks and window targeting."""
import sandbox # noqa: F401
import io
import json
import os
from pathlib import Path
import secrets
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_headset
import frame_headset_runtime
class HeadsetBundle(unittest.TestCase):
def test_bundle_is_deterministic_and_contains_the_local_ui(self):
first = frame_headset.bundle()
self.assertEqual(first, frame_headset.bundle())
with tarfile.open(fileobj=io.BytesIO(first), mode='r:gz') as archive:
names = set(archive.getnames())
self.assertIn('ui/headset.html', names)
self.assertIn('ui/frame_headset_runtime.py', names)
self.assertIn('ui/server.py', names)
self.assertFalse(any('\\' in name for name in names))
self.assertFalse(any('__pycache__' in name for name in names))
def test_incompatible_device_is_rejected_before_upload(self):
state = {'arch': 'x86_64', 'variant': 'vr'}
calls = []
def ssh(*args, **kwargs):
calls.append(args)
return json.dumps(state)
with patch.object(frame_headset, 'bundle') as bundle:
with self.assertRaisesRegex(ValueError, 'Steam Frame'):
frame_headset.change(ssh, 'install')
bundle.assert_not_called()
self.assertEqual(len(calls), 1)
@unittest.skipIf(os.name == 'nt', 'remote script runs on Linux')
def test_unowned_install_is_never_removed(self):
with tempfile.TemporaryDirectory() as home:
token = secrets.token_hex(12)
result = subprocess.run([sys.executable, '-', 'remove', token, 'none'],
input=frame_headset.APPLY, text=True, capture_output=True,
env={**os.environ, 'HOME': home}, timeout=5)
self.assertNotEqual(result.returncode, 0)
self.assertIn('No Frame Control managed', result.stderr)
class HeadsetWindow(unittest.TestCase):
def test_uses_only_its_marker_title(self):
tree = ' 0x101 "Other panel"\n 0xabc "Frame Control VR [fc-headset]"\n'
with patch.object(frame_headset_runtime, 'command', return_value=tree):
self.assertEqual(frame_headset_runtime.panel_window(), '0xabc')
with patch.object(frame_headset_runtime, 'command', return_value=' 0x101 "Other panel"'):
self.assertIsNone(frame_headset_runtime.panel_window())
if __name__ == '__main__':
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
"""Framey hosts only store-owned API v2 frontends and scopes backend calls."""
import sandbox # noqa: F401
import gzip
import http.client
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import tarfile
import threading
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'ui'))
import framey_panel
import frame_headset
import server
class Panel(unittest.TestCase):
def test_vr_store_browsing_and_search(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_store.mjs')],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_plugin_quick_actions_and_settings(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_actions.mjs')],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.plugins = Path(tmp.name)/'plugins'
self.folder = self.plugins/'protondb-decky'
(self.folder/'dist').mkdir(parents=True)
(self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4'}))
(self.folder/'plugin.json').write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
(self.folder/'dist/index.js').write_text("export default () => ({content: SP_REACT.createElement('p', null, 'OK')});")
home = patch.object(framey_panel, 'PLUGINS', self.plugins)
home.start()
self.addCleanup(home.stop)
def test_owned_api_v2_bundle_is_embedded_without_ui_key(self):
page = framey_panel.panel(14).decode()
self.assertIn('framey-bundle', page)
self.assertIn('/framey-runtime/host.js', page)
self.assertIn('framey-toasts', page)
self.assertNotIn('X-Frame-UI', page)
self.assertNotIn('SP_REACT.createElement', page) # bundled code is base64, not executable HTML.
with self.assertRaises(FileNotFoundError):
framey_panel.panel(15)
def test_legacy_root_symlink_and_unmanaged_are_refused(self):
manifest = self.folder/'plugin.json'
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':1}))
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':['root']}))
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
dist = self.folder/'dist'
dist.rename(self.folder/'saved-dist')
dist.symlink_to(self.folder/'saved-dist')
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
dist.unlink()
(self.folder/'.frame-control-store.json').unlink()
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
def test_backend_method_is_scoped_to_store_receipt(self):
def fake_ssh(command, stdin=None, **_):
self.assertIn('python3 - ', command)
self.assertEqual(stdin, framey_panel.METHOD_SCRIPT)
return json.dumps({'result':{'theme':'dark'}})
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':14,'name':'ProtonDB Badges'}]}):
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark']),
{'result':{'theme':'dark'}})
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[])
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[])
compile(framey_panel.METHOD_SCRIPT, '<remote method>', 'exec')
def test_headset_bundle_contains_runtime_and_licenses(self):
packed = frame_headset.bundle()
self.assertGreater(len(packed), 100_000)
self.assertLess(len(packed), frame_headset.MAX_ARCHIVE)
with tarfile.open(fileobj=io.BytesIO(gzip.decompress(packed))) as archive:
names = set(archive.getnames())
self.assertTrue({'ui/framey_panel.py','ui/framey-host.js',
'ui/vendor/react-18.3.1.min.js','ui/vendor/react-dom-18.3.1.min.js',
'ui/vendor/REACT-LICENSE.txt','ui/vendor/REACT-DOM-LICENSE.txt'} <= names)
def test_http_panel_is_sandboxable_and_missing_plugin_is_404(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
self.addCleanup(httpd.server_close)
self.addCleanup(httpd.shutdown)
def get(path):
conn = http.client.HTTPConnection('127.0.0.1', httpd.server_port, timeout=5)
try:
conn.request('GET', path)
reply = conn.getresponse()
return reply.status, dict(reply.getheaders()), reply.read()
finally:
conn.close()
status, headers, body = get('/framey/panel/14')
self.assertEqual(status, 200)
self.assertIn('blob:', headers['Content-Security-Policy'])
self.assertIn(b'framey-bundle', body)
self.assertEqual(get('/framey/panel/15')[0], 404)
if __name__ == '__main__':
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
"""The author example stores settings as a normal Decky user plugin."""
import asyncio
import importlib.util
from pathlib import Path
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
EXAMPLE = Path(__file__).resolve().parents[1] / 'examples/framey-settings/main.py'
def backend(settings_dir):
decky = types.ModuleType('decky')
decky.decky_SETTINGS_DIR = settings_dir
spec = importlib.util.spec_from_file_location('framey_sample', EXAMPLE)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {'decky': decky}):
spec.loader.exec_module(module)
return module.Plugin()
class SamplePlugin(unittest.TestCase):
def test_frontend_bundle_loads_in_framey_host(self):
result = subprocess.run(['node', str(Path(__file__).parent / 'page/framey_sample.mjs')],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_setting_survives_backend_instance_restart(self):
with tempfile.TemporaryDirectory() as folder:
first = backend(folder)
self.assertFalse(asyncio.run(first.get_setting('enabled', False)))
self.assertTrue(asyncio.run(first.set_setting('enabled', True)))
second = backend(folder)
self.assertTrue(asyncio.run(second.get_setting('enabled', False)))
self.assertTrue((Path(folder) / 'settings.json').is_file())
def test_backend_rejects_unknown_keys_and_non_boolean_values(self):
with tempfile.TemporaryDirectory() as folder:
plugin = backend(folder)
with self.assertRaises(ValueError): asyncio.run(plugin.get_setting('other'))
with self.assertRaises(ValueError): asyncio.run(plugin.set_setting('enabled', 'yes'))
self.assertFalse((Path(folder) / 'settings.json').exists())
if __name__ == '__main__':
unittest.main()
+57
View File
@@ -0,0 +1,57 @@
"""The real-device check must abort before touching a standby or busy Frame."""
import sandbox # noqa: F401
import contextlib
import importlib.util
import io
from pathlib import Path
import unittest
from unittest.mock import patch
MODULE = Path(__file__).resolve().parents[1]/'scripts/framey-worn-check.py'
spec = importlib.util.spec_from_file_location('framey_worn_check', MODULE)
worn = importlib.util.module_from_spec(spec)
spec.loader.exec_module(worn)
class WornGate(unittest.TestCase):
def run_gate(self, state, remote_side_effect=None):
out = io.StringIO()
with patch.object(worn, 'probe', return_value=state) as probe, \
patch.object(worn, 'remote', side_effect=remote_side_effect) as remote, \
contextlib.redirect_stdout(out):
result = worn.main()
return result, out.getvalue(), remote, probe
def test_standby_is_read_only_under_owned_lock_and_releases_it(self):
state = {'battery':[{'percent':99}], 'activity':3, 'panelService':True,
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
code, report, remote, probe = self.run_gate(state)
self.assertEqual(code, 1)
self.assertIn('standby', report)
probe.assert_called_once()
self.assertEqual(remote.call_count, 2)
self.assertIn('mkdir /tmp/frame-test.lock', remote.call_args_list[0].args[0])
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args_list[1].args[0])
def test_low_battery_releases_owned_lock(self):
state = {'battery':[{'percent':19}], 'activity':0, 'panelService':True,
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
code, report, remote, probe = self.run_gate(state)
self.assertEqual(code, 1)
self.assertIn('below 20%', report)
probe.assert_called_once()
self.assertEqual(remote.call_count, 2)
def test_foreign_lock_prevents_all_sensor_reads_and_is_never_removed(self):
state = {'battery':[{'percent':99}], 'activity':0, 'panelService':True,
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
code, report, remote, probe = self.run_gate(state, RuntimeError('File exists'))
self.assertEqual(code, 1)
self.assertIn('File exists', report)
probe.assert_not_called()
self.assertEqual(remote.call_count, 1)
self.assertIn('mkdir /tmp/frame-test.lock', remote.call_args.args[0])
def test_screenshot_palette_counts_distinct_colors(self):
self.assertEqual(worn.color_count('0 #ff00aa 1 #ff00aa 2 #00ff00'), 2)
+47
View File
@@ -0,0 +1,47 @@
"""Local headset actions keep writes scoped to the opt-in VR server."""
import sandbox # noqa: F401
import os
from pathlib import Path
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_clips
import server
class HeadsetActions(unittest.TestCase):
def test_quick_actions_are_unavailable_outside_headset_runtime(self):
with patch.object(server, 'HEADSET_RUNTIME', False):
with self.assertRaisesRegex(server.Failure, 'local VR panel'):
server.headset_quick({'action':'game-mode'})
def test_game_mode_targets_steamvr_main_dashboard(self):
with patch.object(server, 'HEADSET_RUNTIME', True), patch.object(server.frame_panels, 'focus') as focus:
response = server.headset_quick({'action':'game-mode'})
focus.assert_called_once_with('valve.steam.gamepadui.main')
self.assertIn('Game Mode', response['message'])
def test_screenshot_is_saved_only_in_user_pictures(self):
with tempfile.TemporaryDirectory() as home, patch.object(server.Path, 'home', return_value=Path(home)), \
patch.object(server, 'HEADSET_RUNTIME', True), patch.object(server, 'headset_view', return_value=b'PNG data'):
result = server.headset_quick({'action':'screenshot'})
file = Path(result['file'])
self.assertEqual(file.parent, Path(home) / 'Pictures/FrameControl')
self.assertEqual(file.read_bytes(), b'PNG data')
if os.name != 'nt': # Windows reports emulated mode bits for user files.
self.assertEqual(file.stat().st_mode & 0o777, 0o600)
def test_local_recording_uses_separate_work_folder(self):
recorder = frame_clips.Recorder(Mock(), Mock(), Mock(), Mock(),
work='.cache/frame-control/headset-video',
destination_label='~/Videos/FrameControl')
self.assertIn('headset-video', frame_clips.probe_command(recorder.work))
self.assertIn('headset-video', frame_clips.record_command('rec-20261025-030000', False, work=recorder.work))
self.assertNotIn('headset-video', frame_clips.probe_command())
if __name__ == '__main__':
unittest.main()
+68 -1
View File
@@ -68,6 +68,9 @@ class ObsSourceTests(unittest.TestCase):
def terminate(self):
self.stopped.set()
def wait(self, timeout=None):
return 255
for ready in (False, True):
with self.subTest(ready=ready):
child = Child()
@@ -80,7 +83,71 @@ class ObsSourceTests(unittest.TestCase):
finally:
watchdog.close()
@unittest.skipIf(sys.platform == 'win32', 'the packaged Start/Stop control is Linux/macOS only')
def test_shutdown_kills_an_unresponsive_ssh_child(self):
proc = mock.Mock()
proc.poll.return_value = None
proc.wait.side_effect = [subprocess.TimeoutExpired('ssh', 5), 137]
obs.stop_child(proc)
proc.terminate.assert_called_once()
proc.kill.assert_called_once()
self.assertEqual(proc.wait.call_count, 2)
def test_windows_sender_ignores_absent_receiver_and_hides_ssh_console(self):
proc = mock.Mock()
proc.stdout = io.BytesIO(bytes(range(188)) * 7)
proc.poll.return_value = proc.wait.return_value = 0
# These are the actual Python Winsock control and subprocess constants.
with mock.patch.object(obs.sys, 'platform', 'win32'), \
mock.patch.object(obs.subprocess, 'CREATE_NO_WINDOW', 0x08000000, create=True), \
mock.patch.object(obs.socket, 'socket') as socket_factory, \
mock.patch.object(obs.subprocess, 'Popen', return_value=proc) as popen:
self.assertEqual(obs.main(['--frame', 'test-frame']), 0)
udp = socket_factory.return_value.__enter__.return_value
self.assertEqual(popen.call_args.kwargs['creationflags'], 0x08000000)
udp.sendto.assert_called_once()
def test_windows_recovers_from_an_unreachable_udp_receiver(self):
udp = mock.Mock()
udp.sendto.side_effect = [ConnectionResetError(10054, 'receiver closed'), 188]
sender = obs._Sender(udp, ('127.0.0.1', 15099))
with mock.patch.object(obs.sys, 'platform', 'win32'):
sender.send(b'G' * 188)
self.assertFalse(sender.ready)
sender.send(b'G' * 188)
self.assertTrue(sender.ready)
self.assertEqual(udp.sendto.call_count, 2)
@unittest.skipIf(sys.platform == 'win32', 'the fake SSH executable uses a POSIX shebang')
def test_stall_exits_cleanly_even_while_app_control_pipe_stays_open(self):
with tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
fake = directory / 'ssh'
fake.write_text("#!/usr/bin/env python3\n"
"import signal,sys,time\n"
"signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
"sys.stdout.buffer.write(b'G'*1316);sys.stdout.buffer.flush()\n"
"while True: time.sleep(1)\n")
fake.chmod(0o755)
wrapper = ("import importlib.util; "
f"s=importlib.util.spec_from_file_location('obs', {str(SCRIPT)!r}); "
"o=importlib.util.module_from_spec(s);s.loader.exec_module(o); "
"o.STARTUP_TIMEOUT=o.STREAM_STALL=o.CHILD_STOP_TIMEOUT=0.15; "
"o.main(['--frame','fake-frame','--control-stdin'])")
env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', '')}
process = subprocess.Popen([sys.executable, '-c', wrapper], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env)
try:
# Keep stdin OPEN: real Electron does this until the helper exits.
self.assertEqual(process.wait(timeout=4), 1)
error = process.stderr.read().decode(errors='replace')
self.assertIn('video stopped', error)
self.assertNotIn('Fatal Python error', error)
finally:
if process.poll() is None:
process.kill(); process.wait(timeout=5)
process.stdin.close(); process.stderr.close()
@unittest.skipIf(sys.platform == 'win32', 'the fake SSH executable uses a POSIX shebang')
def test_relay_stops_its_ssh_child_on_app_signal_or_control_pipe(self):
for mode in ('signal', 'control-pipe'):
with self.subTest(mode=mode):
+1 -1
View File
@@ -9,7 +9,7 @@ import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadDecky', 'loadHeadsetPanel', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
RUN = r'''
+58
View File
@@ -94,6 +94,64 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(headers["X-Frame-Options"], "DENY")
self.assertIn(b"<title>APK Store</title>", payload)
def test_local_extension_is_listed_and_sandboxed(self):
folder = Path(os.environ["FRAME_CONTROL_DATA_DIR"]) / "extensions" / "test-glance"
folder.mkdir(parents=True, exist_ok=True)
(folder / "manifest.json").write_text(json.dumps({"id": "test-glance", "name": "Test Glance",
"version": "1.0", "description": "Test status", "entry": "index.html"}))
(folder / "index.html").write_text('<script src="main.js"></script>')
(folder / "main.js").write_text('parent.postMessage({type:"frame-control:request",id:1,action:"status"},"*");')
try:
code, _, body = self.request("GET", "/api/extensions", headers={"X-Frame-UI": "1"})
self.assertEqual(code, 200)
self.assertIn("test-glance", [x["id"] for x in json.loads(body)["extensions"]])
code, headers, body = self.request("GET", "/extension/test-glance/index.html")
self.assertEqual(code, 200)
self.assertIn(b"main.js", body)
self.assertIn("frame-ancestors 'self'", headers["Content-Security-Policy"])
self.assertNotIn("X-Frame-Options", headers)
self.assertEqual(self.request("GET", "/extension/test-glance/manifest.json")[0], 404)
self.assertEqual(self.request("GET", "/extension/test-glance/../index.html")[0], 404)
try:
(folder / "escape.js").symlink_to(Path(__file__))
except OSError: # Windows may not allow an unprivileged test to create a symlink.
pass
else:
self.assertEqual(self.request("GET", "/extension/test-glance/escape.js")[0], 404)
finally:
shutil.rmtree(folder)
def test_decky_write_requires_experimental_acknowledgement(self):
code, body = self.post('/api/decky', {'action': 'install'})
self.assertEqual(code, 400)
self.assertIn('experimental', body['error'])
def test_incompatible_extension_is_reported_and_assets_are_unavailable(self):
folder = Path(os.environ['FRAME_CONTROL_DATA_DIR']) / 'extensions' / 'future-panel'
folder.mkdir(parents=True, exist_ok=True)
(folder / 'manifest.json').write_text(json.dumps({'id': 'future-panel', 'name': 'Future',
'version': '1.0', 'description': 'Requires a newer API', 'apiVersion': 2}))
(folder / 'index.html').write_text('<p>Incompatible panel</p>')
try:
self.assertEqual(self.request('GET', '/api/extensions')[0], 403)
code, _, body = self.request('GET', '/api/extensions', headers={'X-Frame-UI': '1'})
self.assertEqual(code, 200)
data = json.loads(body)
self.assertNotIn('future-panel', [x['id'] for x in data['extensions']])
self.assertTrue(any(x['folder'] == 'future-panel' and 'apiVersion' in x['error']
for x in data['errors']))
self.assertEqual(self.request('GET', '/extension/future-panel/index.html')[0], 404)
finally:
shutil.rmtree(folder)
def test_headset_page_and_install_acknowledgement(self):
code, _, body = self.request('GET', '/headset')
self.assertEqual(code, 200)
self.assertIn(b'Frame Control VR [fc-headset]', body)
code, body = self.post('/api/headset', {'action': 'install'})
self.assertEqual(code, 400)
self.assertIn('opt-in', body['error'])
def test_foreign_host_rejected(self):
# DNS rebinding: a hostile name pointed at 127.0.0.1.
for path in ("/", "/api/status"):
+92
View File
@@ -0,0 +1,92 @@
"""Screenshot copies retain the headset, Steam account and game identity."""
import sandbox # noqa: F401
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "ui"))
import server # noqa: E402
NAME = "20261025020000_1.jpg"
FIRST = f"1/620/{NAME}"
SECOND = f"1/730/{NAME}"
class ShotCopyTest(unittest.TestCase):
def setUp(self):
self.folder = Path(tempfile.mkdtemp(prefix="frame-shot-copy-"))
self.addCleanup(shutil.rmtree, self.folder, ignore_errors=True)
patch = mock.patch.object(server, "SHOTS_DIR", self.folder)
patch.start()
self.addCleanup(patch.stop)
patch = mock.patch.object(server, "LINK", None)
patch.start()
self.addCleanup(patch.stop)
self.payloads = {
server.shot_path(FIRST): b"first game",
server.shot_path(SECOND): b"second game",
}
def scp(self, argv, **_kwargs):
target = Path(argv[-1])
for source in argv[1:-1]:
if not source.startswith("frame-a:") and not source.startswith("frame-b:"):
continue
remote = source.split(":", 1)[1]
(target / Path(remote).name).write_bytes(self.payloads[remote])
return subprocess.CompletedProcess(argv, 0, "", "")
def test_equal_names_survive_across_games_and_headsets(self):
# A pre-upgrade flat file has no provable source identity. Leave it alone.
legacy = self.folder / NAME
legacy.write_bytes(b"older screenshot")
with mock.patch.object(server, "FRAME", "frame-a"), \
mock.patch.object(server, "ensure_master"), \
mock.patch.object(server.frame_host, "run_ssh", side_effect=self.scp) as copy:
self.assertEqual(server.save_shots({"ids": [FIRST, SECOND]})["saved"], 2)
self.assertEqual(copy.call_count, 2) # scp cannot flatten equal names together
first = server.shot_local_path(FIRST)
second = server.shot_local_path(SECOND)
self.assertEqual(first.read_bytes(), b"first game")
self.assertEqual(second.read_bytes(), b"second game")
self.assertNotEqual(first, second)
self.assertEqual(legacy.read_bytes(), b"older screenshot")
listing = "\n".join([
f"1/760/remote/620/screenshots/{NAME}\t10\t1792893600",
f"1/760/remote/730/screenshots/{NAME}\t11\t1792893601",
])
with mock.patch.object(server, "ssh", return_value=listing):
self.assertTrue(all(s["saved"] for s in server.list_shots()["shots"]))
with mock.patch.object(server.frame_host, "reveal_path") as reveal:
server.open_thing({"what": "shot", "id": SECOND})
reveal.assert_called_once_with(second)
self.payloads[server.shot_path(FIRST)] = b"another headset"
with mock.patch.object(server, "FRAME", "frame-b"), \
mock.patch.object(server, "ensure_master"), \
mock.patch.object(server.frame_host, "run_ssh", side_effect=self.scp):
self.assertEqual(server.save_shots({"ids": [FIRST]})["saved"], 1)
other = server.shot_local_path(FIRST)
self.assertNotEqual(other, first)
self.assertEqual(other.read_bytes(), b"another headset")
self.assertEqual(first.read_bytes(), b"first game")
def test_missing_staged_file_does_not_claim_saved(self):
with mock.patch.object(server, "FRAME", "frame-a"), \
mock.patch.object(server, "ensure_master"), \
mock.patch.object(server.frame_host, "run_ssh",
return_value=subprocess.CompletedProcess([], 0, "", "")):
with self.assertRaisesRegex(server.Failure, "did not contain every"):
server.save_shots({"ids": [FIRST]})
self.assertFalse(server.shot_local_path(FIRST).exists())
self.assertFalse(list(self.folder.glob(".incoming-*")))
if __name__ == "__main__":
unittest.main()
+161 -9
View File
@@ -119,15 +119,6 @@ class Safety(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, 'survived cleanup'):
stop_processes.stop(['fc_smoke_exe'], grace=0)
def test_cleanup_scans_even_when_title_already_removed(self):
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[]), \
mock.patch.object(frame_smoke, 'ssh') as ssh, \
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': [42]}) as stop:
result = frame_smoke.cleanup(frame_smoke.Smoke())
self.assertEqual(stop.call_args_list, [mock.call(stop_processes.IDS)] * 2)
self.assertEqual(result['process_cleanup'][0]['stopped_pids'], [42])
ssh.assert_not_called()
def test_stop_failure_prevents_file_removal(self):
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[{'id': 'fc_smoke_exe'}]), \
mock.patch.object(frame_smoke, 'ssh') as ssh, \
@@ -151,3 +142,164 @@ class Safety(unittest.TestCase):
self.assertEqual(frame_smoke.main(), 1)
cycle.assert_not_called()
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
@unittest.skipUnless(os.name == 'posix', 'local POSIX shell required')
class OrphanCleanup(unittest.TestCase):
"""Run the real cleanup shell commands in a throwaway home; mock Steam/SSH."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.home = Path(self.tmp.name)
self.games = self.home / 'devkit-game'
self.games.mkdir()
self.shortcuts = set()
self.sync_outputs = None
self.events = []
self.smoke = frame_smoke.Smoke()
for target, name, kwargs in (
(frame_smoke, 'ssh', {'side_effect': self.local_ssh}),
(frame_smoke, 'stop_titles', {'side_effect': self.stop}),
(frame_smoke.frame_titles, 'list_titles', {'return_value': []}),
(frame_smoke.frame_titles, 'remove', {'side_effect': self.remove}),
(frame_smoke.frame_titles, 'ensure_utils', {}),
(frame_smoke.frame_titles, 'devkit_script', {'return_value': 'smoke-sync'})):
patcher = mock.patch.object(target, name, **kwargs)
setattr(self, name + '_mock', patcher.start())
self.addCleanup(patcher.stop)
def stop(self, ids):
self.assertEqual(ids, stop_processes.IDS)
self.events.append('stop')
return {'stopped_pids': [42] if len(self.events) == 1 else []}
def remove(self, gid):
self.assertIn(gid, stop_processes.IDS)
self.events.append('remove')
shutil.rmtree(self.games / gid)
def local_ssh(self, command, timeout=60):
self.events.append(command)
self.assertEqual(self.events[0], 'stop', 'processes must stop before file cleanup')
if command == 'smoke-sync 2>&1':
if self.sync_outputs is not None:
return self.sync_outputs.pop(0)
# Valve logs the registered IDs before reconciling with disk.
out = '\n'.join(f'Found Devkit Game registered with Steam Client: {gid!r}'
for gid in sorted(self.shortcuts))
self.shortcuts = {gid for gid in self.shortcuts if (self.games / gid).is_dir()}
return out
return subprocess.run(command, shell=True, check=True, capture_output=True,
text=True, timeout=timeout,
env={**os.environ, 'HOME': str(self.home)}).stdout
def metadata(self, gid):
paths = [self.games / f'{gid}-{kind}.json'
for kind in ('argv', 'env', 'settings', 'framecontrol')]
for path in paths:
path.write_text('{}')
return paths
def test_orphan_metadata_for_every_reserved_id_is_removed_and_shortcuts_rechecked(self):
paths = [path for gid in stop_processes.IDS for path in self.metadata(gid)]
self.shortcuts = set(stop_processes.IDS)
result = frame_smoke.cleanup(self.smoke)
self.assertTrue(all(not path.exists() for path in paths))
self.assertFalse(self.shortcuts)
self.assertEqual(result['removed'], sorted(stop_processes.IDS))
self.assertEqual(result['process_cleanup'], [{'stopped_pids': [42]}, {'stopped_pids': []}])
self.assertEqual(self.events[-1], 'stop')
self.assertEqual(self.events.count('smoke-sync 2>&1'), 2)
self.ensure_utils_mock.assert_called_once_with()
self.remove_mock.assert_not_called()
def test_shortcut_only_leftover_is_synced_without_title_or_install_record(self):
self.shortcuts = {'fc_smoke_exe'}
frame_smoke.cleanup(self.smoke)
self.assertFalse(self.shortcuts)
self.assertEqual(self.events.count('smoke-sync 2>&1'), 2)
def test_unrelated_titles_and_metadata_survive_even_if_in_install_tracker(self):
others = ('portal2', 'fc_smoke_custom', 'fc_smoke_exe-other', 'fc_smoke_exe_other')
paths = []
for gid in ('fc_smoke_exe', *others):
(self.games / gid).mkdir()
paths.extend(self.metadata(gid) if gid in others else [])
self.shortcuts = {'fc_smoke_exe', *others}
self.smoke.installed = {'fc_smoke_exe', *others, 'fc_smoke_exe/../portal2'}
frame_smoke.cleanup(self.smoke)
self.assertFalse((self.games / 'fc_smoke_exe').exists())
self.assertTrue(all((self.games / gid).is_dir() for gid in others))
self.assertTrue(all(path.exists() for path in paths))
self.assertEqual(self.shortcuts, set(others))
self.assertNotIn('fc_smoke_exe', self.smoke.installed)
self.remove_mock.assert_called_once_with('fc_smoke_exe')
def test_still_registered_current_or_legacy_shortcut_fails_cleanup(self):
for gid in sorted(stop_processes.IDS):
with self.subTest(gid=gid):
self.sync_outputs = ['', f'Found Devkit Game registered with Steam Client: {gid!r}']
self.smoke.installed = {gid}
with self.assertRaisesRegex(AssertionError, f'{gid} is still registered with Steam'):
frame_smoke.cleanup(self.smoke)
self.assertIn(gid, self.smoke.installed)
def test_sync_failure_in_either_pass_fails_cleanup(self):
for failed_pass in (0, 1):
with self.subTest(failed_pass=failed_pass):
self.sync_outputs = ['', '']
self.sync_outputs[failed_pass] = 'Steam client sync of devkit games failed: offline'
self.smoke.installed = {'fc_smoke_exe'}
with self.assertRaisesRegex(AssertionError, 'sync of devkit games failed'):
frame_smoke.cleanup(self.smoke)
self.assertIn('fc_smoke_exe', self.smoke.installed)
def test_metadata_remaining_after_delete_fails_cleanup(self):
self.metadata('fc_smoke_exe')
real_ssh = self.local_ssh
with mock.patch.object(frame_smoke, 'ssh', side_effect=lambda command, **kwargs:
'' if command.startswith('rm -f ') else real_ssh(command, **kwargs)):
with self.assertRaisesRegex(AssertionError, 'fc_smoke_exe is still on the Frame'):
frame_smoke.cleanup(self.smoke)
def test_missing_utils_cannot_report_success_without_shortcut_verification(self):
self.ensure_utils_mock.side_effect = frame_smoke.frame_android.FrameError('copy failed')
with self.assertRaisesRegex(AssertionError, "syncing Steam's shortcuts: copy failed"):
frame_smoke.cleanup(self.smoke)
self.assertEqual(self.events[-1], 'stop')
@unittest.skipUnless(os.name == 'posix', 'POSIX wrapper')
class PortableWrapper(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='smoke wrapper ')
self.addCleanup(self.tmp.cleanup)
# macOS aliases /var to /private/var; relative commands need the physical cwd.
self.folder = Path(self.tmp.name).resolve()
self.bin = self.folder / 'bin'
self.bin.mkdir()
# No zsh or SSH on PATH: --help must exit before any headset access.
(self.bin / 'dirname').symlink_to(shutil.which('dirname'))
self.env = {**os.environ, 'PATH': str(self.bin), 'CDPATH': str(self.folder)}
self.wrapper = SMOKE.parent.parent / 'scripts' / 'frame-smoke.sh'
def test_help_runs_directly_and_with_posix_sh_from_another_cwd_without_zsh(self):
(self.bin / 'python3').symlink_to(sys.executable)
for launcher in ([], ['/bin/sh']):
with self.subTest(launcher=launcher):
result = subprocess.run([*launcher, str(self.wrapper), '--help'], cwd=self.folder,
env=self.env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('--pair', result.stdout)
self.assertIn('Headset smoke test', result.stdout)
def test_relative_invocation_preserves_arguments_and_python_exit_status(self):
python = self.bin / 'python3'
python.write_text('#!/bin/sh\nprintf "<%s>\\n" "$@"\nexit 7\n')
python.chmod(0o755)
result = subprocess.run([os.path.relpath(self.wrapper, self.folder), '--pair', 'two words', ''],
cwd=self.folder, env=self.env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 7, result.stderr)
self.assertEqual(result.stdout.splitlines(),
[f'<{SMOKE / "frame_smoke.py"}>', '<--pair>', '<two words>', '<>'])
+58 -14
View File
@@ -131,10 +131,30 @@ def unpack_bundle(path, root):
raise android.FrameError('Could not read Android bundle: ' + str(error)) from error
paths = [str(local) for local, _, _, _ in entries]
if not any(Path(p).suffix.lower() == '.apk' for p in paths):
raise android.FrameError('Android bundles must contain one standalone APK and optional OBB files.')
raise android.FrameError('Android bundles must contain a base APK and optional supported splits or OBB files.')
return paths
def _apk_identity(path):
"""Use the manifest, not the filename, to identify a base or split APK."""
try:
with zipfile.ZipFile(path) as archive:
elements = frame_apk.manifest_elements(
frame_apk._read(archive, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
attrs = next((attrs for tag, attrs in elements if tag == 'manifest'), {})
package = (attrs.get('package') or (None, None, None))[2]
split = (attrs.get('split') or (None, None, None))[2]
code = (attrs.get('versionCode') or (None, None, None))[1]
major = (attrs.get('versionCodeMajor') or (None, 0, None))[1]
if not package or not android.PKG_RE.fullmatch(package):
raise android.FrameError(f'{path.name}: no valid package in APK manifest')
return package, split, (major, code) if code is not None else None
except (zipfile.BadZipFile, frame_apk.ApkError, OSError, UnicodeError,
ValueError, RuntimeError, EOFError, zlib.error, struct.error,
IndexError) as error:
raise android.FrameError(f'Could not read APK manifest in {path.name}: {error}') from error
def _check_standalone(path):
try:
with zipfile.ZipFile(path) as archive:
@@ -168,19 +188,41 @@ def inspect(paths, discover=False):
if path.suffix.lower() not in ('.apk', '.obb') or not path.is_file() or path.stat().st_size == 0:
raise android.FrameError(f'{path.name}: choose a nonempty APK or .obb game-data file.')
apks = [p for p in files if p.suffix.lower() == '.apk']
if len(apks) > 1:
raise android.FrameError('Choose one game at a time. Split APK backups are not supported by this importer.')
apk = apks[0] if apks else None
apk, splits = None, []
if apks:
identities = [(p, *_apk_identity(p)) for p in apks]
bases = [p for p, _, split, _ in identities if not split]
if len(bases) != 1:
raise android.FrameError('Choose one game at a time: exactly one base APK is required.')
apk = bases[0]
package, version = next((pkg, code) for p, pkg, _, code in identities if p == apk)
split_names = set()
for path, pkg, split, code in identities:
if path == apk:
continue
if pkg != package or split in split_names:
raise android.FrameError('Split APKs must have the same package and distinct split names.')
if version is None or code is None or code != version:
raise android.FrameError('Split APK version cannot be verified against the base APK.')
split_names.add(split)
splits.append(path)
if apk:
_check_standalone(apk)
if not splits:
_check_standalone(apk)
info = android.apk_info(str(apk))
try:
android.check_installable(info)
except android.FrameError as error:
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
'blocker': str(error)}
raise
checked = quest.inspect(info['package'], str(apk))
if not splits:
try:
android.check_installable(info)
except android.FrameError as error:
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
'blocker': str(error)}
raise
if splits:
added = android.split_entries(str(apk), [str(p) for p in splits])
if any(name.startswith('lib/') and not name.startswith('lib/arm64-v8a/') for name in added):
raise android.FrameError('This split set includes native libraries for another device. '
'Choose only the arm64-v8a split for the Frame.')
checked = quest.inspect(info['package'], str(apk), [str(p) for p in splits])
pkg = checked['package']
if discover and len(files) == 1:
files.extend(p for p in sorted(apk.parent.iterdir())
@@ -213,13 +255,14 @@ def inspect(paths, discover=False):
if not path.is_file() or not path.stat().st_size or path.name in names:
raise android.FrameError(f'{path.name}: game-data files must be nonempty and have unique names.')
names.add(path.name)
return {'apk': apk, 'obb': obb, 'checked': checked,
return {'apk': apk, 'splits': splits, 'obb': obb, 'checked': checked,
'signatures': {str(p): (p.stat().st_size, p.stat().st_mtime_ns) for p in [*apks, *obb]}}
def public(plan):
checked = plan['checked']
return {**checked, 'apk': plan['apk'].name if plan['apk'] else None,
'splits': [p.name for p in plan['splits']],
'obb': [{'name': p.name, 'size': p.stat().st_size} for p in plan['obb']],
'size': sum(size for size, _ in plan['signatures'].values())}
@@ -243,7 +286,8 @@ def install(plan, display='auto', progress=None):
quest._check_downgrade(android.read_meta(pkg), checked, False)
report('Installing the game app', 15)
meta = android.install(str(plan['apk']), source=plan['apk'].name,
flatscreen=None if display == 'auto' else display == 'flat')
flatscreen=None if display == 'auto' else display == 'flat',
splits=[str(p) for p in plan['splits']])
else:
meta = android._meta_or_fail(pkg)
if not plan['obb']:
+56 -12
View File
@@ -16,9 +16,9 @@ saved Steam clips to ~/Videos/SteamFrame.
- When the headset can't be reached it waits longer each time, up to MAX_BACKOFF.
The server supplies how to reach the headset, so this can be tested without one:
list_shots() -> {"shots": [{"id", "time", "saved"}...]}
list_shots(device) -> {"shots": [{"id", "time", "saved"}...]}
save_shots(device, ids) copies those screenshots here
list_clips() -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
list_clips(device) -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
export_clip(device, id) exports one clip here
device() -> the connected headset's id, or None
busy() -> True while other work runs on the headset (installs, jobs, a recording)
@@ -41,7 +41,7 @@ SETTLE = 30 # a clip written to within this many seconds is still being recorde
MIN_FREE = 512 * 1024**2 # pause below this much free space on this computer
KEEP = 5000 # copied ids remembered per headset and kind
BATCH = 100 # screenshots per copy
TRIES = 3 # an item that fails this often is left until the app restarts
TRIES = 3 # failed attempts per item until Resume, Copy everything now, or restart
KINDS = ("shots", "clips")
@@ -86,6 +86,7 @@ class AutoSync:
self._running = False
self._backoff = 0
self._tries = {} # (device, id) -> failed attempts this run
self._failures = {} # last error for each failed item, including exhausted retries
self._errors = [] # this pass's, for the page
self._s = self._load()
@@ -143,13 +144,19 @@ class AutoSync:
"""Copy everything on the headset that isn't on this computer, once (also resumes)."""
with self._lock:
self._all, self._s["paused"] = True, None
self._tries.clear()
self._failures.clear()
self._s["error"] = None
self._save()
self._backoff = 0
self._wake.set()
return self.state()
def resume(self):
with self._lock:
self._s["paused"], self._s["error"] = None, None
self._tries.clear()
self._failures.clear()
self._save()
self._backoff = 0
self._wake.set()
@@ -215,6 +222,9 @@ class AutoSync:
def _mark(self, device, kind, ids):
with self._lock:
for item in ids:
self._tries.pop((device, item), None)
self._failures.pop((device, item), None)
per = self._s["synced"].setdefault(device, {})
have = per.get(kind, [])
new = [i for i in ids if i not in set(have)]
@@ -233,24 +243,46 @@ class AutoSync:
"then press Resume.")
if self.offline(error):
raise error
key = (device, item)
self._tries[key] = self._tries.get(key, 0) + 1
self._errors.append(f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}")
with self._lock:
key = (device, item)
self._tries[key] = self._tries.get(key, 0) + 1
message = f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}"
if self._tries[key] >= TRIES:
message += ". Automatic retries stopped. Press Resume to try again."
self._failures[key] = message
self._errors.append(message)
def _retry_allowed(self, device, item):
with self._lock:
key = (device, item)
if self._tries.get(key, 0) < TRIES:
return True
self._errors.append(self._failures[key])
return False
def _interrupted(self, device):
return self.busy() or self.device() != device
def _pass(self, device, shots, clips, since):
"""-> (copied, clips still being written, finished without stopping for other work)."""
copied = waiting = 0
if shots:
listing = self.list_shots()["shots"]
if self._interrupted(device):
return copied, waiting, False
listing = self.list_shots(device)["shots"]
if self._interrupted(device):
return copied, waiting, False
done = self._synced(device, "shots")
self._mark(device, "shots", [s["id"] for s in listing if s.get("saved")])
todo = [s["id"] for s in sorted(listing, key=lambda s: s["time"])
if not s.get("saved") and (since is None or (s["time"] >= since and s["id"] not in done))
and self._tries.get((device, s["id"]), 0) < TRIES]
and self._retry_allowed(device, s["id"])]
for i in range(0, len(todo), BATCH):
if self.busy():
if self._interrupted(device):
return copied, waiting, False
self._room(self.shots_folder)
if self._interrupted(device):
return copied, waiting, False
batch = todo[i:i + BATCH]
try:
self.save_shots(device, batch)
@@ -258,28 +290,40 @@ class AutoSync:
for item in batch:
self._failed(device, item, e)
continue
if self.device() != device:
return copied, waiting, False
self._mark(device, "shots", batch)
copied += len(batch)
if clips:
listing = [c for c in self.list_clips()["clips"] if c.get("kind") == "clip"]
if self._interrupted(device):
return copied, waiting, False
listing = [c for c in self.list_clips(device)["clips"] if c.get("kind") == "clip"]
if self._interrupted(device):
return copied, waiting, False
done = self._synced(device, "clips")
self._mark(device, "clips", [c["id"] for c in listing if c.get("saved")])
for c in sorted(listing, key=lambda c: c["time"]):
if c.get("saved") or self._tries.get((device, c["id"]), 0) >= TRIES:
if c.get("saved"):
continue
if since is not None and (c["time"] < since or c["id"] in done):
continue
if not self._retry_allowed(device, c["id"]):
continue
if c.get("idle") is None or c["idle"] < SETTLE:
waiting += 1 # still being written: copied on a later look
continue
if self.busy():
if self._interrupted(device):
return copied, waiting, False
self._room(self.clips_folder)
if self._interrupted(device):
return copied, waiting, False
try:
self.export_clip(device, c["id"])
except Exception as e:
self._failed(device, c["id"], e)
continue
if self.device() != device:
return copied, waiting, False
self._mark(device, "clips", [c["id"]])
copied += 1
return copied, waiting, True
+60 -17
View File
@@ -19,6 +19,7 @@ import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import subprocess
import sys
import tarfile
@@ -88,28 +89,57 @@ def _under(rel, root):
return rel == root or rel.startswith(root + '/')
def _backup_stat(rel):
"""lstat a backup path only if every component below trusted HOME is link-free.
Rechecked at discovery and before reads; not atomic against concurrent renames.
HOME itself may use the host's normal home/temp-directory aliases.
"""
if not safe_rel(rel):
return None
path = HOME
try:
for component in rel.split('/'):
path = path / component
st = path.lstat()
if stat.S_ISLNK(st.st_mode):
return None
return st
except OSError:
return None
def _backup_dir(rel):
st = _backup_stat(rel)
return st is not None and stat.S_ISDIR(st.st_mode)
def _walk(rel_root, skip=()):
"""Regular files under HOME/rel_root, never following links: [(rel, size)], [skipped]."""
files, skipped = [], []
base = HOME / rel_root
if base.is_symlink() or not base.is_dir():
if base.is_file() and not base.is_symlink():
return [(rel_root, base.stat().st_size)], skipped
st = _backup_stat(rel_root)
if st is None or is_secret(rel_root):
return files, skipped
if stat.S_ISREG(st.st_mode):
if st.st_size > MAX_FILE:
skipped.append({'path': rel_root, 'reason': 'over 2 GiB'})
return files, skipped
return [(rel_root, st.st_size)], skipped
if not stat.S_ISDIR(st.st_mode):
return files, skipped
for top, dirs, names in os.walk(str(base)):
rel_top = PurePosixPath(rel_root, *Path(top).relative_to(base).parts).as_posix()
dirs[:] = sorted(d for d in dirs if not os.path.islink(os.path.join(top, d)) and
if not _backup_dir(rel_top):
dirs[:] = []
continue
dirs[:] = sorted(d for d in dirs if _backup_dir(rel_top + '/' + d) and
not any(_under(rel_top + '/' + d, s) for s in skip) and
not is_secret(rel_top + '/' + d + '/x'))
for name in sorted(names):
path, rel = os.path.join(top, name), rel_top + '/' + name
if os.path.islink(path) or is_secret(rel):
continue
try:
st = os.lstat(path)
except OSError:
continue
if not os.path.isfile(path):
rel = rel_top + '/' + name
st = _backup_stat(rel)
if st is None or not stat.S_ISREG(st.st_mode) or is_secret(rel):
continue
if st.st_size > MAX_FILE:
skipped.append({'path': rel, 'reason': 'over 2 GiB'})
@@ -124,26 +154,34 @@ def _manifest_field(text, key):
def game_name(appid):
rel = '%s/steamapps/appmanifest_%s.acf' % (STEAM, appid)
st = _backup_stat(rel)
if st is None or not stat.S_ISREG(st.st_mode):
return None
try:
text = (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).read_text(errors='replace')
text = (HOME / rel).read_text(errors='replace')
except OSError:
return None
return _manifest_field(text, 'name')
def accounts():
root = HOME / STEAM / 'userdata'
rel = STEAM + '/userdata'
if not _backup_dir(rel):
return []
try:
return sorted(p.name for p in root.iterdir() if p.name.isdigit() and p.is_dir()
and not p.is_symlink() and p.name != '0')
return sorted(p.name for p in (HOME / rel).iterdir() if p.name.isdigit() and
p.name != '0' and _backup_dir(rel + '/' + p.name))
except OSError:
return []
def _prefixes():
if not _backup_dir(COMPAT):
return []
try:
return sorted((p.name for p in (HOME / COMPAT).iterdir()
if p.name.isdigit() and (p / PREFIX_USER).is_dir()), key=int)
if p.name.isdigit() and _backup_dir(COMPAT + '/' + p.name + '/' + PREFIX_USER)), key=int)
except OSError:
return []
@@ -244,9 +282,14 @@ def pack(part, output):
with tarfile.open(fileobj=output, mode='w|gz') as archive:
for rel, _ in files:
path = HOME / rel
st = _backup_stat(rel)
if st is None or not stat.S_ISREG(st.st_mode) or is_secret(rel):
continue
try:
with open(str(path), 'rb') as src:
st = os.fstat(src.fileno())
if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_FILE:
continue
member = tarfile.TarInfo(rel)
member.size, member.mtime, member.mode = st.st_size, int(st.st_mtime), st.st_mode & 0o755
archive.addfile(member, src)
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more