Compare commits

..
Author SHA1 Message Date
SAPHID 3a6b13a9b2 Merge remote-tracking branch 'origin/main' into release/v0.4.20 2026-10-24 13:24:54 +00:00
SAPHID 25fe57be10 Bump Frame Control to 0.4.20 2026-10-24 13:06:08 +00:00
227 changed files with 259 additions and 32535 deletions

No files matched your search

+1 -6
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/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
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
- name: App tests
run: node --test app/test/*.test.js
- name: Website
@@ -71,11 +71,6 @@ 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
+1 -39
View File
@@ -10,7 +10,7 @@ on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
@@ -70,9 +70,6 @@ jobs:
- os: macos-latest
script: dist
files: app/dist/*.dmg app/dist/*.zip
- os: macos-15-intel
script: dist:mac:x64
files: app/dist/*.dmg app/dist/*.zip
- os: windows-latest
script: dist:win
files: app/dist/*.exe app/dist/*.zip
@@ -113,20 +110,6 @@ jobs:
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Check the Mac bundle matches its runner
if: runner.os == 'macOS'
shell: bash
run: |
bundle=$(find app/dist -maxdepth 2 -name 'Frame Control.app' -type d -print -quit)
test -n "$bundle"
resources="$bundle/Contents/Resources"
for binary in "$bundle/Contents/MacOS/Frame Control" "$resources/python/bin/python3" "$resources/tools/adb" "$resources/mac/bin/frame-mac-view"; do
lipo "$binary" -verify_arch "$(uname -m)"
done
"$resources/python/bin/python3" -I -B -c 'import http.server, ssl, sqlite3; print(ssl.OPENSSL_VERSION)'
- name: Launch the built Linux AppImage
if: matrix.os == 'ubuntu-latest'
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
# The installer, not just the unpacked build: its 7-Zip payload once dropped
# the OpenXR layer's arm64 library without any error. Install it silently
# and compare every installed file with the unpacked build.
@@ -190,24 +173,3 @@ jobs:
app/dist/*.AppImage
app/dist/*.deb
if-no-files-found: ignore
checksums:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Attach SHA256SUMS after all installers upload
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[]' > releases.jsonl
python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" < releases.jsonl > SHA256SUMS
# Find the same draft using its exact tag, or its version title if a
# draft edit made GitHub temporarily show an untagged-* tag name.
id=$(python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" --id < releases.jsonl)
old=$(gh api "repos/$GH_REPO/releases/$id" --jq '.assets[] | select(.name == "SHA256SUMS") | .id')
for asset in $old; do gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset" >/dev/null; done
gh api -X POST "https://uploads.github.com/repos/$GH_REPO/releases/$id/assets?name=SHA256SUMS" \
-H "Content-Type: text/plain" --input SHA256SUMS >/dev/null
-86
View File
@@ -2,87 +2,6 @@
## 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.
No adapter setting changes or report uploads are made.
- Add recovery steps to the Windows Wireless Adapter doctor for streams that
drop as they start and dongles that will not reconnect. Compare exact PnP IDs
and test one connection condition at a time. In-app disable/re-enable remains
deferred until an active Steam adapter instance can be identified safely.
[Guide and limits](docs/windows-wireless-doctor.md).
- Add an explicit host window-switch button to whole-display panels: Alt-Tab
on Windows and Command-Tab on macOS. Single-window panels keep their own
source, so the button is hidden there.
- Enable the in-app OBS source Start/Stop control on Windows. A control pipe
lets the Python relay close its SSH child on Stop, app quit or parent exit;
add a short [Discord guide](docs/streaming-to-discord.md).
- Add a read-only wired connection doctor for a PC using the Frame's observed
USB-C network: show the local route, SSH port and pinned-key login result
without changing IP, host keys or headset settings.
[Usage and limits](docs/wired-connection.md).
- Start the Android app catalogue without waiting for the headset's installed-app
list. Browsing remains available if that status request is slow or fails;
installation still waits for a confirmed list to avoid duplicate installs.
- Verify the packaged OBS Start and Stop control with a real Frame, and record
37.600 seconds of headset mirror video and non-silent output audio in OBS.
Listening, sync and active passthrough remain unverified.
[Test record](docs/evidence/obs-media-source-real-2026-10-25.md).
- Bundle the OBS source helper in Linux and macOS installers and add a
**Capture & Live** Start/Stop control. The app selects the active Frame and
closes its SSH stream on a headset switch or app exit. A real Frame stream
reached OBS with video and non-silent audio; listening and sync remain
unverified. [Setup and limits](docs/obs-headset-source.md).
- Add a source-checkout helper that sends the Frame's SteamVR mirror and
default-output audio to a local OBS Media Source. A real Frame supplied a
4.021-second H.264/AAC stream to a Linux receiver; OBS playback and sync
remain unverified. [Setup and limits](docs/obs-headset-source.md).
- Link emulator setup guidance from Store → Emulators. Existing Steam library
shortcuts and artwork cover Android apps and sideloaded titles; Android ROM
transfer still needs a verified per-instance workflow.
[Guide](docs/emulators.md).
- Record the current Frame OS mirror and charge-limit checks. Active
passthrough in the mirror is still unknown; this build has no supported
user-writable 80% charge-limit control.
[Headset evidence](docs/evidence/round3-headset-2026-10-25.md).
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
@@ -230,8 +149,3 @@
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,9 +58,6 @@ 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
+8 -68
View File
@@ -25,8 +25,6 @@ See what the headset sees, install games and Android apps, move files and text a
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
[See what was tested on a real Frame, in a VM, or only in automated checks](docs/current-support-matrix.md).
</div>
---
@@ -128,31 +126,12 @@ Choose the operating system and processor of the computer you will use.
| | Download | Needs |
|---|---|---|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
| **macOS** (Intel, v0.4.39+) | [Frame-Control-mac-x64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-x64.dmg) | Nothing extra |
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) |
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
### Is it safe? Check your download
Starting with **v0.4.26**, get the installer and [SHA256SUMS](https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS) from the **same release**. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Use the command for your downloaded file, then compare all 64 characters with its line in `SHA256SUMS`:
```powershell
# Windows PowerShell
Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
```
```sh
# macOS: shasum -a 256 Frame-Control-mac-arm64.dmg
# Linux: sha256sum Frame-Control-linux-x86_64.AppImage
```
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.
日本語: [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: use `mac-arm64`
for Apple Silicon (M-series), or `mac-x64` for Intel (available from v0.4.39). On Windows,
**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,
check Settings → System → About → System type: the release is x64, not a native
Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64`
(arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions.
@@ -174,44 +153,16 @@ distribution's. If you already have `adb`, the app uses yours.
<details>
<summary><b>macOS: the app isn't notarized</b></summary>
The app has an **ad hoc signature**, no Apple Developer ID signature, and no
notarization. The DMG is unsigned. First verify the download against the
`SHA256SUMS` from the same release, then open the DMG and drag **Frame Control**
to **Applications**.
Check your processor before opening: v0.4.38 and earlier publish only
`mac-arm64` for Apple Silicon. On an Intel Mac, this build cannot run;
removing quarantine or installing Rosetta does not make an ARM app run on Intel.
On a real Intel MacBook Air with macOS 15.7.7, opening v0.4.35 and v0.4.36
reported: **“The application cannot be opened because it has an incorrect
executable format.”** This is an architecture failure, not an observed
Gatekeeper warning. The official **v0.4.39 Intel DMG** was then verified,
installed and launched on that Air. Both CLI and T3 browser downloads lacked
quarantine metadata; no Gatekeeper dialog appeared, while `spctl` rejected the
ad hoc signed app. This does not verify Safari/Chrome warnings or Open Anyway.
See the [released Intel test](docs/evidence/mac-v0439-2026-10-26.md) and
[earlier architecture failure](docs/evidence/mac-desktop-2026-10-26.md).
On a compatible Mac, if macOS blocks an unverified developer, try opening the
app once, then go to **System Settings → Privacy & Security → Open Anyway**
and confirm **Open**. Only do this for a download you verified and trust.
[Apple's opening instructions](https://support.apple.com/102445) describe the
current security controls. The exact warning and this override flow have not
yet been verified here on either Mac architecture.
If a trusted, verified copy still reports “damaged” and offers no Open Anyway
option, the Terminal fallback removes only this app's download quarantine
(it does not sign or notarize the app):
There's no paid Apple developer account behind it, so macOS says the app is
damaged or can't be checked. Drag it to Applications, then clear the download
quarantine once, only for a download you trust from the releases linked above:
```sh
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
```
Then open it from Applications. Do not disable Gatekeeper globally. Depending
on the features you use, macOS may also request Local Network access, permission
to control Terminal, or Screen Recording; approve only the feature you intend
to use. Those permission prompts were not verified in the release test above.
The first time, macOS also asks to allow local network access (for SSH) and
control of Terminal (for the password prompts).
</details>
<details>
@@ -235,10 +186,8 @@ or run it with `--appimage-extract-and-run`.
## Open the app
**Optional: open at login.** In the installed desktop app, choose **Frame → Open at Login**. It is off by default. When on, a login launch starts in the tray or menu bar if that control is available; click its icon to open the window, or use its **Quit Frame Control** menu item. Closing the window keeps the app in the tray while this option is on. Turn the same menu option off to remove its OS login item. On Linux this creates or removes only Frame Control's own file in `~/.config/autostart/` (or `$XDG_CONFIG_HOME/autostart/`). If the desktop has no working tray, the window opens at login instead. A source checkout does not offer this setting.
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
open it from Applications. For the Gatekeeper warning, use the macOS steps
open it from Applications. For the unsigned-app warning, use the macOS steps
above. Allow local network access and control of Terminal when prompted.
- **Windows:** run the installer, then open **Frame Control** from Start. For
the portable download, extract the entire `.zip` first and run
@@ -274,14 +223,6 @@ 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
@@ -327,7 +268,6 @@ 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 |
-1
View File
@@ -17,7 +17,6 @@ const REQUIRED = [
"ui/frame_status.py",
"scripts/frame_power_sample.py",
"scripts/frame_power_report.py",
"scripts/obs-headset-source.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
+1 -2
View File
@@ -4,7 +4,7 @@
// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
// packages for every build), into ../frame/kdeconnect/packages as listed in
// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
@@ -15,7 +15,6 @@ const PY = "3.12.14+20260924";
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
+ `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
const PYTHON = {
"mac-x64": ["x86_64-apple-darwin", "7ea9761b9069c10b9a20531d568645849d604c59e9c7f11f6659f1e1790c968e"],
"mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
"win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
"linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
-53
View File
@@ -1,53 +0,0 @@
// Opt-in OS login item. Linux owns only its marked autostart file; an existing
// custom entry is never overwritten or removed.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const MARKER = "# Managed by Frame Control; use Frame > Open at Login to remove.\n";
const FILE = "com.saphid.frame-control.desktop";
function quoteExec(value) {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`").replace(/%/g, "%%")}"`;
}
function createLoginItem({ platform = process.platform, electronApp, env = process.env,
executable = process.execPath, filesystem = fs,
home = os.homedir() }) {
const config = env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)
? env.XDG_CONFIG_HOME : path.join(home, ".config");
const file = path.join(config, "autostart", FILE);
const command = platform === "linux" && env.APPIMAGE && path.isAbsolute(env.APPIMAGE)
? env.APPIMAGE : executable;
function owned() {
try { return filesystem.readFileSync(file, "utf8").startsWith(MARKER); }
catch (err) { if (err.code === "ENOENT") return false; throw err; }
}
function enabled() {
if (platform === "linux") return owned();
return !!electronApp.getLoginItemSettings().openAtLogin;
}
function set(enabledNow) {
if (platform !== "linux") {
electronApp.setLoginItemSettings({ openAtLogin: !!enabledNow, openAsHidden: !!enabledNow,
args: enabledNow ? ["--hidden"] : [] });
if (enabled() !== !!enabledNow) throw new Error("The operating system did not save the login item.");
return enabled();
}
if (enabledNow) {
if (filesystem.existsSync(file) && !owned()) throw new Error(`A different autostart entry already exists: ${file}`);
filesystem.mkdirSync(path.dirname(file), { recursive: true });
const body = MARKER + "[Desktop Entry]\nType=Application\nName=Frame Control\n" +
`Exec=${quoteExec(command)} --hidden\nTerminal=false\nX-GNOME-Autostart-enabled=true\n`;
const tmp = file + `.tmp-${process.pid}`;
try { filesystem.writeFileSync(tmp, body, { mode: 0o600 }); filesystem.renameSync(tmp, file); }
finally { try { filesystem.unlinkSync(tmp); } catch (err) { if (err.code !== "ENOENT") throw err; } }
} else if (owned()) {
filesystem.unlinkSync(file);
}
return enabled();
}
return { enabled, set, file };
}
module.exports = { createLoginItem, quoteExec };
+7 -125
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, Notification, Tray, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -12,7 +12,6 @@ const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const updater = require("./updater");
const { preferenceStore } = require("./preferences");
const { createLoginItem } = require("./login-item");
const { createMetaSignIn } = require("./meta-signin");
const run = promisify(execFile);
@@ -31,12 +30,10 @@ const LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame";
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
const loginItem = createLoginItem({ electronApp: app });
let server = null;
let url = null;
let win = null;
let tray = null;
let quitting = false;
let python = null;
@@ -307,33 +304,6 @@ ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// The OBS source is local to this computer. Closing the Python relay's control
// 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");
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);
},
});
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.
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -344,7 +314,6 @@ 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 (obsSource.status().alias && obsSource.status().alias !== activeAlias()) stopObs();
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
@@ -547,60 +516,7 @@ function registerScheme() {
}
}
function showWindow() {
if (!win) return createWindow(true);
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
function ensureTray() {
if (tray) return true;
try {
const icon = nativeImage.createFromPath(path.join(__dirname, "build", "icon.png"));
if (icon.isEmpty()) return false;
tray = new Tray(icon);
// Some Linux desktops accept the Tray object but expose no status icon.
// Keep a visible login window rather than hiding the only way back in.
if (process.platform === "linux") {
const bounds = tray.getBounds();
if (!bounds.width || !bounds.height) { tray.destroy(); tray = null; return false; }
}
tray.setToolTip("Frame Control");
tray.on("click", showWindow);
updateTrayMenu();
return true;
} catch {
if (tray) { try { tray.destroy(); } catch {} tray = null; }
return false;
}
}
function updateTrayMenu() {
if (!tray) return;
tray.setContextMenu(Menu.buildFromTemplate([
{ label: "Open Frame Control", click: showWindow },
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(), click: toggleLoginItem },
{ type: "separator" },
{ label: "Quit Frame Control", click: () => app.quit() },
]));
}
function toggleLoginItem() {
try {
const on = loginItem.set(!loginItem.enabled());
if (on) ensureTray();
else if (tray) { tray.destroy(); tray = null; }
buildMenu();
updateTrayMenu();
} catch (err) {
dialog.showErrorBox("Open at Login", String(err.message || err));
buildMenu();
updateTrayMenu();
}
}
function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray) {
function createWindow() {
win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false,
@@ -609,10 +525,7 @@ function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray)
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
preload: path.join(__dirname, "preload.js") },
});
win.once("ready-to-show", () => { if (showOnReady) win.show(); });
win.on("close", (event) => {
if (!quitting && tray && loginItem.enabled()) { event.preventDefault(); win.hide(); }
});
win.once("ready-to-show", () => win.show());
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
// External links open in the default browser; the app never navigates away.
win.webContents.setWindowOpenHandler(({ url: target }) => {
@@ -666,8 +579,6 @@ function buildMenu() {
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: () => setUpConnection() },
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(),
enabled: app.isPackaged, click: toggleLoginItem },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
{ type: "separator" },
...(devices.length > 1 ? [{
@@ -716,7 +627,7 @@ if (!app.requestSingleInstanceLock()) {
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
// Windows and Linux start a second instance with the link as an argument.
app.on("second-instance", (_e, argv) => {
if (!argv.includes("--hidden")) showWindow();
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
const link = linkFromArgv(argv);
if (link) openInstallLink(link);
});
@@ -724,41 +635,12 @@ if (!app.requestSingleInstanceLock()) {
if (firstLink) openInstallLink(firstLink);
app.whenReady().then(() => {
registerScheme();
if (loginItem.enabled()) ensureTray();
buildMenu();
createWindow();
scheduleUpdateChecks();
});
app.on("activate", showWindow);
app.on("activate", () => { if (!win) createWindow(); });
app.on("window-all-closed", () => app.quit());
let obsQuitWaiting = false;
let obsQuitReady = false;
app.on("before-quit", (event) => {
quitting = true;
stopServer();
if (!obsSource.child || obsQuitReady) { stopObs(); return; }
event.preventDefault();
if (obsQuitWaiting) return;
obsQuitWaiting = true;
const child = obsSource.child;
let timer;
child.once("close", () => {
clearTimeout(timer);
obsQuitReady = true;
app.quit();
});
stopObs();
timer = setTimeout(() => {
if (child.exitCode === null) {
if (IS_WIN) {
// Only the helper and its SSH child: never enumerate unrelated SSH jobs.
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"],
{ stdio: "ignore", windowsHide: true, detached: true }).unref();
} else child.kill("SIGKILL");
}
obsQuitReady = true;
app.quit();
}, 8000);
});
process.on("exit", () => { stopObs(); stopServer(); });
app.on("before-quit", () => { quitting = true; stopServer(); });
process.on("exit", stopServer);
}
-59
View File
@@ -1,59 +0,0 @@
// 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.40",
"version": "0.4.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.40",
"version": "0.4.20",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+5 -10
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.40",
"version": "0.4.20",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -9,11 +9,10 @@
"scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
"dist:mac:x64": "FRAME_MAC_ARCH=x86_64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac x64 && electron-builder --mac --x64 --publish never"
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
},
"devDependencies": {
"electron": "^44.4.5",
@@ -37,12 +36,10 @@
},
"files": [
"main.js",
"obs-source.js",
"preload.js",
"install-link.js",
"updater.js",
"preferences.js",
"login-item.js",
"meta-signin.js",
"package.json",
"build/icon.png"
@@ -56,8 +53,7 @@
"*.html",
"*.js",
"*.css",
"telemetry.json",
"framey-compatibility.json"
"telemetry.json"
]
},
{
@@ -73,7 +69,6 @@
"to": "scripts",
"filter": [
"*.sh",
"obs-headset-source.py",
"frame_power_sample.py",
"frame_power_report.py"
]
-9
View File
@@ -21,15 +21,6 @@ contextBridge.exposeInMainWorld("frameApp", {
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
obs: {
status: () => ipcRenderer.invoke("obs:status"),
start: (port) => ipcRenderer.invoke("obs:start", port),
stop: () => ipcRenderer.invoke("obs:stop"),
onState: (cb) => {
ipcRenderer.removeAllListeners("obs:state");
ipcRenderer.on("obs:state", (_e, state) => cb(state));
},
},
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
-57
View File
@@ -1,57 +0,0 @@
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { createLoginItem } = require("../login-item");
test("Linux login toggle creates and removes only its own desktop entry", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-login-test-"));
try {
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root },
executable: "/opt/Frame Control/frame-control" });
assert.equal(login.enabled(), false);
assert.equal(login.set(true), true);
const body = fs.readFileSync(login.file, "utf8");
assert.match(body, /^# Managed by Frame Control;/);
assert.match(body, /Exec="\/opt\/Frame Control\/frame-control" --hidden/);
assert.equal(fs.statSync(login.file).mode & 0o777, 0o600);
assert.equal(login.set(false), false);
assert.equal(fs.existsSync(login.file), false);
fs.mkdirSync(path.dirname(login.file), { recursive: true });
fs.writeFileSync(login.file, "[Desktop Entry]\nName=My custom launcher\n");
assert.throws(() => login.set(true), /different autostart entry/);
assert.equal(login.set(false), false);
assert.match(fs.readFileSync(login.file, "utf8"), /My custom launcher/);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test("AppImage autostart launches the AppImage, with safe desktop escaping", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-appimage-test-"));
try {
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root,
APPIMAGE: '/home/user/Frame Control 100%.AppImage' },
executable: "/tmp/.mount_extracted/AppRun" });
login.set(true);
assert.match(fs.readFileSync(login.file, "utf8"), /Exec="\/home\/user\/Frame Control 100%%\.AppImage" --hidden/);
login.set(false);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
for (const platform of ["win32", "darwin"]) {
test(`${platform} login toggle uses the OS API and starts hidden`, () => {
let openAtLogin = false;
const calls = [];
const electronApp = {
getLoginItemSettings: () => ({ openAtLogin }),
setLoginItemSettings: opts => { calls.push(opts); openAtLogin = opts.openAtLogin; },
};
const login = createLoginItem({ platform, electronApp });
assert.equal(login.enabled(), false);
assert.equal(login.set(true), true);
assert.deepEqual(calls.at(-1).args, ["--hidden"]);
assert.equal(calls.at(-1).openAsHidden, true);
assert.equal(login.set(false), false);
assert.deepEqual(calls.at(-1).args, []);
});
}
-86
View File
@@ -1,86 +0,0 @@
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);
});
+4 -10
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 base APK, any supported splits, and its OBB files together.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK 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,13 +37,9 @@ 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 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
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
existing route. Quest USB and Meta library imports keep their existing workflow.
Validation covers actual APK parsing, the three-file backup layout, package
@@ -59,5 +55,3 @@ 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.
+9 -11
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
base APK, any supported splits, and its matching `main.<version>.<package>.obb` and/or
standalone APK 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 base APK, optional supported splits and matching OBBs. These extensions describe
one standalone APK and optional 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
100-file/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
one-APK/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,14 +463,12 @@ 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 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.
**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.
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,
+2 -23
View File
@@ -100,21 +100,7 @@ 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, 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.
- Symbolic links (never followed), and single files over 2 GiB.
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.
@@ -206,18 +192,11 @@ 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 in save/settings roots and their ancestors refused, the manifest
and its checksums, restore checks
folders), links never followed, 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.
-5
View File
@@ -9,11 +9,6 @@ only control the Frame has is a vendor charger attribute that Valve's own
charging daemon also manages. Research as of 2026-10-23, confirmed with the
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
Rechecked read-only on build `20261022.6153909` on 2026-10-25: the
`BatteryChargeLimit1` interface still returned `UnknownInterface`, no
`charge_control_*` sysfs attribute existed, and the vendor charging switches
were root-owned mode `644`. See [Round 3 headset evidence](evidence/round3-headset-2026-10-25.md).
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
-37
View File
@@ -1,37 +0,0 @@
# 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 ネットワークが使えるかは、まだ確認できていません。
-30
View File
@@ -1,30 +0,0 @@
# What we have tested
Release baseline: Frame Control **v0.4.39**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “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 |
|---|---|---|---|---|
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG verified, installed and launched. Native UI, preference persistence across restart (original file restored), bundled Python logs, update check, offline OBS Start/Stop and 116-entry Decky store checked. A 40-byte relay upload/readback/delete passed under coordinator/headset locks | No | No quarantined Safari/Chrome download or Gatekeeper override test; native Open at Login/menu automation unavailable. Direct frame.local had no route and the app refused a stale SSH identity. Relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Quit exposed a server cleanup race; fix #284 has physical-Mac regression coverage, not a released fix | 26 Oct, official v0.4.39 tag `fd0b0a9`: [Intel release evidence](evidence/mac-v0439-2026-10-26.md); [earlier ARM64 failure](evidence/mac-desktop-2026-10-26.md) |
| Core title smoke test | Linux lxso2 installed and removed reserved native/Proton titles on the real Frame; all 13 steps passed. ARM64 and Windows titles launched | No | The x86_64 title could not launch because Steam Linux Runtime 4.0 was not installed. The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, v0.4.40 candidate source `9aa26a8`, Frame OS 0.5.6 build `20261022.6153909`: [latest 13/13 smoke](evidence/v0440-candidate-frame-2026-10-26.md); [earlier tagged smoke](evidence/v0438-release-smoke-2026-10-26.md) |
| 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.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 | Physical Intel Air: official v0.4.39 native Start/Stop returned to idle offline | No | Real headset stream, OBS receiver and audio/video remain unverified; the app rejected its stale SSH identity | 26 Oct, v0.4.39: [Mac evidence](evidence/mac-v0439-2026-10-26.md), [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) |
| 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 | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.39: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [latest candidate sample exercise](evidence/v0440-candidate-frame-2026-10-26.md), [earlier standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. Post-release main after PR #286 classifies the 116 exact-version plugins as 1 works (Notebook default flow only), 49 partial, 55 broken and 11 not applicable; v0.4.39 shipped the earlier 1/45/59/11 catalog. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Three official audio plugins had local real-backend controls and independent virtual-audio readbacks, but their ARM64 Frame audio sessions and physical output remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.39: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; post-release PR #282 recorded 101 local Chromium initializers ready and 15 failed, up from 98/18 in v0.4.39. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.39: [latest unworn sample evidence](evidence/framey-vr-standby-gate-2026-10-26.md), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [post-release sample exercise](evidence/v0439-release-2026-10-26.md#post-release-framey-library-change-282), [worn checklist](framey-vr-check.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.
-237
View File
@@ -1,237 +0,0 @@
# 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).
### Which of these plugins can open a Framey panel?
The separate Framey panel accepts a Frame Control store install with a supported
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
A backend load does not bypass this frontend check. We downloaded the 11 versions above
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
catalogue. All 11 had a frontend bundle, but their manifests differed:
| Manifest in the checked archive | Plugins | Framey panel result |
| --- | --- | --- |
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
has each store ID, exact version, SHA-256, manifest field, and bundle check.
The archive audit itself is static PC inspection. Later local host and fake
Frame tests cover the Storage Cleaner, HLTB, CSS Loader and SteamGridDB cases noted above; none shows
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
remain the only device evidence for these versions. A new store version needs a
new check because its manifest and binaries can change.
Pinned CSS Loader 2.1.3 and SteamGridDB 1.8.1 archives now pass the fake Frame
managed install/remove flow. CSS Loader's `plugin.json` has no `api_version` and
its older IIFE bundle passes the Framey packaging check added in
[#261](https://github.com/saphid/frame-control/pull/261). The fixture does not
render its theme controls. SteamGridDB declares API v1 and its bundle passes Framey's packaging
check, but it looks up Steam library modules while loading and patches game
context menus. Those modules and menus do not exist in Framey's isolated
iframe, so its artwork picker cannot be called a usable VR panel. Framey does
not apply its Steam library changes. These checks did not run CSS themes or
modify game artwork on the real Frame.
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. An update is also refused if its
new plugin name is already used by another installed plugin, because Decky
routes backend calls by name. This collision check has local tests only.
For a managed plugin with a valid recorded SHA-256, Update and Remove recheck
that installed receipt under the headset transaction lock. If another store
action changed it after the list was read, refresh the installed list and
review the action again. This race check has local transaction tests; older
receipts without a valid hash still use the existing ownership check.
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 hosts
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
with settings, local routes, and panel toasts where supported.
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-decky-api-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.
-54
View File
@@ -1,54 +0,0 @@
# Emulators and your own games
Frame Control's **Store → Emulators** lists Android emulators from its open
app sources. Pick an app, read its compatibility result, and install it. An
installed Android app gets its own Steam library shortcut and artwork; **Add
artwork** in its library menu can fill missing images later. The artwork
workflow also covers native Linux and Windows titles added through **Send to
Frame**. An optional SteamGridDB key under **Settings → Library artwork**
can supply exact-title community images; source images and generated art are
fallbacks. See [VR APKs → In the Steam library](vr-apks.md#in-the-steam-library)
for the limits and tested behavior.
## Set up an emulator
1. In **Store → Emulators**, choose an emulator that supports the console and
file format you want. Check its compatibility result before installing;
an app listed in the store is not automatically verified on the Frame.
2. Launch the new shortcut from Steam. Open the emulator's own settings to
choose a gamepad layout, core or system, and game folder. These controls
differ by emulator; Frame Control does not change them.
3. Use game files you own and any firmware that emulator's instructions
require. Frame Control does not provide ROMs or firmware.
**Android apps run in separate Lepton instances.** Copying a ROM to the
Frame's Linux `~/Downloads` through **Send to Frame** does not automatically
put it in an Android app's `/sdcard/Download`. A one-click ROM transfer is
deferred until an emulator and its file picker can be tested end to end on a
real Frame. An app's own import or sync feature may be enough. Advanced users
can use [Lepton's per-instance ADB port](apks.md)
to copy into that instance, after confirming the correct container and port.
Do not assume that port 5555 is the emulator: it is Lepton Development, while
installed apps receive their own ports.
For a native Linux emulator that can read the Frame's home directory, use
**Send to Frame → Copy to ~/Downloads instead** for your files, then open them
from the emulator. Whether a particular emulator has a Frame-compatible ARM64
build, correct controller mapping and readable storage must be checked for
that app. **Linux apps** in Frame Control lists Flatpak options; a Windows or
native game archive can instead be added through
[Send to Frame](sideloading.md#using-it), which registers a Steam title and
artwork. Each ROM is usually opened *inside* its emulator; Frame Control does
not create one Steam shortcut per ROM.
## If something does not appear
- Check that the emulator is running and is the same app instance to which
you sent the file. Android instances do not share `/sdcard` storage.
- Check the emulator's supported extension and whether it expects an archive
or an extracted file. Some systems need firmware before games appear.
- For an empty Steam tile, use **Add artwork** in the installed app or
sideloaded title menu. This changes artwork, not emulator data or ROMs.
The Frame's microSD/USB path into Android apps remains unverified without an
inserted medium and an app readback test; see [issue #169](https://github.com/saphid/frame-control/issues/169).
@@ -1,79 +0,0 @@
# 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.
@@ -1,20 +0,0 @@
# API v1 panel check, 25 October 2026
The unmodified official Storage Cleaner 1.4.0 archive (store ID 50, SHA-256
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`)
declares `api_version: 1`, but its bundled frontend connects to Decky's modern
frontend API. Framey admitted that bundle from a local store receipt and
rendered it in Chromium inside an iframe with `sandbox="allow-scripts
allow-popups"`.
A test parent returned empty lists and `0B` for the plugin's two list and two
size backend calls. Chromium reported `framey:ready`, displayed Shader Cache
and Compatibility Data sections, and showed all four cleanup buttons disabled.
No cleanup action was invoked. The backend was a stub; the test did not run the
plugin's Python code or connect to a headset.
This confirms a panel shell for this exact archive with empty data. Storage
Cleaner may remove compatibility data and local game saves when used with a
real backend. Its cleanup actions, game names, SteamClient app details, VR
visibility, and controller input remain unverified on Frame. Do not infer that
its deletion controls are safe or complete in VR from this browser check.
@@ -1,677 +0,0 @@
{
"schemaVersion": 1,
"timeUtc": "2026-10-26T01:30:56.939937+00:00",
"baseCommit": "442b5ec",
"hostChange": "SliderField editableValue numeric input",
"verification": "local Chromium + real Decky 3.3.0 amd64 container; no real Frame/worn VR",
"decky": {
"tag": "v3.3.0",
"name": "PluginLoader",
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
},
"audioVersions": {
"pipewire": "1.4.2",
"wireplumber": "0.5.8",
"pactl": "17.0"
},
"plugins": [
{
"id": 38,
"name": "Volume Mixer",
"version": "1.2.1-1",
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
"folder": "VolumeMixer-decky"
},
{
"id": 58,
"name": "Volume Boost",
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
"folder": "volume-boost"
},
{
"id": 144,
"name": "Mixer",
"version": "0.1.0",
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
"folder": "decky-volume-mixer"
}
],
"isolation": {
"network": "none",
"devices": "none",
"hostAudioSockets": "none",
"audio": "private PipeWire/Pulse null sink; two silent streams; WirePlumber policy-only"
},
"fixtureBoundaries": [
"Outer headset status/installed-plugin responses were fixture-shaped. Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe and unmodified official bundles ran in Chromium.",
"The relay used Decky 3.3.0 official websocket RPC routes directly inside the isolated container; Steam CEF and the production SSH/CDP method transport were not part of this audio run.",
"Actual published amd64 Decky binary and official unmodified Python backends executed. No ARM64 or real Frame audio claim.",
"Previous full-catalog fake-Frame evidence tests archive/install/remove transport only; its backends remain stubbed."
],
"sharedHeadsetLock": {
"acquired": false,
"owner": "delightvr-r4-a56d43b4c0",
"headsetReadsOrChanges": false
},
"audioBefore": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "45%",
"front-right": "50%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
},
"mutatingCalls": [
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 75,
"channel": "both"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "75%",
"front-right": "75%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 74,
"channel": "both"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "74%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 73,
"channel": "both"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "73%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 75,
"channel": "right"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "75%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 74,
"channel": "right"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 38,
"method": "set_input_volume",
"args": [
{
"index": 46,
"volume": 0.5
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "50%",
"front-right": "50%"
}
}
]
}
},
{
"id": 38,
"method": "set_input_volume",
"args": [
{
"index": 46,
"volume": 0.45
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "45%",
"front-right": "45%"
}
}
]
}
},
{
"id": 144,
"method": "set_volume",
"args": [
41,
37
],
"result": true,
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "37%",
"front-right": "37%"
}
}
]
}
},
{
"id": 144,
"method": "set_volume",
"args": [
41,
38
],
"result": true,
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "38%",
"front-right": "38%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 42,
"channel": "left"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "42%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "38%",
"front-right": "38%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 42,
"channel": "right"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "42%",
"front-right": "42%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "38%",
"front-right": "38%"
}
}
]
}
},
{
"id": 144,
"method": "save_settings",
"args": [
{
"filters": [
"FrameySilent0"
],
"boostLimit": 0
}
],
"result": true
},
{
"id": 144,
"method": "set_volume",
"args": [
41,
39
],
"result": true,
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "42%",
"front-right": "42%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "39%",
"front-right": "39%"
}
}
]
}
}
],
"checks": {
"volumeMixer38": "Discovery, slider click and ArrowDown: only FrameySilent1 changed 100\u219250\u219245%; FrameySilent0 stayed100%.",
"volumeBoost58": "Sync both channels 75\u219274\u219273%; unsynced right 75\u219274% preserved left73%; reopen restored unchecked sync and73/74. Numeric left42 preserved right74; re-sync set right42 and disabled both right inputs.",
"mixer144": "Numeric37\u219238\u219239 changed FrameySilent1 only; server readbacks37/38/39%. Invalid101 and off-step37.5 produced no set_volume RPC. Filter FrameySilent0 saved with boostLimit0, survived actual Decky shutdown/restart and reopened showing only FrameySilent1 at38%.",
"invalidNumericBrowserMessages": {
"inputs": [
"101",
"37.5",
"39"
],
"setVolumeMessages": [
{
"method": "set_volume",
"args": [
41,
39
]
}
]
},
"notRun": [
"boost above100%",
"Bluetooth playback commands",
"reset_volumes",
"physical headset audio",
"worn VR",
"VR keyboard/controller input"
]
},
"pipewireConfig": "context.properties = { core.daemon = true core.name = framey-audio }\ncontext.spa-libs = { audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }\ncontext.modules = [\n { name = libpipewire-module-protocol-native }\n { name = libpipewire-module-access }\n { name = libpipewire-module-spa-node-factory }\n { name = libpipewire-module-client-node }\n { name = libpipewire-module-client-device }\n { name = libpipewire-module-adapter }\n { name = libpipewire-module-metadata }\n { name = libpipewire-module-link-factory }\n { name = libpipewire-module-session-manager }\n]\ncontext.objects = [\n\n { factory = spa-node-factory args = { factory.name = support.node.driver node.name = Dummy-Driver node.group = pipewire.dummy priority.driver = 20000 } }\n { factory = adapter args = { factory.name = support.null-audio-sink node.name = framey-silent-test node.description = \"Framey Silent Test\" media.class = Audio/Sink audio.position = [ FL FR ] } }\n]",
"remaining": "All three remain partial until real ARM64 Frame dependencies and audio-session mapping are verified under the shared lock.",
"cleanup": {
"testContainerRemoved": true,
"browserRelayStopped": true,
"headsetUntouched": true,
"testServicesOnHeadset": false
},
"catalogRefresh": {
"checkedAtUtc": "2026-10-26T01:34:07.257386+00:00",
"plugins": 116,
"idsVersionsDigestsUnchanged": true
},
"regression": {
"serverTests": {
"count": 1612,
"skipped": 16,
"result": "OK"
},
"siteTests": {
"passed": 2,
"failed": 0
},
"focusedFrameyTests": 38,
"officialFrontendAudit": {
"attempted": 116,
"ready": 98,
"failed": 18,
"readyStateChanges": [],
"initializerOnly": true,
"backendAndNetworkDenied": true
}
}
}
@@ -1,106 +0,0 @@
# Official audio plugins: local real-backend checks
Volume Mixer 1.2.1-1 (38), Volume Boost 0.0.7 (58) and Mixer 0.1.0 (144)
passed useful audio controls in local Chromium with their **unmodified official
Python backends running in actual Decky 3.3.0**. All three remain **partial**:
this run used amd64 Linux, and Frame ARM64 dependencies/audio-session mapping
are unverified. The shared headset lock was occupied; no headset reads or
changes occurred. No worn-VR result is claimed.
The private test container and browser relay were removed after verification.
The [machine-readable observations](framey-audio-backends-2026-10-26.json)
pin all three official archives and the published Decky binary digest, and
record each browser-triggered write with independent `pactl` channel readbacks.
| Plugin | Useful behavior checked | Still unverified on Frame |
| --- | --- | --- |
| Volume Mixer | Discover two playback streams; slider click and ArrowDown change only the selected stream, 100 → 50 → 45%; other stream remains 100% | ARM64 libpulse/libdbus, actual user audio session, Bluetooth playback controls |
| Volume Boost | Both-channel slider; separate right-channel change preserves left; reopen restores sync preference and actual values; exact numeric left entry; syncing disables both right controls | `pactl`, default sink, hard-coded runtime UID, physical output, boost above 100% |
| Mixer | Exact stream volume 37 → 38%, independently read back; invalid 101 and off-step 37.5 produce no write; playback filter saved, survives actual Decky restart, hides the intended stream on reopen | `pw-dump`/`wpctl`, actual Frame session, physical output, worn VR |
## Reproduced host gap
Both Volume Boost and Mixer request `SliderField.editableValue`. Framey
previously ignored that prop and rendered only a range slider. It now supplies
an accessible numeric input with the same minimum, maximum, step and disabled
state, and emits numeric `onChange` values. Empty, non-finite, out-of-range and
browser-invalid drafts do not reach a backend. No official archive was patched.
This also makes the control available to other plugins requesting that prop;
it does not establish their compatibility.
## Runtime and isolation
The disposable Debian 13 container ran PipeWire 1.4.2, WirePlumber 0.5.8 and
PulseAudio client tools 17.0. Docker used `--network none --cap-drop ALL
--security-opt no-new-privileges`, no host audio sockets and no sound devices.
Only the archive cache and test inputs were mounted, read-only. Debian 12 was
insufficient for the published Decky binary's GLIBC requirement; that initial
runtime did not start.
Private environment:
```text
XDG_RUNTIME_DIR=/tmp/framey-audio-runtime
PIPEWIRE_REMOTE=framey-audio
PULSE_SERVER=unix:/tmp/framey-audio-runtime/pulse-native
DBUS_SYSTEM_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
```
XDG config/data/state and both Decky paths were below the private test base.
Decky ran unprivileged with `CHOWN_PLUGIN_PATH=0`, `LIVE_RELOAD=0`,
`USE_CEF_CLOSE_WORKAROUND=0` and `KEEP_SYSTEMD_SERVICE=1`. A private D-Bus
session satisfied Volume Mixer's import; there was no real BlueZ service.
Its fixed `/tmp/VolumeMixer-decky.log` existed only inside the container.
The JSON includes the complete PipeWire server configuration: only a dummy
driver and null stereo sink, no hardware factories. A second PipeWire process
loaded protocol-native, client-node, adapter, metadata and protocol-pulse with
the private absolute socket. `wireplumber --profile policy` managed this server;
that installed profile contains no hardware monitors. Only private default-sink
metadata was selected. Two `paplay --raw --format=s16le --rate=48000 --channels=2
/dev/zero` clients supplied silence named `FrameySilent0` and `FrameySilent1`.
Without policy, successful volume RPCs initially left readbacks unchanged; those
fixture attempts are excluded from the passing observations.
The browser relay served actual `ui/headset.html`, vendored React 18.3.1,
`ui/framey-host.js` and the exact archived frontends in an opaque `allow-scripts`
iframe. Outer headset/installed-plugin responses were fixture-shaped. Backend
RPC used the official Decky websocket protocol inside the isolated container:
`loader/call_legacy_plugin_method` with keyword objects for 38/58 and
`loader/call_plugin_method` with positional arguments for 144. This bypassed
Steam CEF and the production SSH/CDP transport; neither is an audio verification
claim from this run.
The relay allowed discovery/settings reads, bounded volume writes only to
identified `FrameySilent` streams/the private default sink, and Mixer filters
`[]` or `["FrameySilent0"]` with boost limit 0. It denied Bluetooth commands,
`reset_volumes` and boosts. Every volume write was followed by a separate
`pactl -f json list sinks` / `list sink-inputs` read. A clean Decky shutdown and
restart retained Mixer's actual settings file and filter behavior.
Earlier full-catalog fake-Frame evidence for these exact digests covers
install/receipt/frontend/removal transport, with plugin execution stubbed.
The new evidence adds actual local backend execution; it does not turn that
older fake-Frame result into an ARM64 backend test.
## Regression checks
The required server suite passed 1,612 tests (16 skips); all 38 focused Framey
tests and both website tests passed. The 116-plugin browser audit retained
98 initializer-ready / 18 failed, with no change to any ready state. That audit
keeps backend/network actions denied. A fresh official catalog fetch confirmed
all 116 IDs, versions and digests unchanged.
## Next device check
Acquire the normal atomic `/tmp/frame-test.lock`; refuse an existing owner.
First inspect available tools/libraries and audio runtime UID. If supported,
repeat with a private ARM64 Decky user service, isolated audio socket, dummy
sink/streams and hardware discovery disabled. Check Volume Mixer's fixed log
path for collisions before loading it. Never attach the fixture to the normal
headset output/default server. Remove only test-owned services/processes/files,
read back original service/battery state and release only the matching lock.
Keep all three partial until that check passes; physical output, Bluetooth and
worn-VR controls need separate evidence.
@@ -1,16 +0,0 @@
# Bluetooth 3.1.1 compatibility fixture, 25 October 2026
The unmodified official Decky store archive for Bluetooth 3.1.1 (store ID 18,
SHA-256 `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5`)
passes Frame Control's archive rules. The fake Frame e2e harness installed it
through the store API, verified the managed receipt and frontend bytes, then
removed it. An earlier real Frame check recorded the backend's `Bluetooth
plugin loaded` line while SteamVR was in standby.
In a local Node host test, its official frontend starts and builds an initial
panel tree. Framey supplies empty Steam token maps and readable English
fallbacks for the three Bluetooth labels that lack plugin-owned fallbacks.
The test leaves React effects and click
handlers inert and sends no backend calls. It does not inspect paired devices,
change Bluetooth state, exercise a controller, or prove the panel is visible in
VR. No worn-headset check has passed for this plugin.
@@ -1,36 +0,0 @@
# 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.

Before

Width:  |  Height:  |  Size: 62 KiB

@@ -1,19 +0,0 @@
# Framey cache check, 25 October 2026
The official Steam Status 1.2.3 ZIP pinned in
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
allow-popups"`, so its native storage remained unavailable. The new Framey
storage facade accepted a set/get probe without a `SecurityError`. Steam
Status rendered and issued four `get_settings` calls to a stub parent backend.
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
snapshot and the plugin wrote the same cache key again. The browser log had
zero `SecurityError` or `localStorage` errors.
The parent was a local test page with in-memory Decky backend replies. One
public status fetch also failed in the test browser, so this check does not
establish offline correctness or the freshness of Steam Status data. The
per-plugin save/reopen protocol, stale panel session rejection, and cache
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
live Decky settings, VR visibility, and controller input remain unverified.
@@ -1,56 +0,0 @@
{
"dateUtc": "2026-10-26",
"plugin": {
"storeId": 135,
"name": "Deck Shelves",
"version": "2.0.0",
"sha256": "b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91"
},
"mode": "opaque allow-scripts iframe in local Chromium, exact archived frontend, backend and network denied",
"baselineError": "relative JavaScript chunk imports cannot resolve from a blob URL",
"result": {
"initializerReady": true,
"error": null,
"moduleResponses200": 9,
"settingsVisible": [
"Enable shelves on Home",
"Create default shelves",
"Behavior",
"Shelves"
],
"attemptedBackendMethods": [
"get_settings"
],
"backendMethodsExecuted": [],
"steamLibraryRead": "denied by audit",
"pluginNetwork": "denied by audit"
},
"limits": [
"No Steam Home shelf injection or settings write was verified",
"No official backend executed",
"No official Deck Shelves interaction occurred on the real Frame or in worn VR",
"Settings visibility alone does not prove the plugin works"
],
"realFrameGate": {
"sourceCommit": "b0c4647b856035c970ae27ea4e2ddbf6e51f6963",
"headsetOsBuild": "20261022.6153909",
"preflightActivity": 3,
"preflightBatteryPercent": 100,
"postBatteryPercent": 99,
"sampleExercisePassed": true,
"sampleSettingPersistedAfterFreshIframe": true,
"syntheticOpenVrPointerTrusted": true,
"stereoPixelsChanged": true,
"mirrorCapturePresent": true,
"nativeScrollMoved": false,
"powerValuesRestored": true,
"recoveryTimerRemoved": true,
"lockReleased": true,
"cleanupErrors": [],
"genericFrameSmokePassed": 13,
"genericFrameSmokeFailed": 0,
"officialDeckShelvesBackendOrVrTested": false,
"physicalControllerVerified": false,
"wornVisualComfortVerified": false
}
}
@@ -1,13 +0,0 @@
# Deck Shelves chunked frontend, 26 October 2026
The exact official Deck Shelves 2.0.0 archive (store ID 135, SHA-256 `b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91`) contains `dist/index.js` and eight JavaScript chunks. Its API v1 `connect(1, "Deck Shelves")` bridge sits in `chunks/decky-api-WgbNuFYS.js`, not in the entry file; the original static scanner therefore reported its frontend API as unknown. Framey's prior blob import failed before initialization because a blob URL has no directory in which to resolve `./chunks/...`. The [numeric observation](framey-deck-shelves-browser-2026-10-26.json) and [local Chromium capture](framey-deck-shelves-browser-2026-10-26.png) record a targeted follow-up using the unchanged official frontend.
Framey now serves JavaScript files from the managed plugin's `dist` directory using hierarchical module URLs. Its opaque `allow-scripts` iframe still has no Frame UI key. Local Chromium received HTTP 200 for the entry and eight chunks, reported the initializer ready, and rendered the settings panel with “Enable shelves on Home,” “Create default shelves,” “Behavior,” and “Shelves.” The browser audit denied the attempted `get_settings` backend call, Steam library read and plugin network access. No backend method executed.
The catalog moves this exact version from broken to **partial** for visible local settings only. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Settings writes, official backend behavior, real Frame module serving, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
## Exact source real Frame gates
The exact source commit `b0c4647b856035c970ae27ea4e2ddbf6e51f6963` ran the unattended `scripts/framey-vr-check.py --exercise` on SteamOS build `20261022.6153909` under its unique shared-lock owner. Preflight found SteamVR activity 3 (standby), battery 100%, and proximity 6.25. The repository sample panel appeared, stereo compositor pixels changed, and `/dev/video99` supplied a mirror frame. Synthetic OpenVR pointer input changed the sample setting through actual Decky; the value survived a fresh iframe reopen. Native scroll did not move. The runner removed its temporary services and recovery timer, read back the original `false`/`5` power values, found battery 99%, reported no cleanup errors, and released its lock.
The same source then passed `scripts/frame-smoke.sh` **13/13** on the real Frame, starting from standby at battery 99%. Its final cleanup step passed. The [sanitized result fields](framey-deck-shelves-browser-2026-10-26.json) omit room pixels, raw overlays, identifiers, and settings contents; private captures were not committed. These gates exercise the Framey repository sample and generic install/launch/remove paths. They do **not** exercise Deck Shelves' official ARM64 backend or prove its settings or shelves in VR. No person wore the headset and no physical controller was checked.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

@@ -1,418 +0,0 @@
{
"checkedAtUtc": "2026-10-26T00:12:47.296778+00:00",
"method": "Local Chromium actual Framey headset page and opaque official plugin iframe; production method/event bridge relayed by SSH to real ARM64 Decky 3.3.0 in a private removable user service. Test relay restricts RPC methods and timer durations. Backend code is unmodified, no fake plugin replies. Only file paths to private install and outer status/data APIs differ. Not worn VR.",
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"hostSourceSha256": {
"framey-host.js": "3bad52546c0b78c20e6479c91899f1f3864b74687c6fce37e776abededfb5f1c",
"framey-events.js": "89046f4c89bdab0f578b910170a044d5ada540db0f7b71d6a370c68b4b008c3f",
"framey_panel.py": "8ca4b3ba5f0d51c51bb3601f9ccd24c4af397fbf8f63c383a3042e1ae3f7b39b",
"headset.html": "ebaf1711639a929ec7287c01dbbbe8eb08f000b852fdc877f31c9d99e5fe69fc"
},
"before": {
"battery": [
100
],
"decky": "inactive",
"panel": "inactive"
},
"after": {
"battery": [
100
],
"decky": "inactive",
"panel": "inactive"
},
"powerSettingsChanged": false,
"plugins": [
{
"id": 19,
"name": "Notebook",
"version": "0.1.5",
"sha256": "c35b09de4bba4b4157fbe84cb7b0d20fc3924f78fa3c48ae17686b4314c1cc0c",
"calls": [
{
"id": 19,
"method": "listPages",
"args": [
{
"appid": 0
}
],
"result": {
"result": {
"result": [],
"success": true
}
}
},
{
"id": 19,
"method": "loadLastSelectedPage",
"args": [
{
"appid": 0
}
],
"result": {
"result": {
"result": 0,
"success": true
}
}
},
{
"id": 19,
"method": "loadPage",
"args": [
{
"appid": 0,
"page": 0
}
],
"result": {
"result": {
"result": {
"page": 0,
"timestamp": 0,
"empty": true,
"data": ""
},
"success": true
}
}
},
{
"id": 19,
"method": "savePage",
"args": [
{
"appid": 0,
"page": 0,
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
}
],
"result": {
"result": {
"result": {
"timestamp": 1792973680303
},
"success": true
}
}
},
{
"id": 19,
"method": "listPages",
"args": [
{
"appid": 0
}
],
"result": {
"result": {
"result": [
{
"page": 0,
"timestamp": 1792973680303,
"empty": false
}
],
"success": true
}
}
},
{
"id": 19,
"method": "loadLastSelectedPage",
"args": [
{
"appid": 0
}
],
"result": {
"result": {
"result": 0,
"success": true
}
}
},
{
"id": 19,
"method": "loadPage",
"args": [
{
"appid": 0,
"page": 0
}
],
"result": {
"result": {
"result": {
"page": 0,
"timestamp": 1792973680303,
"empty": false,
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
},
"success": true
}
}
}
],
"events": [],
"checks": {
"pointerStrokeSaved": true,
"sameStrokeLoadedAfterBackendRestart": true,
"defaultAppId": 0,
"existingNotesTouched": false
}
},
{
"id": 96,
"name": "Simple Timer",
"version": "1.0.0",
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312",
"calls": [
{
"id": 96,
"method": "load_remaining_seconds",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_subtle_mode",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_recents",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "start_timer",
"args": [
300
],
"result": {
"result": null
}
},
{
"id": 96,
"method": "cancel_timer",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "start_timer",
"args": [
5
],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_remaining_seconds",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_recents",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_subtle_mode",
"args": [],
"result": {
"result": null
}
}
],
"events": [
{
"sequence": 1,
"event": "simple_timer_seconds_updated",
"args": [
0
]
},
{
"sequence": 2,
"event": "simple_timer_subtle",
"args": [
true
]
},
{
"sequence": 3,
"event": "simple_timer_refresh_recents",
"args": [
[]
]
},
{
"sequence": 4,
"event": "simple_timer_refresh_recents",
"args": [
[
300
]
]
},
{
"sequence": 5,
"event": "simple_timer_seconds_updated",
"args": [
300
]
},
{
"sequence": 6,
"event": "simple_timer_seconds_updated",
"args": [
294.9945251941681
]
},
{
"sequence": 7,
"event": "simple_timer_seconds_updated",
"args": [
289.99201130867004
]
},
{
"sequence": 8,
"event": "simple_timer_seconds_updated",
"args": [
284.9877219200134
]
},
{
"sequence": 9,
"event": "simple_timer_seconds_updated",
"args": [
0
]
},
{
"sequence": 10,
"event": "simple_timer_refresh_recents",
"args": [
[
5,
300
]
]
},
{
"sequence": 11,
"event": "simple_timer_seconds_updated",
"args": [
5
]
},
{
"sequence": 12,
"event": "simple_timer_seconds_updated",
"args": [
-0.005679607391357422
]
},
{
"sequence": 13,
"event": "simple_timer_seconds_updated",
"args": [
0
]
},
{
"sequence": 14,
"event": "simple_timer_event",
"args": [
"Your session has ended!",
true
]
},
{
"sequence": 1,
"event": "simple_timer_seconds_updated",
"args": [
0
]
},
{
"sequence": 2,
"event": "simple_timer_refresh_recents",
"args": [
[
5,
300
]
]
},
{
"sequence": 3,
"event": "simple_timer_subtle",
"args": [
true
]
}
],
"checks": {
"uiStart300Seconds": true,
"uiCancel": true,
"shortTimer5Seconds": true,
"completionToastVisible": true,
"recentTimersAndSubtleModeSurvivedRestart": true,
"subscriptionsAfterPanelClose": 0
},
"upstreamError": "Simple Timer 1.0.0 _main after restarting with an expired timer raises AttributeError: 'Plugin' object has no attribute 'timer_task' (main.py:94,129). Frontend read requests still load recent timers and subtle mode."
}
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"serviceActive": false,
"independentlyRechecked": true,
"lockReleased": true
},
"regressions": {
"unitTests": 1606,
"unitSkips": 16,
"browserPlugins": 116,
"browserReady": 96,
"browserFailed": 20,
"fakeFrameInstalled": 60,
"fakeFrameRejected": 56
},
"limitations": [
"Timer sound asset and suspend action unsupported; no suspend/power action run.",
"Closing a Framey panel removes its event channel; it is not a global background notification service.",
"Notebook default notebook only (appid 0): no per-game auto-selection or background artwork.",
"No worn VR, physical controller, keyboard, or optical check."
]
}
-25
View File
@@ -1,25 +0,0 @@
# Legacy Decky frontend bridge
The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures)
has no `api_version` field and returns its initializer from a Decky IIFE.
Framey now wraps that expression in an ES module inside the existing
opaque-origin iframe. It passes a legacy `serverAPI` to the initializer.
Local Chromium ran the unchanged official frontend, including React effects,
with all backend calls rejected and network access blocked. It emitted
`framey:ready` without a JavaScript startup error during the observation window.
This proves initialization, not playback, MPRIS access, or a useful populated UI.
The fake Frame installed, packed, checked the frontend bytes and removed this
exact archive. Its synthetic Sample Panel also read a legacy keyword argument,
wrote a setting, and read that setting through the modern API. This exercised
the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was
checked against Decky's `handle_plugin_method_call_legacy`: it calls with a
keyword object and returns `{success, result}`. Invalid argument shapes and
unmanaged plugin IDs are rejected.
Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'`
(21 passed), `node tests/page/framey_actions.mjs`, and
`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or
headset state was changed by these checks. Real Frame backend behavior,
controller input, playback and worn VR use remain unverified.
@@ -1,326 +0,0 @@
{
"timeUtc": "2026-10-26T02:23:08.939647+00:00",
"theme": "Detached read-only Steam library and current-user context",
"baselineMain": "3921e59",
"hostFileSha256": "3f70e9769c6c95d39cfa329ccf3f822df85d411e9295bdf888508b4738679e9d",
"realFrame": {
"platform": "Frame OS 0.5.6 ARM64",
"path": "SSH via lxso2; Steam SharedJSContext CDP",
"lock": "/tmp/frame-test.lock atomic mkdir; matching owner released in finally after every probe",
"apps": 1106,
"games": 895,
"compactBytesWithoutUser": 88451,
"invalidRows": 0,
"missingGameOverviews": 0,
"currentUser": "17-digit string verified; value withheld",
"mutations": "none; no wake, service, setting, screenshot or library changes; no device artifacts created",
"limits": "read-only CEF projection, not an official backend or worn-VR test"
},
"browser": {
"mode": "opaque iframe Chromium; detached real Frame snapshot replay; all backend/network actions denied",
"attempted": 116,
"initializerReady": 101,
"failed": 15,
"initializerGains": [
46,
53,
66
],
"regressions": [],
"focused": [
{
"id": 37,
"name": "Discord Status",
"ready": true,
"error": null,
"logs": [],
"methods": [],
"library": "snapshot replay"
},
{
"id": 46,
"name": "Steamback",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "TypeError: saveInfos.map is not a function"
}
],
"methods": [
"set_account_id",
"get_saveinfos"
],
"library": "snapshot replay"
},
{
"id": 53,
"name": "Shotty",
"ready": true,
"error": null,
"logs": [],
"methods": [
"set_id_map_fronend"
],
"library": "snapshot replay"
},
{
"id": 66,
"name": "PlayTime",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Failed to set current user: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Error: Read-only compatibility audit: backend method blocked"
}
],
"methods": [
"set_current_user",
"fetch_playtime_information",
"statistics_for_last_two_weeks",
"daily_statistics_for_period"
],
"library": "snapshot replay"
},
{
"id": 93,
"name": "Deck Settings",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
},
{
"kind": "error",
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: Error: Read-only compatibility audit: live Steam API blocked"
}
],
"methods": [],
"library": "snapshot replay"
},
{
"id": 132,
"name": "SuggestMe",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "[SuggestMe] Failed to load config: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load library status: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load play next list: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load excluded games: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load filter presets: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load history: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load non-steam info: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load collections: Error: Read-only compatibility audit: backend method blocked"
}
],
"methods": [
"log_message",
"get_config",
"get_library_status",
"get_play_next_list",
"get_excluded_games",
"get_filter_presets",
"get_suggestion_history",
"get_non_steam_games",
"get_collections",
"get_available_genres",
"get_available_tags",
"get_available_community_tags"
],
"library": "snapshot replay"
},
{
"id": 134,
"name": "Deck Progress Tracker",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_settings",
"get_tag_statistics"
],
"library": "snapshot replay"
},
{
"id": 135,
"name": "Deck Shelves",
"ready": false,
"error": "Failed to resolve module specifier \"./chunks/pluginApi-B8sV7970.js\". Invalid relative url or base scheme isn't hierarchical.",
"logs": [],
"methods": [],
"library": "snapshot replay"
}
]
},
"fakeFrame": {
"attempted": 116,
"installedRemoved": 60,
"expectedBlockers": 56,
"backendExecution": "stubbed",
"cleanup": "private Compose containers/network/volumes removed"
},
"localBackends": {
"timeUtc": "2026-10-26T02:18:51.848256+00:00",
"verification": "local Chromium + actual Decky 3.3.0 amd64, detached real Frame SSH snapshot; no worn VR",
"calls": [
{
"id": 46,
"method": "set_account_id",
"resultType": "dict",
"resultCount": 2
},
{
"id": 46,
"method": "get_saveinfos",
"resultType": "dict",
"resultCount": 2
},
{
"id": 66,
"method": "set_current_user",
"resultType": "NoneType"
},
{
"id": 66,
"method": "statistics_for_last_two_weeks",
"resultType": "list",
"resultCount": 0
},
{
"id": 66,
"method": "fetch_playtime_information",
"resultType": "list",
"resultCount": 0
},
{
"id": 66,
"method": "daily_statistics_for_period",
"resultType": "dict",
"resultCount": 3
},
{
"id": 66,
"method": "fetch_playtime_information",
"resultType": "list",
"resultCount": 0
},
{
"id": 66,
"method": "statistics_for_last_two_weeks",
"resultType": "list",
"resultCount": 0
}
],
"runtime": {
"tag": "v3.3.0",
"name": "PluginLoader",
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
},
"legacyReadback": {
"plugin": 46,
"method": "get_saveinfos",
"replyType": 1,
"success": true,
"saveInfoCount": 0
}
},
"uiChecks": {
"Steamback": "Supported games panel, actual private backend set_account_id and get_saveinfos returned successfully; empty isolated Steam save data",
"PlayTime": "Statistics panel (empty database) and routed General settings/tabs rendered; actual private backend selected user and returned statistics",
"Deck Settings": "Installed game picker rendered real Frame install-folder/name snapshot; search modal rendered",
"Shotty": "Initializer repaired; actual Decky 3.3.0 backend fails importing unused click.get_app_dir: ModuleNotFoundError; no screenshot methods invoked"
},
"notVerified": [
"Real ARM64 official backends in this theme",
"Worn VR/controllers/keyboard",
"Live MobX or running-game observations",
"Steamback backups/restores and automatic notifications",
"PlayTime automatic recording, existing databases or account switches while panel remains open",
"Deck Settings remote reports/proxy fetch and settings changes",
"Shotty screenshot aggregation/rescue"
],
"cleanup": {
"localContainer": "stopped/removed; docker ps -a matching name empty",
"headsetLockReleased": true,
"httpHelpers": "three task-local HTTP servers stopped"
},
"plugins": [
{
"id": 37,
"name": "Discord Status",
"version": "1.5.0",
"sha256": "00a45dd919abb6e050685e1e408ce8023b9c4a09d6e91ba528d50aed2a63bf45"
},
{
"id": 46,
"name": "Steamback",
"version": "1.0.0-1",
"sha256": "ed5f2fbb47cdfc3aa9a4349a139ab20c29976117a3be0de7e8a588bb265ee7f3"
},
{
"id": 53,
"name": "Shotty",
"version": "0.1.0",
"sha256": "ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe"
},
{
"id": 66,
"name": "PlayTime",
"version": "3.2.5",
"sha256": "10fc3beea48991c77cb02867e919b618d4cd6d5e9c6a6d0ccd5266c13214039f"
},
{
"id": 93,
"name": "Deck Settings",
"version": "1.2.6",
"sha256": "23c2a933a6883e2622f0184c33a76ce4a4e88c0a7dcd730f9bab71b8c3702132"
}
],
"validation": {
"serverTests": 1617,
"serverSkips": 16,
"siteTests": 18,
"fakeFramePanel": "2 targeted panel/store flows passed; private Compose resources removed",
"focusedFrameyTests": 40
}
}
@@ -1,72 +0,0 @@
# Framey detached Steam library context — 2026-10-26
Framey now reads a bounded library snapshot before importing official frontends
that reference `appStore`, `collectionStore` or `m_CurrentUser`. It supplies app
IDs/names/types/game IDs, sorting/playtime scalars, the actual all-games
collection and the current public Steam ID. It does not copy credentials,
balances, account names, filesystem paths or executable Steam objects.
The projection is a fixed, parameterless `GetLibrarySnapshot` read through the
existing installed-store-plugin boundary. The parent checks the iframe source,
session, method and empty argument list. The host validates rows before exposing
detached Maps and lookup methods. Plugin patches remain local to that iframe.
Missing or invalid data leaves the globals unavailable and logs a clear warning;
there is no invented account or empty-library fallback. Reopening the panel
refreshes the snapshot. Live MobX observations, account-switch notifications and
running-game events are outside this change.
Library-sized backend arguments now have a 256 KiB UTF-8 JSON budget. The old
8 KiB ceiling rejected normal app-name maps. Payloads travel as quoted literals
on SSH stdin, avoiding operating-system limits on a single command argument.
Store receipt ownership, method validation and legacy keyword envelopes still
apply. The library read has a separate 2 MiB / 10,000-app limit; other Steam
reads retain their 256 KiB response limit.
## Observations
| Plugin | Verified behavior | Remaining limit |
| --- | --- | --- |
| Steamback 1.0.0-1 | Panel opens; account setup and empty save-list reads succeed through actual Decky 3.3.0 amd64 | Private container had no Steam saves. ARM64 backend, backup/restore and automatic hooks unverified. Partial. |
| PlayTime 3.2.5 | Actual backend selects the user, returns empty statistics; statistics panel and routed settings/tabs render | No sessions were created. ARM64 backend, existing databases, live observations and automatic recording unverified. Partial. |
| Deck Settings 1.2.6 | Installed game picker renders the real Frame folder/name snapshot; search modal opens | Remote reports/proxy fetch, settings changes and ARM64 backend unverified. Partial. |
| Shotty 0.1.0 | Library-based initializer now completes | Exact backend fails importing `click.get_app_dir`: `ModuleNotFoundError: No module named 'click'`. Archive does not bundle it. Broken. |
| Discord Status 1.5.0 | Detached overview lookups available | Live running-app observations and artwork helpers remain absent; Discord activity unverified. Broken. |
Exact plugin versions/digests, runtime digest, scoped calls and counts are in the
[redacted observations](framey-library-context-2026-10-26.json). The canonical
table/store labels use the same digest-scoped JSON. None of these plugins is
promoted to **works** by this pass.
## Verification boundaries
- **Real Frame SSH:** atomic `/tmp/frame-test.lock` acquisition for every probe,
matching-owner release in `finally`. Frame OS 0.5.6 ARM64 Steam CEF returned
1,106 apps and 895 game IDs; no missing collection members or invalid rows.
The compact library-only response was 88,451 bytes. Current-user field shape
was separately inspected. Values remain private; only aggregate counts are
published. No wake, power/service changes or device artifacts were needed.
- **Local Chromium:** production `headset.html`, current host, opaque iframe and
unmodified digest-checked official frontends. Library/user and install-folder
data were detached snapshots read from the Frame, replayed locally. This is
not a deployed headset HTTP or worn-VR test.
- **Actual local backends:** published Decky 3.3.0 amd64 executable, private
unprivileged container, network disabled, capabilities dropped, no devices,
host home, saves or Steam files mounted. Only account setup within private
plugin data and read methods were allowed. Backup, restore, aggregation,
screenshot rescue and game actions were denied. Shotty's import failure was
observed from the runtime log before invoking screenshot methods.
- **Catalog regression:** 116 exact frontends attempted, 101 initializer-ready
and 15 failed, compared with 98 ready before this theme. Steamback, Shotty and
PlayTime gained initializer readiness; no previous ready state regressed.
Backend/network calls stayed denied in this sweep. Readiness alone does not
establish useful operation.
- **Fake Frame:** 116 archive/transport cases; 60 install/remove cycles and 56
expected blockers. Official backend execution was stubbed. Its private
Compose containers, network and volumes were removed.
The browser audit accepts `--library-snapshot /private/library.json` to reproduce
the detached replay. This flag does not convert the audit into live Steam or
backend evidence. Never commit personal library snapshots or account values.
No worn-VR, physical controller, keyboard, automatic recording or backup claim
is made. Further device backend checks still require the shared hardware lock.
@@ -1,97 +0,0 @@
{
"checkedAtUtc": "2026-10-26",
"plugin": {
"id": 125,
"name": "Decky Localsend",
"officialArchiveStatus": "x86_64; blocked; no substitutions or safeguard changes"
},
"source": {
"url": "https://github.com/MoYoez/LocalSend-Go",
"revision": "66edc509a28b9f877e7374504892dcbc653a4da4",
"license": "MIT",
"licenseUrl": "https://github.com/MoYoez/LocalSend-Go/blob/66edc509a28b9f877e7374504892dcbc653a4da4/LICENSE",
"pluginLicense": "BSD-3-Clause; distinct from the MIT helper",
"officialElfBuildInfo": "Go 1.25.7, CGO_ENABLED=0, GOARCH=amd64; no embedded VCS revision. Exact source correspondence unproven."
},
"build": {
"toolchain": "go1.25.7",
"environment": {
"GOTOOLCHAIN": "go1.25.7",
"CGO_ENABLED": "0",
"GOOS": "linux",
"GOARCH": "arm64"
},
"command": "go build -trimpath -o localsend-core .",
"elfMachine": 183,
"architecture": "aarch64",
"staticallyLinked": true,
"sha256": "e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9",
"rebuildSha256Matches": true
},
"realFrame": {
"os": "Frame OS 0.5.6 ARM64",
"sharedLockOwner": "kp0z857q58-live-steam-reads-20261026",
"helpExitCode": 0,
"officialBackendFlagsAllRecognized": true,
"helpFlags": [
"-doNotMakeSessionFolder",
"-log",
"-scanTimeout",
"-skipNotify",
"-useAlias",
"-useAutoSave",
"-useAutoSaveFromFavorites",
"-useConfigPath",
"-useDefaultUploadFolder",
"-useDownload",
"-useHttp",
"-useMultcastAddress",
"-useMultcastPort",
"-usePin",
"-useReferNetworkInterface",
"-useWebOutPath"
],
"runtimeExitCode": 0,
"isolation": "unshare -Urn; loopback and private veth pair only; no external interface/default route",
"receiving": "-useAutoSave=false and -useAutoSaveFromFavorites=false; no upload/transfer requests",
"notifications": "-skipNotify",
"readOnlyRequest": "GET http://127.0.0.1:53317/api/localsend/v2/info",
"result": {
"ok": true,
"protocolVersion": "2.0",
"deviceType": "headless",
"responseKeys": [
"alias",
"deviceModel",
"deviceType",
"download",
"fingerprint",
"version"
],
"aliasMatches": true
},
"cleanup": {
"helperExited": true,
"filesCreated": [
"config.yaml",
"helper.log"
]
},
"privateDirectoryAbsent": true,
"servicesInstalledOrStarted": false,
"powerChanges": false,
"sharedLockReleasedAfterTests": true,
"independentReacquireAudit": "Deferred: another job had acquired the shared lock; it was left untouched. The helper process exited and TemporaryDirectory removal completed within the original locked probe."
},
"failedProbe": {
"interface": "loopback only",
"result": "Upstream rejects lo as a discovery interface and exits. Repeated with a veth pair wholly inside the private namespace; no source changes."
},
"unverified": [
"Exact source correspondence to shipped official ELF",
"Unmodified official Python backend invoking this ARM64 artifact",
"HTTPS, discovery on real network, pairing and file transfers",
"Framey frontend/proxy protocol integration",
"Worn VR/controller/keyboard"
]
}
@@ -1,57 +0,0 @@
# LocalSend ARM64 build and isolated Frame probe
The official Decky Localsend archive remains **broken/blocked**: its helper is
x86_64. A source rebuild is now proven to execute on the ARM64 Frame and answer
the LocalSend 2.0 info endpoint. This is a core runtime result, not a working
official-plugin or file-transfer claim. [Machine-readable evidence](framey-localsend-arm64-2026-10-26.json)
records the exact scope and cleanup.
## Reproduce the build
The helper is MIT-licensed; the separate official Python plugin is BSD-3-Clause.
Use the pinned helper source and retain its license for any redistribution.
No binary is redistributed in Frame Control, and no installer digest or
architecture safeguard was changed.
```sh
framey_build_dir=$(mktemp -d)
git clone https://github.com/MoYoez/LocalSend-Go "$framey_build_dir/source"
git -C "$framey_build_dir/source" checkout --detach 66edc509a28b9f877e7374504892dcbc653a4da4
cd "$framey_build_dir/source"
env GOTOOLCHAIN=go1.25.7 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -trimpath -o "$framey_build_dir/localsend-core" .
sha256sum "$framey_build_dir/localsend-core"
```
Two builds produced the same static aarch64 ELF SHA-256:
`e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9`.
The official ELF uses Go 1.25.7 too, but embeds no VCS revision, so the exact
source correspondence is still unproven. The rebuilt helper recognizes every
CLI flag supplied by the official Python backend.
## Real Frame scope
Tests ran under the normal atomic `/tmp/frame-test.lock` with a private temporary
directory. `-h` exited before configuration or networking. For the health probe,
`unshare -Urn` created a private user/network namespace with loopback and a veth
pair entirely inside it: no external interface or default route. The veth used
the documentation-only `192.0.2.1/24` address. Upstream rejects a loopback-only
discovery interface, which is why the second isolated attempt used a veth.
The helper used a private config/upload path, `-skipNotify`,
`-useAutoSave=false`, `-useAutoSaveFromFavorites=false`, `-useHttp=true`,
`-useReferNetworkInterface probe0` and `-scanTimeout 1`. The test client sent only
`GET /api/localsend/v2/info` over loopback; it returned version `2.0`, device type
`headless` and the private test alias. No upload, transfer, pairing or external
discovery request ran. The helper exited; its private config/log and executable
directory were removed; no service or power setting changed. The lock was
released. An independent cleanup reacquisition found another job's lock and
left it alone; cleanup success comes from the original probe's process wait and
temporary-directory exit, not that deferred audit.
Before enabling an ARM64 official package, verify the unmodified Python backend
and frontend/proxy protocol with this source revision, then HTTPS, discovery and
an explicitly safe private transfer. The official store must publish a matching
ARM64 archive or Frame Control must gain a separately reviewed, digest-scoped
build/package path. Compiling the core does not justify accepting the current
x86 archive or marking the full plugin works.
@@ -1,222 +0,0 @@
{
"schemaVersion": 1,
"timeUtc": "2026-10-26T03:29:05.328492+00:00",
"baseCommit": "c89ddff",
"scope": "Finite Framey-local DFL component/CSS registry and explicit unsupported optional UI-audio export",
"upstreamReferences": [
"https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/webpack.ts",
"https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/class-mapper.ts"
],
"plugins": [
{
"id": 16,
"name": "Audio Loader",
"version": "1.6.1",
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel.",
"browser": {
"id": 16,
"name": "Audio Loader",
"ready": false,
"error": "Cannot read properties of undefined (reading 'm_AudioPlaybackManager')",
"logs": [
{
"kind": "warn",
"message": "Resolve failed: [object Object] promise [object Promise]"
}
],
"methods": [
"store_read"
]
}
},
{
"id": 36,
"name": "SteamGridDB",
"version": "1.8.1",
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
"status": "broken",
"reason": "Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel.",
"browser": {
"id": 36,
"name": "SteamGridDB",
"ready": false,
"error": "Cannot convert undefined or null to object",
"logs": [],
"methods": []
}
},
{
"id": 61,
"name": "TabMaster",
"version": "2.15.1",
"sha256": "e08333859a32b0c177b8e9a7959c5eeeafe38f946578f7f1d161c721f0e05da5",
"status": "partial",
"reason": "Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified.",
"browser": {
"id": 61,
"name": "TabMaster",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "%c TabMaster %c ERROR %c background: #ff6d05; color: black; background: #c70808; color: black; background: transparent; Could not find mobx.reaction"
}
],
"methods": [
"log_message"
]
}
},
{
"id": 84,
"name": "Junk-Store",
"version": "1.1.13",
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
"status": "broken",
"reason": "Local CSS lookup fixes the loading view crash. Actual Decky 3.3.0 amd64 startup/static InitActions return a script execution/JSON error; the published scripts are not executable after extraction. Store install/login/game launch and ARM64 remain unverified.",
"browser": {
"id": 84,
"name": "Junk-Store",
"ready": true,
"error": null,
"logs": [],
"methods": [
"fetch_rss_feed",
"execute_action"
]
}
},
{
"id": 102,
"name": "DeckSP",
"version": "1.1.3",
"sha256": "d6cbedcd64430e1f94a2b12caf936ba270f1ceafe77c6252808ff39c88640b40",
"status": "broken",
"reason": "Optional UI-audio lookup succeeds, but the published frontend raises \"Cannot access PluginManager before initialization\" when Steam MobX reaction lookup is unavailable. JamesDSP installation/audio routing and ARM64 remain unverified.",
"browser": {
"id": 102,
"name": "DeckSP",
"ready": false,
"error": "Cannot access 'PluginManager' before initialization",
"logs": [],
"methods": []
}
}
],
"fullCatalogFrontend": {
"attempted": 116,
"ready": 102,
"failed": 14,
"previousReady": 101,
"newlyReady": [
61
],
"regressions": [],
"backendAndNetworkDenied": true,
"library": "Private detached real Frame snapshot replay, not live Steam or backend verification"
},
"focusedBrowser": {
"host": "Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe, exact official bundles",
"installedPluginAndStatusResponses": "Fixture-shaped; no headset HTTP endpoint exercised",
"backend": "Actual official Decky 3.3.0 amd64 in unprivileged network-none container; no host audio/device/data mounts",
"deckySha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"allowedMethods": {
"TabMaster": [
"log_message",
"get_setting",
"get_users_dict",
"get_tags"
],
"Junk-Store": [
"execute_action only init/InitActions"
]
},
"checks": [
"TabMaster QAM renders; library editing controls remain disabled",
"TabMaster Open Docs -> Overview renders",
"TabMaster Filters tab renders",
"Back to plugin returns to QAM",
"Junk-Store static InitActions via real backend returns Type Error; not promoted"
],
"denied": [
"External RSS/network",
"Other execute_action arguments",
"Login, installation, game launch, library patching, audio playback"
],
"directBackendReads": {
"TabMaster.get_setting": "default string returned",
"TabMaster.get_users_dict": {},
"TabMaster.get_tags": [],
"Junk-Store.scriptMode": "0644, Python shebang present"
}
},
"headset": {
"newPluginDeviceExecution": false,
"lockProbe": "Normal atomic lock acquired/released for read-only dependency availability probe; no service or power changes",
"ARM64OfficialBackend": "Not exercised in this theme",
"wornVR": false
},
"verification": {
"frameyTests": 42,
"fullServerTests": {
"count": 1620,
"skipped": 16,
"result": "OK"
},
"siteTestFilesPassed": 2,
"fakeFrame": {
"attempted": 116,
"installedRemoved": 60,
"expectedBlockers": 56,
"backendExecuted": false,
"privateProject": "framey-catalog-6456d3d939",
"projectRemoved": true,
"baseCommit": "c89ddff"
}
},
"storeRefresh": {
"timeUtc": "2026-10-26T03:29:46.606326+00:00",
"currentCount": 116,
"pinnedCount": 116,
"unchanged": true,
"changedIds": []
},
"postMainMerge": {
"mainCommit": "38c388f",
"mergedPRs": [
286,
287,
288
],
"frameyTests": 43,
"fullCatalogFrontend": {
"attempted": 116,
"ready": 103,
"failed": 13,
"additionalReadyFromUpstream": [
135
],
"regressionsComparedWithPreMergeAudit": []
},
"catalog": {
"works": 1,
"partial": 50,
"broken": 54,
"na": 11
},
"fullServerTests": {
"count": 1621,
"skipped": 16,
"result": "OK"
}
},
"cleanup": {
"focusedBackendContainerAbsent": true,
"loopback18770Closed": true,
"loopback18771Closed": true,
"fakeProjectAndVolumesRemoved": true
}
}
@@ -1,64 +0,0 @@
# Framey-local module lookup: official plugin checks
TabMaster 2.15.1 now renders its QAM and bundled documentation in local Chromium.
Opening its docs, selecting Overview and Filters, and returning to the panel
passed. Its status moves from **broken to partial**: library editing controls
remain disabled because Steam login, live MobX reactions and library-page
patches are unavailable. This is not working library-tab customization.
The [observations](framey-module-lookup-2026-10-26.json) pin each tested official
archive and the actual Decky 3.3.0 amd64 binary. The complete current official
store was refreshed: all 116 IDs, versions and digests match the catalog.
| Plugin | Result | Remaining limitation |
| --- | --- | --- |
| TabMaster 61 | Partial: QAM and documentation navigation render | Steam library editing unavailable; ARM64 and worn VR unverified |
| Junk-Store 84 | Loading and backend-error views render without the CSS lookup crash | Actual startup/static InitActions return an execution/JSON error; extracted scripts lack executable permission; remains broken |
| SteamGridDB 36 | Module mapping is callable | Still needs Steam's LibraryContextMenu implementation; remains broken |
| DeckSP 102 | Optional UI-audio lookup succeeds | Published bundle accesses PluginManager before initialization when MobX reaction is absent; remains broken |
| Audio Loader 16 | Optional UI-audio lookup succeeds | Still requires Steam's mutable audio playback store; remains broken |
## What ran
The host exposes a finite registry of its own components and styled CSS maps,
with DFL module/export/class lookup and mapping semantics. Unknown matches return
undefined or an empty mapping. An optional audio export explicitly reports
unavailable; invoking playback throws. No Steam webpack cache, function-source
signature, audio store or library patch was manufactured.
The full exact-bundle browser pass ran with backend and network calls denied.
It completed 102 initializers and failed 14, versus 101/15 before this theme.
Initializer readiness alone does not establish rendered or useful behavior.
After integrating main’s #286/#287/#288, all 116 were retried: 103 initializers
completed and 13 failed. The additional completion is Deck Shelves from #286;
no previously completed initializer regressed.
Focused checks used actual `ui/headset.html`, React 18.3.1 and opaque
`allow-scripts` iframes. Status/installed-plugin responses were fixture-shaped,
and the private detached library snapshot from the earlier Frame SSH check was
replayed. The relay allowed only TabMaster log/settings reads and Junk-Store's
static `init/InitActions` action. TabMaster's official backend returned the
default setting, empty users dictionary and empty tags. Junk-Store returned a
structured Type Error, which its panel displayed. RSS/network, login, installs,
launch actions and other backend methods were denied.
The unmodified Python backends ran in actual Decky 3.3.0 inside a disposable
unprivileged amd64 container with no network, host audio, devices or user-data
mounts. This bypasses Steam CEF and is not a headset HTTP/RPC check. The normal
headset lock was briefly acquired and released for read-only dependency/service
availability; no official plugin ran there in this theme. No ARM64 or worn-VR
result is claimed.
## Regression checks
- 1,621 server tests passed, 16 skipped; all 43 focused Framey tests passed
after integrating the latest main.
- Both site test files passed.
- All 116 exact archives passed the fake-Frame audit: 60 install/remove flows
and 56 expected blockers. Official backend execution remained stubbed.
- The unique fake-Frame project and its volumes were removed. The focused
backend container and both loopback helpers were removed after the checks.
The catalog and generated compatibility table use the same status/reasons as
both store views. The result is **1 works / 50 partial / 54 broken / 11 N/A** (including main’s independent Deck Shelves improvement);
the majority goal remains open.
@@ -1,116 +0,0 @@
{
"checkedAtUtc": "2026-10-25",
"source": "https://plugins.deckbrew.xyz/plugins",
"method": "Downloaded exact current official store versions; local SHA-256 matched each store hash; inspected ZIP plugin.json and dist/index.js without running plugin code.",
"limit": "Static inspection on PC. Prior backend load results are documented in docs/decky-frame.md; VR visibility and controller input remain unverified.",
"plugins": [
{
"storeId": 7,
"name": "CSS Loader",
"storeVersion": "2.1.3",
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
"manifestApiVersion": null,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 14,
"name": "ProtonDB Badges",
"storeVersion": "1.3.4",
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
"manifestApiVersion": 2,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 16,
"name": "Audio Loader",
"storeVersion": "1.6.1",
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
"manifestApiVersion": null,
"manifestFlags": [
"_root"
],
"frontendBundle": true
},
{
"storeId": 18,
"name": "Bluetooth",
"storeVersion": "3.1.1",
"sha256": "f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 21,
"name": "PowerTools",
"storeVersion": "2.0.3",
"sha256": "47614f53b8c538c4caa15f89a01e4ab106fa328e89f78545bacb3166d104d964",
"manifestApiVersion": null,
"manifestFlags": [
"root",
"global-dfl"
],
"frontendBundle": true
},
{
"storeId": 23,
"name": "Animation Changer",
"storeVersion": "1.3.2",
"sha256": "f2c62b90ca60d8a80b6d0f75d8027552b1509c7a05842c6f4a24a9072846d133",
"manifestApiVersion": null,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 26,
"name": "HLTB for Deck",
"storeVersion": "2.0.12",
"sha256": "bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 36,
"name": "SteamGridDB",
"storeVersion": "1.8.1",
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
"manifestApiVersion": 1,
"manifestFlags": [
"debug"
],
"frontendBundle": true
},
{
"storeId": 50,
"name": "Storage Cleaner",
"storeVersion": "1.4.0",
"sha256": "1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 68,
"name": "Wine Cellar",
"storeVersion": "0.2.1",
"sha256": "7e077b933c234414d172b5c6d1c804b026bf39fa4e18e6663495a282637031e8",
"manifestApiVersion": 1,
"manifestFlags": [
"debug"
],
"frontendBundle": true
},
{
"storeId": 82,
"name": "MagicPods",
"storeVersion": "2.0.21",
"sha256": "abe2eef5788cb3ff6e73fcb9783e30a54b127e28c761a9bf99e64ab5fc4be9ab",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
}
]
}
@@ -1,66 +0,0 @@
{
"timeUtc": "2026-10-25T23:35:22.022586+00:00",
"method": "Real Frame ARM64 Decky 3.3.0, isolated removable user service; explicit read-only plugin methods only",
"plugins": [
{
"id": 14,
"version": "1.3.4",
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
"method": "get_setting",
"result": {
"result": "default"
},
"errors": [
"Transient CEF reconnect during Decky frontend injection; succeeded on retry."
]
},
{
"id": 123,
"version": "1.2.3",
"sha256": "9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e",
"method": "get_settings",
"result": {
"keys": [
"enable_notification_antiflood",
"enable_notifications",
"gateway_api_key",
"gateway_url",
"refresh_interval_seconds",
"show_history",
"show_regions",
"show_trending_games"
],
"defaultRefreshInterval": 120
},
"errors": []
}
],
"before": {
"battery": [
100
],
"deckyService": "inactive",
"panelService": "inactive"
},
"service": {
"created": true
},
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"passed": true,
"cleanup": {
"baseAbsent": true,
"unitAbsent": true
},
"after": {
"battery": [
99
],
"deckyService": "inactive",
"panelService": "inactive"
},
"lockReleased": true,
"cleanupErrors": []
}
@@ -1,26 +0,0 @@
# Framey session cache browser check, 25 October 2026
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
used a per-panel memory substitute. It did **not** change the iframe's sandbox
flags or give plugin code Frame Control's UI key.
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
from `tests/fixtures/framey-official/123.zip` (SHA-256
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
four `get_settings` calls. That reply was a stub, not the Decky backend.
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
The parent could not read the iframe document (`contentDocument` was null).
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
Managers and Players Online. Its public status request returned HTTP 200.
No cache `SecurityError` appeared in the captured console, while the
same archive had logged that error with the v0.4.34 host.
Node host tests also checked item updates, removal, clear, key order, string
conversion, quota rejection, and preservation of an existing item after a
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
values **in memory**. Closing or reloading the panel loses this cache. This
check did not test offline behavior, persistent plugin settings, a live Decky
backend, SteamVR visibility, controller input, or headset performance.
@@ -1,99 +0,0 @@
{
"checkedAtUtc": "2026-10-26T00:40:00Z",
"scope": "Actual SteamClient parameterless reads through Steam CEF on ARM64 Frame. Official frontends ran in local Chromium with plugin backend/network calls denied. Private loopback relay used the production read bridge with fixture installed-plugin status and the real frame_steam module supplied in memory because the optional headset service was not deployed. This does not verify a deployed real-Frame HTTP endpoint.",
"allowlist": [
"User.GetIPCountry",
"InstallFolder.GetInstallFolders",
"System.GetSystemInfo"
],
"probeResults": {
"GetIPCountry": {
"ok": true,
"type": "string",
"length": 2,
"value": "redacted"
},
"GetInstallFolders": {
"ok": true,
"folders": 1,
"apps": 29,
"pathsAndTitles": "redacted"
},
"GetSystemInfo": {
"ok": true,
"fields": 27,
"identifiers": "redacted"
}
},
"officialPanels": {
"IsThereAnyDeal for Deck": {
"observation": {
"id": 81,
"name": "IsThereAnyDeal for Deck",
"ready": true,
"error": null,
"logs": [],
"methods": [
"settings_load",
"settings_save"
]
},
"countryDropdownMatchesLiveFrameValue": true,
"backendSettingsSave": "attempted by official frontend; denied; no setting written",
"dealLookups": "not run"
},
"Deck Settings": {
"observation": {
"id": 93,
"name": "Deck Settings",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
},
{
"kind": "error",
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: TypeError: Cannot read properties of undefined (reading 'allGamesCollection')"
}
],
"methods": []
},
"remainingBlocker": "collectionStore.allGamesCollection"
}
},
"boundariesTested": [
"Only three explicit zero-argument APIs",
"Installed store plugin required",
"Headset-service gate",
"Iframe source and current session required",
"Late replies dropped after reopen",
"256 KiB UTF-8 response cap and socket cleanup"
],
"deviceState": {
"sharedLock": "owned normally; released after testing",
"servicesInstalledOrStarted": false,
"powerChanges": false,
"deviceFilesForSteamProbe": false
},
"unverified": [
"Deployed headset HTTP endpoint",
"Plugin backend settings/deal lookups",
"Steam page patches and live library context",
"Worn VR/controller/keyboard"
],
"catalogRefresh": {
"source": "https://plugins.deckbrew.xyz/plugins",
"checkedAtUtc": "2026-10-26T00:44:00Z",
"visiblePlugins": 116,
"idVersionDigestChanges": 0
},
"localRegressionAudit": {
"exactArchivedFrontends": 116,
"ready": 98,
"failed": 18,
"initializationRegressions": 0,
"pluginBackendAndNetworkActions": "denied, including live Steam read APIs"
}
}
@@ -1,28 +0,0 @@
# Steam Status in the local Framey browser, 25 October 2026
This check used the unmodified official Steam Status 1.2.3 archive already
pinned in `tests/fixtures/framey-official/123.zip`. Its SHA-256 is
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`.
The archive declares Decky API v2. No headset was contacted for this check.
On a Linux PC, we extracted the archive to a disposable plugin folder with a
Frame Control store receipt. A local HTTP server served the page from
`framey_panel.panel(123)`, Frame Control's React 18.3.1 runtime and its
`framey-host.js`. A parent page put the panel in an iframe with
`sandbox="allow-scripts"` and returned `{}` for the plugin's `get_settings`
backend calls. This was a **stub**, not the Decky service.
Chromium reported `framey:ready` and four `get_settings` calls. The page showed
Steam Status, Store, Community, WebAPI, Connection Managers, Players Online,
and other controls. Its own public `https://steamstatus.schelstraete.org/api/v1/status`
request returned HTTP 200 and populated the status view. A click on **Refresh**
sent Framey's trusted pointer event to the parent; this test did not prove a
fresh status fetch or settings write. The [browser screenshot](framey-steam-status-browser-2026-10-25.png)
contains only the local test page and public service data.
The plugin logged `SecurityError` while reading and writing `localStorage` in
the opaque-origin iframe. It caught the errors and rendered anyway. Its cache
and offline behavior in Framey are **not verified**. The real Decky backend,
live settings, SteamVR visibility, controller input and headset performance
were not tested. This result supports only local frontend rendering for this
exact archive and Frame Control v0.4.34 host code.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

@@ -1,107 +0,0 @@
{
"checkedAtUtc": "2026-10-26T00:29:00Z",
"scope": "Exact archived frontends only. Production Framey host with bounded SteamClient JSON cache, image fallback and inert Steam subscriptions/context. Opaque iframe; all backend calls and network denied. No headset or plugin backends used in this follow-up.",
"baseHostCommit": "52a0974",
"before": {
"ready": 96,
"failed": 20
},
"after": {
"ready": 98,
"failed": 18
},
"regressions": [],
"observations": [
{
"id": 13,
"name": "Pause Games",
"ready": true,
"error": null,
"logs": [],
"methods": [
"load_settings"
]
},
{
"id": 43,
"name": "Tailscale Control",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_tailscale_state"
]
},
{
"id": 46,
"name": "Steamback",
"ready": false,
"error": "Cannot read properties of undefined (reading 'm_CurrentUser')",
"logs": [],
"methods": []
},
{
"id": 66,
"name": "PlayTime",
"ready": false,
"error": "Cannot read properties of undefined (reading 'm_mapApps')",
"logs": [
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
}
],
"methods": [
"fetch_playtime_information",
"statistics_for_last_two_weeks"
]
},
{
"id": 120,
"name": "Reroll",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_setting",
"version"
]
}
],
"tailscaleFocusedCheck": {
"rendered": [
"Settings",
"Toggle Tailscale",
"Exit Node IP",
"Toggle LAN Access",
"Advanced Settings",
"Device Status",
"Tailscale Setup"
],
"assetError": "Hardcoded localhost:1337 QR SVG blocked by image CSP",
"actions": "Inspected only; no toggles, exit nodes, installs or connection actions"
},
"pauseGamesFocusedCheck": {
"rendered": [
"Pause before Suspend",
"Pause on focus loss"
],
"actions": "Inspected only; no process signaling or suspend actions"
},
"storageChecks": [
"Per-plugin cache survives panel reopen",
"Missing key rejects Not found",
"JSON serialization and cache quota enforced",
"Malformed and stale-session cache updates rejected"
],
"unverified": [
"Real backends for these plugins",
"Tailscale network state/actions",
"Live games and pause/resume",
"Worn VR/controller/keyboard"
]
}
@@ -1,119 +0,0 @@
# Framey unattended VR evidence, 25 October 2026 UTC
The committed runner exited **0** on the real Frame, using the existing
`ssh lxso2 'ssh frame …'` route from lxso1. No person wore the headset.
Frame OS **0.5.6**, build **20261022.6153909**, ARM64, SteamVR **2.18.2**,
Decky **3.3.0**. The run began at **23:27:34 UTC**; the Frame's own clock/logs
use UTC+11 and therefore show 26 October.
```sh
python3 scripts/framey-vr-check.py --via lxso2 --exercise --output /tmp/framey-vr-verified
```
[Full machine result](result.json) records source commit
`cb9ad1b6cb7302008783d5f7fe42fa77a4dd43af`, bundle digest, before/after
SteamVR listings, power settings, input observations, and private image hashes.
The checked app code includes the sample settings-path and iframe-reopen fixes
and main through `d77e2bd`. This is source-bundle testing, not a newly published
release/AppImage test. The sample has a random test name and store-shaped local
receipt; it is not presented as an official catalogue listing.
| Check | Observed | Scope |
| --- | --- | --- |
| Overlay registration and visibility | `IVROverlay_027.IsOverlayVisible=true` | Actual Frame Control gamescope overlay, not a browser fixture |
| Transform | type **5**, DashboardTab; absolute getter returns **18**, WrongTransformType | Dashboard owns placement; no transform/dock setters used |
| Texture queries | main size 0×0; image read **24**, InvalidTexture | Direct texture refresh not established; compositor capture used instead |
| Stereo compositor | Before/after SHA-256 differ; final image visibly contains Framey tabs, quick action and saved result | Actual `IVRScreenshots_001` production screenshot path |
| OBS mirror | Current run's random name and “Saved setting is on” appear after draining 60 frames | `/dev/video99` pixels; OBS app itself was not launched in this task |
| OpenVR click | Move/down/up reaches intended control; iframe pointer event is trusted | `IVROverlayView_003.PostOverlayEvent`, no DOM `.click()` |
| Live setting | off → on; real backend file `{"enabled":true}`; fresh iframe reads on | Actual Framey HTTP → Steam CEF → Decky websocket → Python backend; no stubs |
| Tabs/quick action | Settings/Panel clicked; “Saved setting is on” returned | Actual shipped host controls and sample action |
| Slider | 10 → **50** | Native OpenVR click on actual DFL slider, diagnostic UI-only value |
| Text | **framey-ci** | OpenVR pointer focus, then CDP `Input.insertText`; not SteamVR keyboard proof |
| Smooth scroll attempt | `scrollBefore=0`, `scrollAfter=0` | No scrolling established by this attempt; do not count it as a pass |
| Battery | **99% → 99%** | Probed only under the owned lock |
| Power restoration | original pause **false**, timeout **5.000000**, exact values read back | Temporary values false/120; no idle-suspend timer or proximity changes |
| Cleanup | `cleanupErrors=[]`, `powerRecoveryRemoved=true`, `lockReleased=true` | Temporary managed services, sample/settings/profile, timer and restore script removed |
`IsDashboardVisible` and `IsActiveDashboardOverlay` are false while the panel is
visible in these captures: this is an existing placed/docked panel. They cannot
be used alone as a Framey visibility verdict. Activity changes from standby 3
to active 0 without wearing the headset, so activity alone is not worn proof.
Native OpenVR events also produce trusted pointers, so `event.isTrusted` alone
is not physical-controller proof. The worn script now requires explicit
`--worn-confirmed` and reports trusted pointer input with that narrower name.
![Actual compositor panel, room masked](stereo-left-panel-redacted.png)
![Actual mirror panel, room masked](mirror-panel-redacted.png)
These are inset panel crops from the raw captures, with all other pixels
replaced by solid black. No UI content was synthesized. [Image provenance](images.json)
contains source hashes, mask polygons, crops and published hashes. Raw images
remain private under `/tmp/framey-vr-verified` on lxso1. They are not committed.
## Bugs found and fixed
1. The author sample used `decky.decky_SETTINGS_DIR`, absent from real Decky
3.3.0. The actual iframe displayed its backend `AttributeError`. Decky's
[pinned module](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/plugin/imports/decky.py)
exports `DECKY_PLUGIN_SETTINGS_DIR`. The sample, docs and Python test fixture
now use that name. The old fixture invented the same wrong attribute and
masked the failure. The live fixed backend save/reopen is in `result.json`.
2. Reopening the same plugin changed only `#session`, so Chromium retained the
old document/host/cache session. It could retain an error and eventually
show a false “No compatible panel loaded” message. A fresh query parameter
forces a new document while keeping the fragment session. The cache
regression now checks document URLs, rather than manually assuming a new
iframe. The real reopen with the saved setting passed.
## Methods tried and limits
Read-only `vrcmd` stats/overlay listings, the shipped OpenVR ABI, compositor
logs and actual CEF/Chromium targets were inspected first. A simple
`--handlewakeup` initially produced the Frame Control status panel in a stereo
screenshot. The full test uses a bounded screen timeout so it stays active.
Decky's service startup reloads Steam's web helper. During exploratory service
recreation, SteamVR logged `UpdateInputFocus: Failed to give focus to panel`
and returned a hidden, zero-sized main overlay despite a successful focus
request. Retagging only the test browser's `STEAM_GAME` property recovered it;
no Steam, SteamVR, gamescope or headset restart was needed. The final run did
not need this recovery. The runner records it when used.
The first mirror image was demonstrably stale: it contained a previous test's
random plugin name while the contemporaneous stereo image contained the current
one. Reading 60 frames before selecting the mirror image fixed that observation;
the final two published images contain the same current run name.
The diagnostic itself initially failed its cleanup check because an idle
systemd watchdog service was already unloaded. It restored power and removed
the test services, retained the lock, then the exact owned remainder was cleaned
up. Cleanup now checks `LoadState` and tolerates an already-unloaded transient
unit. Subsequent full runs, including the committed run here, exited 0 with no
cleanup errors. A four-minute power-recovery timer was created and cancelled;
forced host death or network loss was not fault injected.
A null driver or virtual tracked controller was not needed for pointer delivery
and was not installed. No root/system files, tracking/proximity configuration,
Steam global binaries, or spatial transforms were edited. PR #113 was untouched.
This check does not establish every official plugin, sustained frame rate,
backend persistence across Steam/Decky reboot, physical controller tracking or
haptics, a functioning virtual keyboard, native scrolling, comfort or legibility
through the optics. Follow the [three-minute worn checklist](../../framey-vr-check.md#three-minute-worn-checklist).
## Local checks
- Full Python suite after integrating current main and worn-confirmation gate:
**1,595 tests, 16 skipped, OK**. The final added scroll/mirror helpers also
passed the **24 focused Framey tests** and the real-device run above.
- App and website test commands passed under **Node 24.22.1**, matching CI's
Node major. System Node 22 cancelled unrelated Meta sign-in timer tests;
those files were not changed. Node 24 was used for final app verification.
- `git diff --check` and Python compilation passed. Hosted CI status belongs to
the PR; local checks do not substitute for its macOS/Windows/ARM64 jobs.
A separate [locked cleanup audit](cleanup-verification.json) confirms both test
services inactive, no panel bundle or Decky binary, no test plugins/profiles or
remaining timers, power restored to false/5, battery 99→99, and its own lock
released. It also removed the stale DevTools discovery file created during the
earlier exploratory use of the original browser profile.
@@ -1,2 +0,0 @@
{"batteryBefore": [99], "headsetService": {"code": 4, "out": "inactive", "err": ""}, "deckyService": {"code": 4, "out": "inactive", "err": ""}, "panelBundleExists": false, "deckyBinaryExists": false, "testPluginFolders": [], "testProfiles": [], "timers": {"code": 0, "out": "", "err": ""}, "pause": {"code": 0, "out": "power.pauseCompositorOnStandby=false", "err": ""}, "timeout": {"code": 0, "out": "power.turnOffScreensTimeout=5.000000", "err": ""}, "removedOwnedStaleDevToolsDiscovery": true, "batteryAfter": [99], "lockReleased": true}
@@ -1,80 +0,0 @@
[
{
"source": "stereo-private.png",
"sourceSha256": "25944a3f3ffe0c217ef25cbc3f5e08a104f9d8901075b755c928ec5049a4cb3c",
"published": "stereo-left-panel-redacted.png",
"publishedSha256": "0de6fbf0f31d0587c8ba91817406acf455b8adb10d343bd56daa605f9a3051c7",
"maskPolygon": [
[
50,
435
],
[
910,
365
],
[
940,
920
],
[
700,
910
],
[
350,
945
],
[
90,
1000
]
],
"crop": [
50,
365,
941,
1001
],
"background": "solid black; only inset panel pixels retained"
},
{
"source": "mirror-private.png",
"sourceSha256": "44d193943ca3164269feeea38f5cbfb0d58cefb79d6c0e8231eab4a5069985b8",
"published": "mirror-panel-redacted.png",
"publishedSha256": "86fa296a36171efca6a9aa627ec3589496df73f3c03f8f9428454c411a904a77",
"maskPolygon": [
[
455,
445
],
[
1420,
390
],
[
1470,
940
],
[
1150,
910
],
[
850,
940
],
[
495,
1020
]
],
"crop": [
455,
390,
1471,
1021
],
"background": "solid black; only inset panel pixels retained"
}
]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

@@ -1,189 +0,0 @@
{
"schema": 1,
"time": "2026-10-25T23:27:34.130334+00:00",
"controllerHardwareVerified": false,
"wornVisualComfortVerified": false,
"sourceCommit": "cb9ad1b6cb7302008783d5f7fe42fa77a4dd43af",
"before": {
"battery": [
99
],
"dashboardVisible": false,
"stats": [
{
"activity_level": 3,
"operation": "status",
"version": "2.18.2",
"version_timestamp": 1791331621
}
],
"overlays": "---- OVERLAYS ---- \n'system.HeadsetView' -- 'Headset View', 2048x1280 not_visible VROverlayType_Basic \n'system.toast' -- 'Toast', not_visible VROverlayType_Basic \n'system.cursor.ibeam' -- 'system.cursor.ibeam', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000d00000002\n'system.pointer' -- 'system.pointer', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000e00000003\n'system.pointer.secondary' -- 'system.pointer.secondary', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000f00000004\n'system.standalone' -- 'Standalone Overlay Manager', not_visible VROverlayType_Basic \n'valve.steam.desktopgame.0' -- 'Gamescope', not_visible VROverlayType_Dashboard_Main \n flags=0x29A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000001100000006\n'valve.steam.desktopgame.0.thumb' -- 'Gamescope (Thumb)', 256x256 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x0000001200000007\n'valve.steam.desktopgame.0.layer1' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001300000008\n'valve.steam.desktopgame.0.layer2' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001400000009\n'valve.steam.desktopgame.0.layer3' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000150000000a\n'valve.steam.desktopgame.0.layer4' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000160000000b\n'valve.steam.desktopgame.0.layer5' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000170000000c\n'valve.steam.desktopgame.0.layer6' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000180000000d\n'valve.steam.desktopgame.0.layer7' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000190000000e\n'valve.steam.desktopgame.0.layer8' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001a0000000f\n'gamescope.gamescope-0.cursor.0' -- 'Mouse Cursor', not_visible VROverlayType_Basic \n flags=0x60000000 overlayhandle=0x0000001b00000010\n'system.systemui' -- 'systemui', 1860x2048 not_visible VROverlayType_Basic \n flags=0x08230008 -dash, +interactive, +smoothscroll, +premult, +clickstab overlayhandle=0x0000001c00000011\n'system.vrwebhelper.controllerbinding' -- 'controllerbinding', 2400x1350 not_visible VROverlayType_Dashboard_Main \n flags=0x09228108 -dash, +scrollwheel, +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000003800000012\n'system.vrwebhelper.controllerbinding.thumb' -- 'controllerbinding (Thumb)', 16x16 not_visible VROverlayType_Dashboard_Thumbnail \n'valve.steam.gamepadui.main' -- 'SteamVR', 0x0 not_visible VROverlayType_Dashboard_Main \n flags=0x69A28000 +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a400000014\n'valve.steam.gamepadui.main.thumb' -- 'Webhelper (Thumb)', 300x300 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x000001a500000015\n'valve.steam.browserview_MainBrowser_2' -- 'data:text/html,<body><%2Fbody>', 0x0 not_visible VROverlayType_Subview \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a600000016\n'valve.steam.gamepadui.keyboard' -- 'SteamVR - Keyboard', 0x0 not_visible VROverlayType_Basic \n flags=0x31A30000 +interactive, +smoothscroll, +premult, +keyboard, +2cursor overlayhandle=0x000001a700000017\n'valve.steam.gamepadui.bar' -- 'SteamVR - valve.steam.gamepadui.bar (85370000)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a800000018\n'valve.steam.gamepadui.barpopup.85370001' -- 'SteamVR - valve.steam.gamepadui.barpopup.85370001 (85370001)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a900000019\n'valve.steam.gamepadui.tooltip.85370002' -- 'SteamVR - valve.steam.gamepadui.tooltip.85370002 (85370002)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001aa0000001a\n'valve.steam.gamepadui.volumelevel' -- 'SteamVR - valve.steam.gamepadui.volumelevel (85370003)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001ab0000001b\n'valve.steam.gamepadui.floatingfooterLine truncated
"pause": "power.pauseCompositorOnStandby=false",
"timeout": "power.turnOffScreensTimeout=5.000000"
},
"powerRecovery": {
"timer": "framey-vr-5e068da08ef73637-restore.timer",
"script": "/tmp/framey-vr-5e068da08ef73637-restore.py",
"originalPause": "false",
"originalTimeout": "5.000000"
},
"deckyInstall": {
"message": "Decky Loader v3.3.0 is running as a user service. The VR menu is experimental."
},
"panelInstall": {
"message": "Installed Frame Control on the headset. Asked SteamVR to show Frame Control. Select its panel in the dashboard if it is hidden.",
"version": "4968ad070054d005",
"overlay": "valve.steam.desktopgame.2001947813"
},
"overlay": {
"state": {
"interface": "IVROverlay_027",
"visible": true,
"dashboardVisible": false,
"activeDashboard": false,
"texture": {
"error": 0,
"width": 0,
"height": 0
},
"transform": {
"error": 18,
"typeError": 0,
"type": 5,
"origin": 0,
"matrix": [
[
0.0,
0.0,
0.0,
0.0
],
[
0.0,
0.0,
0.0,
0.0
],
[
0.0,
0.0,
0.0,
0.0
]
]
},
"input": {
"error": 0,
"method": 1,
"scaleError": 0,
"scale": [
1920.0,
1080.0
]
}
},
"imageRead": {
"error": 24,
"width": 0,
"height": 0
}
},
"beforeStereoSha256": "5d0635d02e44d7cf2d5c249e72ea01c5cd161996427c1012cfd1c42fa5aa5751",
"input": {
"clickTransport": "IVROverlayView_003.PostOverlayEvent",
"textTransport": "CDP Input.insertText after OpenVR focus",
"controllerHardwareVerified": false,
"initialText": "PanelSettings\nQuick actions\nRead saved setting\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: off",
"savedText": "Framey sample\n\nSetting saved\n\nDismiss\nPanelSettings\nQuick actions\nRead saved setting\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: on",
"pointerEvents": [
{
"trusted": true,
"x": 594.6666870117188,
"y": 280.0625
}
],
"trustedOverlayPointer": true,
"diskSetting": {
"enabled": true
},
"reopenedText": "PanelSettings\nQuick actions\nRead saved setting\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: on",
"reopenedChecked": true,
"quickActionText": "PanelSettings\nQuick actions\nRead saved setting\n\nSaved setting is on\n\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: on",
"sliderValue": "50",
"textValue": "framey-ci",
"scrollBefore": 0,
"scrollAfter": 0,
"nativeScrollMoved": false,
"finalOverlay": {
"interface": "IVROverlay_027",
"visible": true,
"dashboardVisible": false,
"activeDashboard": false,
"texture": {
"error": 0,
"width": 0,
"height": 0
},
"transform": {
"error": 18,
"typeError": 0,
"type": 5,
"origin": 0,
"matrix": [
[
0.0,
0.0,
0.0,
0.0
],
[
0.0,
0.0,
0.0,
0.0
],
[
0.0,
0.0,
0.0,
0.0
]
]
},
"input": {
"error": 0,
"method": 1,
"scaleError": 0,
"scale": [
1920.0,
1080.0
]
}
},
"passed": true
},
"stereoSha256": "25944a3f3ffe0c217ef25cbc3f5e08a104f9d8901075b755c928ec5049a4cb3c",
"compositorChanged": true,
"mirrorSha256": "44d193943ca3164269feeea38f5cbfb0d58cefb79d6c0e8231eab4a5069985b8",
"passed": true,
"after": {
"battery": [
99
],
"dashboardVisible": false,
"stats": [
{
"activity_level": 0,
"operation": "status",
"version": "2.18.2",
"version_timestamp": 1791331621
}
],
"overlays": "---- OVERLAYS ---- \n'system.HeadsetView' -- 'Headset View', 2048x1280 not_visible VROverlayType_Basic \n'system.toast' -- 'Toast', not_visible VROverlayType_Basic \n'system.cursor.ibeam' -- 'system.cursor.ibeam', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000d00000002\n'system.pointer' -- 'system.pointer', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000e00000003\n'system.pointer.secondary' -- 'system.pointer.secondary', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000f00000004\n'system.standalone' -- 'Standalone Overlay Manager', not_visible VROverlayType_Basic \n'valve.steam.desktopgame.0' -- 'Gamescope', not_visible VROverlayType_Dashboard_Main \n flags=0x29A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000001100000006\n'valve.steam.desktopgame.0.thumb' -- 'Gamescope (Thumb)', 256x256 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x0000001200000007\n'valve.steam.desktopgame.0.layer1' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001300000008\n'valve.steam.desktopgame.0.layer2' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001400000009\n'valve.steam.desktopgame.0.layer3' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000150000000a\n'valve.steam.desktopgame.0.layer4' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000160000000b\n'valve.steam.desktopgame.0.layer5' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000170000000c\n'valve.steam.desktopgame.0.layer6' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000180000000d\n'valve.steam.desktopgame.0.layer7' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000190000000e\n'valve.steam.desktopgame.0.layer8' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001a0000000f\n'gamescope.gamescope-0.cursor.0' -- 'Mouse Cursor', not_visible VROverlayType_Basic \n flags=0x60000000 overlayhandle=0x0000001b00000010\n'system.systemui' -- 'systemui', 1860x2048 not_visible VROverlayType_Basic \n flags=0x08230008 -dash, +interactive, +smoothscroll, +premult, +clickstab overlayhandle=0x0000001c00000011\n'system.vrwebhelper.controllerbinding' -- 'controllerbinding', 2400x1350 not_visible VROverlayType_Dashboard_Main \n flags=0x09228108 -dash, +scrollwheel, +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000003800000012\n'system.vrwebhelper.controllerbinding.thumb' -- 'controllerbinding (Thumb)', 16x16 not_visible VROverlayType_Dashboard_Thumbnail \n'valve.steam.gamepadui.main' -- 'SteamVR', 0x0 not_visible VROverlayType_Dashboard_Main \n flags=0x69A28000 +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001c600000014\n'valve.steam.gamepadui.main.thumb' -- 'Webhelper (Thumb)', 300x300 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x000001c700000015\n'valve.steam.browserview_MainBrowser_2' -- 'data:text/html,<body><%2Fbody>', 0x0 not_visible VROverlayType_Subview \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001c800000016\n'valve.steam.gamepadui.keyboard' -- 'SteamVR - Keyboard', 0x0 not_visible VROverlayType_Basic \n flags=0x31A30000 +interactive, +smoothscroll, +premult, +keyboard, +2cursor overlayhandle=0x000001c900000017\n'valve.steam.gamepadui.bar' -- 'SteamVR - valve.steam.gamepadui.bar (85430000)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001ca00000018\n'valve.steam.gamepadui.barpopup.85430001' -- 'SteamVR - valve.steam.gamepadui.barpopup.85430001 (85430001)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cb00000019\n'valve.steam.gamepadui.tooltip.85430002' -- 'SteamVR - valve.steam.gamepadui.tooltip.85430002 (85430002)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cc0000001a\n'valve.steam.gamepadui.volumelevel' -- 'SteamVR - valve.steam.gamepadui.volumelevel (85430003)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cd0000001b\n'valve.steam.gamepadui.floatingfooterLine truncated
"pause": "power.pauseCompositorOnStandby=false",
"timeout": "power.turnOffScreensTimeout=5.000000"
},
"powerRecoveryRemoved": true,
"lockReleased": true,
"cleanupErrors": []
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

@@ -1,120 +0,0 @@
{
"timeUtc": "2026-10-26T01:52:44.513151+00:00",
"testedMainCommit": "c62bffc8efe88e107a82e2f2ef481228021acc88",
"change": "uncommitted standby/proximity preflight in this PR",
"preflight": {
"activity": 3,
"proximityRaw": [
6.3125
],
"batteryPercent": [
99
],
"pause": "power.pauseCompositorOnStandby=false",
"screenTimeout": "power.turnOffScreensTimeout=5.000000"
},
"result": {
"passed": true,
"overlayVisible": true,
"stereoChanged": true,
"mirrorCaptured": true,
"trustedSyntheticOpenVRPointer": true,
"liveDeckySavedSetting": {
"enabled": true
},
"settingPersistedAfterReopen": true,
"quickActionObserved": true,
"sliderValue": "50",
"textValue": "framey-ci",
"nativeScrollMoved": false
},
"cleanup": {
"batteryPercent": [
99
],
"proximityRaw": [
6.1875
],
"pause": "power.pauseCompositorOnStandby=false",
"screenTimeout": "power.turnOffScreensTimeout=5.000000",
"powerRecoveryRemoved": true,
"lockReleased": true,
"errors": []
},
"limits": {
"controllerHardwareVerified": false,
"wornVisualComfortVerified": false,
"vrKeyboardVerified": false
},
"combinedHeadChecks": [
{
"timeUtc": "2026-10-26T02:01:05.938830+00:00",
"head": "bc72c41be5aa4bc80192145afefb1c6fe31a47fc",
"passed": false,
"overlayVisible": false,
"inputPassed": false,
"reason": "Timed out waiting for live UI: 0",
"retaggedTestWindow": true,
"batteryBefore": [
100
],
"batteryAfter": [
99
],
"powerRestored": true,
"lockReleased": true
},
{
"timeUtc": "2026-10-26T02:02:44.600755+00:00",
"head": "bc72c41be5aa4bc80192145afefb1c6fe31a47fc",
"passed": false,
"overlayVisible": false,
"inputPassed": false,
"reason": "Timed out waiting for live UI: 0",
"retaggedTestWindow": true,
"batteryBefore": [
99
],
"batteryAfter": [
99
],
"powerRestored": true,
"lockReleased": true
},
{
"timeUtc": "2026-10-26T02:05:00.560460+00:00",
"head": "bc72c41be5aa4bc80192145afefb1c6fe31a47fc",
"passed": true,
"overlayVisible": true,
"inputPassed": true,
"reason": null,
"retaggedTestWindow": false,
"batteryBefore": [
99
],
"batteryAfter": [
99
],
"powerRestored": true,
"lockReleased": true
},
{
"timeUtc": "2026-10-26T02:07:28.654641+00:00",
"head": "db079eec9f64a865fca8a1a51b5304e071596383",
"localChange": "bounded overlay wait on this PR branch",
"passed": true,
"overlayVisible": true,
"inputPassed": true,
"reason": null,
"retaggedTestWindow": false,
"batteryBefore": [
100
],
"batteryAfter": [
99
],
"powerRestored": true,
"lockReleased": true
}
]
}
@@ -1,50 +0,0 @@
# Framey unworn exercise with standby gate
At 2026-10-26 01:52 UTC, this PR's `scripts/framey-vr-check.py --exercise`
acquired the shared lock on a real Frame running the `c62bffc8` main bundle.
The new read-only preflight found SteamVR activity 3 (standby), proximity raw
6.3125 and battery 99% before allowing temporary installation or power changes.
The lock owner was unique to this run.
The documented unworn exercise passed: the Frame Control overlay was visible,
stereo compositor pixels changed, `/dev/video99` was captured, a synthetic
trusted OpenVR pointer reached the sample, a live Decky setting was saved and
persisted after a fresh panel reopen, and the quick action, slider and text
field produced their expected values. Native scroll did not move. The test
retagged only its temporary panel window once to recover focus. Public numeric
results are in [framey-vr-standby-gate-2026-10-26.json](framey-vr-standby-gate-2026-10-26.json).
Room-containing stereo and mirror captures remain private in `/tmp`.
Cleanup returned battery 99%, `power.pauseCompositorOnStandby=false` and
`power.turnOffScreensTimeout=5.000000`, matching the preflight. The temporary
power recovery unit was removed, no cleanup errors were reported, and only
this run's lock was released. Local tests also checked that activity 1, 2 or
unknown refuses both `--wake` and `--exercise` before any installation or
power change.
Synthetic pointer events and captures do not prove physical controller use,
wearer-visible readability, optical comfort or VR keyboard behavior. Those
remain unverified.
## Combined v0.4.39 candidate reruns
After #278 and #279 joined main, the combined `bc72c41` branch ran the same
locked exercise three times. The first two runs (02:01 and 02:02 UTC) loaded
the sample UI in Chromium, but SteamVR kept its overlay hidden even after the
existing test-window focus retry. Synthetic input reached no panel control;
both runs failed. The third run (02:05 UTC) showed the overlay without that
retry and passed the sample setting, input, stereo and mirror checks. Every run
restored the original power settings, removed its recovery timer and released
its own lock with no cleanup errors. The [numeric summary](framey-vr-standby-gate-2026-10-26.json)
records all three outcomes.
The intermittent hidden-overlay condition remains a reliability limit. The
runner now records a small read-only diagnostic and refuses to send synthetic
input if the overlay stays hidden after its focus retry. A successful Chromium
panel alone is not treated as VR visibility.
At 02:07 UTC, a fourth combined-head run with a bounded overlay wait passed
without a focus retry. The sample setting/input and stereo checks passed; power
settings were restored, the recovery timer was removed, and the owned lock was
released. This single pass does not prove that the wait eliminates intermittent
hidden overlays. Battery changed from 100% to 99% during the run.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

-116
View File
@@ -1,116 +0,0 @@
# Physical Mac desktop check, 26 October 2026
Host: **MacBook Air (MacBookAir9,1), Intel Core i3 1.1 GHz, macOS 15.7.7
(24G720), x86_64**. This is physical hardware. The console is an unattended
server with display/automation restrictions. No Apple Silicon runtime result
is inferred from this test.
**Follow-up:** the official v0.4.39 Intel installer later launched on this Air.
See the [released-build test](mac-v0439-2026-10-26.md); the results below remain
the earlier ARM64 failure and separate candidate test.
## Released installers: verified, installed, cannot run on Intel
GitHub's latest release was v0.4.35 at the start; v0.4.36 became latest during
the check. Each DMG and its SHA256SUMS were downloaded from that exact release
with `gh release download`. Both passed `shasum -a 256 -c SHA256SUMS
--ignore-missing` (only the downloaded asset was checked):
| Release | Asset | SHA-256 |
|---|---|---|
| v0.4.35 | Frame-Control-mac-arm64.dmg | `eda670748679ca02fcf242870cd53954b22cf8b93af47a193c9903bd08a8334f` |
| v0.4.36 | Frame-Control-mac-arm64.dmg | `4d13068f0cfd3616823d63c82333afa7e368c1d57d375e9564f5f7ed4f0ebbd7` |
Each read-only DMG was mounted with `hdiutil attach -readonly -nobrowse`.
There was no existing `/Applications/Frame Control.app`; the app was copied
there with `ditto`, the command-line equivalent of the DMG's drag to
Applications. `open "/Applications/Frame Control.app"` returned exactly:
> The application cannot be opened because it has an incorrect executable format.
`file` identified the executable as `Mach-O 64-bit executable arm64`.
`codesign -dv` identified an ad hoc signature with no TeamIdentifier. The
v0.4.35 `spctl -a -vv` assessment returned `rejected`. The v0.4.36 assessment
stalled and is not counted as a completed assessment.
**No Gatekeeper warning dialog was observed.** LaunchServices rejected the
architecture first. The CLI download had no `com.apple.quarantine` attribute,
so this was not a browser-quarantine test. Neither right-click Open, Open
Anyway nor removing quarantine was tested. On this Intel Mac there is no
honest opening workaround for either released ARM64 app. Rosetta does not
run ARM64 apps on Intel. The README now separates that fact from Apple's
recommended Open Anyway flow for compatible Macs and labels the latter
unverified here. No global Gatekeeper setting was changed.
## Separate Intel candidate: partial native runtime pass
[PR #262](https://github.com/saphid/frame-control/pull/262), commit `6ea9eb2`,
adds native x64 DMG/ZIP builds, pinned x64 Python, an x86_64 Swift helper, and
CI architecture checks. Swift 6.2.4 rejected an ambiguous
`Data.withUnsafeMutableBytes` overload in the helper; an explicit
`UnsafeMutableRawBufferPointer` annotation compiled successfully on this Air.
`npm run dist:mac:x64` completed locally, producing an ad hoc signed app,
ZIP and DMG. The unpacked candidate was copied to
`/Applications/Frame Control Intel Test.app` and launched with telemetry off,
a task-local server data directory and a temporary loopback debug port.
This is **not** the published v0.4.36 binary.
The first launch stalled for several minutes before app code ran. A process
sample showed only `_dyld_start`, a 12 KiB footprint and no loaded image
list, while `syspolicyd` used about 89% CPU. It later proceeded without any
system-service restart; the app window and server loaded. The initial stall
is a host observation, not proof of a Frame Control bug. Restarting that
security daemon was rejected by automatic approval review, and was not done.
| Check | Observed result and limit |
|---|---|
| Native window and IPC bridge | The Electron renderer displayed Apps and the offline identity warning; `window.frameApp` was present. Native renderer inspected through its temporary loopback CDP port; this does not prove every macOS permission dialog or physical click |
| Settings | Native preference read returned `{}`. Writing `mvSeparate="0"` read back correctly. The original preference file was restored byte-for-byte (or removed when originally absent) in a finally block. Persistence across an app restart is not claimed |
| Open at Login | Source is opt-in and focused login-item tests passed. Native menu read via System Events timed out; an OS login session and actual checkbox state remain unverified. No login item was enabled |
| Logs | `~/Library/Logs/Frame Control/server.log` exists. Its startup line proves the candidate used `Contents/Resources/python/bin/python3` and its bundled `ui/server.py`; HTTP reads and offline failures were logged. Show Server Log menu interaction was not verified |
| Update check | Actual `frameApp.update.check()` completed with `status="none"`, `current="0.4.36"`, latest `0.4.36`, and `error=null`. No update was installed; native dialog dismissal was not verified |
| OBS setup and lifecycle | Native status reported supported/idle, Start on port 15123 reported starting, Stop reported stopping, then idle. The Frame's SSH identity rejection prevented a headset stream. This checks local lifecycle only: no OBS app, audio/video delivery or receiver tested |
| Decky store | The exact released UI/server resources, separately served by this Mac's existing Python 3.9.6, loaded 116 official entries in local Chromium. Forty rows rendered; searching ProtonDB found ProtonDB Badges 1.3.4; all Install buttons stayed disabled while offline. No loader or plugin was installed. [Browser screenshot](mac-decky-browser-2026-10-26.png) |
| Other UI | Released Apps, Settings, Privacy & updates, Tools, Capture & Live and Decky views rendered. The privacy page correctly reported telemetry blocked by `FRAME_CONTROL_TELEMETRY=0`; sharing/contact choices were left alone. The activity log displayed the offline failure. The browser path hides native OBS buttons as designed; it is not a substitute for the native lifecycle check above |
| Framey preview | No standalone local preview control was found in the desktop UI. The Framey panel belongs to the headset runtime; no VR or plugin-backend result is claimed |
The released server refused a stale SSH host identity rather than silently
trusting it. That safety check was preserved; no host pin was replaced just
to make the test pass. Capture, media, game launches, power controls, installs,
sign-ins and notifications were not triggered without a working connection.
## Headset: read-only relay checks, no changes
`frame.local` resolved on the Air, but direct TCP/SSH did not connect. The
coordinator host lxso2 could reach it. The app's read-only `frame_status.py`
was piped through lxso2 to the Frame and returned:
- SteamOS **0.5.6**, build **20261022.6153909**, variant `vr`.
- Battery **99%, Charging**.
- Home storage: 996,984,774,656 bytes total, 769,023,275,008 bytes free.
- A read-only search counted **14** Steam screenshot JPEGs; none was copied.
The shared `/tmp/frame-test.lock` was owned by another Framey test on each
check. This job did not acquire, remove or replace it. File upload/delete,
OBS on the device and every other headset mutation were skipped. These relay
reads do not prove the native Mac app's direct SSH or file-transfer path.
The headset was never wiped or rebooted.
## Validation and follow-up
A disposable Linux checkout of exact commit `6ea9eb2` on lxso2 passed:
**1,590 server tests (16 skipped)** and **46 app tests**. On the physical Air,
**14 focused login-item/preferences/OBS tests** and **18 website tests** passed.
Full Mac server/release suites were attempted but spawned helper programs
stalled and timed out during the policy-service problem; they are not a pass.
Node 22's complete app run cancelled sign-in tests around an unreferenced
timeout; the exact same app suite passed on lxso2. CI uses Node 24.
Required next steps: pass exact-head Mac packaging/architecture CI, publish
an Intel asset through coordinator job mppkd9qczt, then repeat a normal
browser-download/quarantine installation of that release on this Air. Verify
Open Anyway and Open at Login through an authorized interactive console.
Acquire the shared headset lock before a temporary transfer or real OBS test.
Preserve all unverified labels until those checks actually pass. PR #113 was
not changed. No social post was made.
-102
View File
@@ -1,102 +0,0 @@
# Official Intel Mac release test, 26 October 2026
Physical **MacBookAir9,1, Intel Core i3 1.1 GHz, macOS 15.7.7 (24G720)**.
The earlier ARM64 failure led to [#262](https://github.com/saphid/frame-control/pull/262),
now merged. This test uses the published **v0.4.39**, tag `fd0b0a9`, rather
than the locally built candidate. [Earlier results](mac-desktop-2026-10-26.md)
remain a separate historical record.
## Download, installation and Gatekeeper observations
Downloaded `Frame-Control-mac-x64.dmg` and its same-release `SHA256SUMS`
with `gh release download`. `shasum -a 256 -c SHA256SUMS --ignore-missing`
passed for the downloaded asset:
```text
f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8 Frame-Control-mac-x64.dmg
```
Also clicked that asset in T3's Chromium browser. Its 169,230,398-byte download
had the same hash. **Neither download had `com.apple.quarantine`.** T3's
browser is not evidence of Safari/Chrome download metadata.
Mounted the verified CLI DMG read-only, preserved the task's earlier ARM64
installation, and copied `Frame Control.app` into `/Applications` with `ditto`
(the command-line equivalent of dragging it there). The executable is x86_64;
`codesign -dv` reports ad hoc signing. `open "/Applications/Frame Control.app"`
returned success and the app started its bundled Python server.
**No Gatekeeper warning dialog was observed.** `spctl --assess --type execute
-vv "/Applications/Frame Control.app"` returned exit 3 and exactly:
```text
/Applications/Frame Control.app: rejected
```
Opening this unquarantined copy needed no override. This does not establish
the warning or opening steps for a quarantined browser download. The README
keeps Apple's Open Anyway guidance, explicitly unverified here. No quarantine
attribute was removed and no global security setting was changed. System
Events inspection timed out; an AppleEvent quit request returned “User
cancelled” (-128). Native console/menu automation remains unavailable.
## Official native app results
After the normal launch, a second run disabled telemetry, used a task-local
server data directory and enabled a temporary loopback renderer debug port.
The app bundle was not patched. The following checks used its real renderer
and `window.frameApp` IPC bridge; they are not physical mouse/menu tests.
| Check | Result and limit |
|---|---|
| UI | Apps, Games, Tools, Settings, Privacy & updates, Capture and Decky views rendered with the expected headings. The activity log showed the stale SSH identity warning. [Native Apps screenshot](mac-v0439-native-2026-10-26.png) |
| Settings | Initial native preferences were `{}`. `mvSeparate="0"` saved and read back, including after a real app restart. The original preferences file was restored byte-for-byte in `finally` (or removed if originally absent) |
| Open at Login | Still opt-in in source; no login item was enabled. Actual menu checkbox and OS login behavior remain unverified because native automation was unavailable |
| Logs | `~/Library/Logs/Frame Control/server.log` recorded startup with the installed app's `Contents/Resources/python/bin/python3` and `ui/server.py`. Show Server Log menu interaction remains unverified |
| Update check | Native check returned current/latest `0.4.39`, status `none`, error `null`. No update installed |
| OBS | Native Start on port 15123 returned `starting`; Stop returned `stopping`, then `idle`. This was offline: no OBS receiver or real headset audio/video |
| Decky store | Native Load store returned 116 entries; 40 initial rows rendered. Search found ProtonDB Badges 1.3.4 and its compatibility text. Install remained disabled; no plugin/loader installed |
| Framey preview | No standalone local Framey preview control found. No VR runtime result claimed |
Closing the app through `window.close()` stopped its processes and closed
the debug port. **The final shutdown log exposed a bug:** stdin EOF and the
app's SIGTERM can overlap, raising `KeyboardInterrupt` while cleanup is
installing its ignore handler. Cleanup can then stop before releasing staging
or SSH resources. [#284](https://github.com/saphid/frame-control/pull/284)
changes SIGTERM to request HTTP shutdown without raising into cleanup.
The regression test injects SIGTERM at that exact cleanup boundary. On this
physical Mac with the official app's bundled Python 3.12, it failed before
the fix with exit -2 and the observed traceback. After the fix, all **45
server integration tests** passed, including owned staging-file cleanup.
This validates source from the fix PR, not a fixed published app.
## Headset relay and small transfer
`frame.local` resolved but TCP 22 returned **No route to host**. The native
app refused a stale SSH identity; no pin was replaced. The trusted lxso2
coordinator relay could reach the Frame. Both shared locks were absent.
The relay test atomically created `/tmp/frame-test.lock` on the coordinator
and the headset, each with this job's owner marker. It then:
- Read battery **99%, Not charging** before and after; storage total
**996,984,774,656 bytes**, free **769,077,035,008 bytes**.
- Listed **7** Steam screenshot JPEGs matching
`userdata/*/760/remote/*/screenshots/*.jpg`; no image was copied. This narrower
layout-specific count differs from the earlier broad JPEG search.
- Sent a **40-byte** test payload from this Mac through lxso2, created one
reserved file in the Frame user's home using exclusive creation, and read
it back. SHA-256 matched
`457485db13f80d819d5301d337502c3df050c6b3eb6202be7aac85d2af8e0b92`.
- Deleted that exact test file and released only the matching owned locks.
A separate SSH read verified the file and both locks were absent.
This proves a bounded relay transfer, not native Mac app file transfer or
direct `frame.local` connectivity. No existing headset file, app, service,
power setting or SteamVR state was changed. The headset was not wiped or
rebooted. Real OBS and wearer/controller interaction remain unverified.
The DMG was ejected; the native app, debug port and task browser tab were
closed; the desk hold was released. The official app remains installed for
reproduction. No social post was made; PR #113 was left alone.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 KiB

-58
View File
@@ -1,58 +0,0 @@
# OBS relay and MP4 follow-up, 25 October 2026 UTC
This check used a Linux x86-64 host (`lxso2`) and the real Frame running
SteamOS 0.5.6, build `20261022.6153909`. The host reached `ssh frame`. Before
device work, the task atomically acquired `/tmp/frame-test.lock`, wrote its
owner marker, and after cleanup removed only that marker and its lock. Battery
was 99–100%; SteamVR 2.18.2 reported `activity_level: 3` (standby). Steam and
SteamVR were left running. No passthrough session or game was started.
The production `scripts/obs-headset-source.py` relay reached its first local
UDP packet in 0.65–1.15 seconds across several runs. A 12-second wall-clock
capture of its MPEG-TS packets contained 325 H.264 frames at 1280×720,
spanning 10.83 seconds of video timestamps: 30.0 fps. All 324 adjacent frame
intervals were 33.333–33.334 ms, with none over 50 ms. The 826 captured TS
packets had zero sync-byte, transport-error-indicator, or per-PID continuity
errors. Six final TS packets arrived after the receiver's 12-second cutoff and
were not included in those counts. Static standby pixels cannot establish
whether a moving game would drop frames.
After the fix, the `--height 1080 --fps 60 --no-audio` fallback reached its
first packet in 0.597 seconds. An eight-second wall-clock capture contained
447 H.264 frames at 1920×1080 over 7.45 seconds of video timestamps: 60.0 fps.
All frame intervals were 16.666–16.667 ms, none over 25 ms, and its 756 TS
packets had zero continuity or transport-error-indicator errors. It had no
audio stream, as requested. The helper exited 0 on Stop, and the owned lock
was released.
The stream also contained 48 kHz stereo AAC. A 440 Hz synthetic tone played
through the Frame's default speaker output with real-time pacing appeared in
the capture for about 0.89 seconds; decoded audio had a -21.0 dB peak. This
proves an audio signal reached the output monitor and OBS transport. It does
not prove audible monitoring, real game audio, or audio/video sync: there was
no simultaneous visible marker to compare with the tone.
The production MP4 recording commands were run separately for six seconds,
then Stop, remux, and copy to the host completed. Stop returned after 7.27
seconds from Start, including six seconds of recording; the remote ffmpeg
exited 0. The copied 36,929-byte MP4 contained 188 H.264 frames at 1920×1080
and 30 fps (6.266 seconds), plus 281 AAC packets (5.994 seconds). One decoded
frame at two seconds had a single color, RGB `(22, 27, 53)`, across all
2,073,600 pixels. The test's remote files were removed; the host copy stayed
under `/tmp` for inspection.
The relay previously returned an error when the app deliberately closed its
control pipe, because its SSH child commonly exits 255 after termination.
The same test reproduced this (`helper_exit: 1`) on the real Frame. The follow-up
fix recognizes deliberate Stop and adds bounded startup and stall timeouts,
with fake-SSH regression tests. A new locked real-Frame check after the fix
received its first 1,316-byte UDP datagram in 0.697 seconds and returned
`helper_exit_after_stop: 0` after closing the app control pipe. The longer
transport and MP4 measurements above were taken before the code change; the
fix does not alter codecs or capture commands.
Prior [OBS 30 Media Source evidence](obs-media-source-real-2026-10-25.md)
records a real Frame stream in OBS on Linux. This follow-up measured the relay
with an FFmpeg receiver; OBS itself was not relaunched. Windows and macOS
application paths, wearer-visible image latency, real game sound, audible
playback, perceptual A/V sync, and active passthrough pixels remain unverified.
@@ -1,48 +0,0 @@
# OBS Media Source on a real Frame, 2026-10-25 AEDT
The Linux x86_64 AppImage from PR #174's exact head `69fff152` had SHA-256
`4902ac80b77fbf98ec918db29df9cf9260b8abd6763a5b5055572335869f038a`.
Its extracted resources passed `app/build/check-resources.js`, including the
OBS helper, and the app launched under Xvfb with an isolated profile and a
dummy SSH alias. Its bundled server served the page with HTTP 200.
For the device test, the PC was lxso2. The real Frame was reached as `ssh frame`
under an atomically acquired `/tmp/frame-test.lock`. Battery read 100% Full
before and after. SteamVR 2.18.2 reported `activity_level: 3` (standby). The
mirror service and `/dev/video99` were active. The test cleaned its temporary
remote WAV and released only its own lock.
OBS 30.0.2 ran in a disposable Ubuntu 24.04 container on lxso2, with Xvfb and
host loopback networking. A Media Source opened `udp://127.0.0.1:15099` with
input format `mpegts`. A synthetic pattern and tone first verified the OBS
scene: its saved 43.733-second MKV had H.264 video and AAC audio with a
`-17.9 dB` audio peak. The real Frame test then ran the helper from the
extracted AppImage resources **before** OBS opened the source, so OBS joined
an ongoing stream. OBS's saved 37.600-second MKV had H.264 video and AAC audio.
The standby mirror appeared in decoded frames by five seconds. A 440 Hz tone
played briefly to the Frame's default PulseAudio output appeared in the OBS
audio recording at seconds 20–21; decoded audio peaked at `-34.4 dB`. A
separate direct recording of `@DEFAULT_MONITOR@` during the same type of tone
peaked at `-34.5 dB`. These measurements verify non-silent headset output
audio passed through the helper and OBS into the saved recording.
The first OBS attempt opened its Media Source well before the Frame sent
packets. Its output had a silent AAC track until the mirror appeared late.
Starting the helper before OBS fixed this in the second run. The setup guide
now recommends starting the source in Frame Control before adding or activating
the OBS Media Source.
The exact-head AppImage was then launched on an isolated Xvfb display and
profile with the real `frame` alias. In **Tools → Capture & Live**, clicking
**Start OBS source** changed the UI to **Stop OBS source** and showed
`udp://127.0.0.1:15099` after the first stream packet. The packaged Python
helper was running with `--frame frame`. Clicking **Stop OBS source** returned
the UI to **Start OBS source**; the helper and its remote `/dev/video99` ffmpeg
process were gone. The app exited through **File → Quit**. Battery again read
100% Full, and the owned lock was released.
**Limits:** Nobody listened to OBS audio monitoring or the Frame speakers.
Audio/video sync was not measured. The headset was unworn and in standby, so
this test does not answer whether active passthrough pixels appear in the
mirror. The OBS MKVs were temporary local test artifacts and are not included
in the repository.
@@ -1,32 +0,0 @@
# 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
@@ -1,125 +0,0 @@
# 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`.
@@ -1,23 +0,0 @@
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))
@@ -1,136 +0,0 @@
{
"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
}
@@ -1,123 +0,0 @@
[
{
"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"
}
]
@@ -1,22 +0,0 @@
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))
@@ -1,88 +0,0 @@
{
"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"
}
]
}
@@ -1,16 +0,0 @@
{
"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
}
@@ -1,46 +0,0 @@
{
"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
}
@@ -1,13 +0,0 @@
{
"clock": {
"rtt": 0.0034094941802322865,
"offset_remote_minus_host": -2894789.9195547644
},
"hits": [
{
"source_index": 1000,
"delay_ms": 1431.418131571263,
"query_ms": 11.743285227566957
}
]
}
@@ -1,25 +0,0 @@
{
"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"
}
}
@@ -1,10 +0,0 @@
{
"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"
}
}
@@ -1,210 +0,0 @@
{
"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
}
}
]
}
@@ -1,26 +0,0 @@
[
{
"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
}
]
@@ -1,15 +0,0 @@
{
"baseline": "d79efb4",
"after_stop": {
"state": "stopping",
"alias": "frame",
"error": "",
"supported": true
},
"after_late_stderr": {
"state": "running",
"alias": "frame",
"error": "",
"supported": true
}
}
@@ -1,70 +0,0 @@
[
{
"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": []
}
]
@@ -1,46 +0,0 @@
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)
@@ -1,10 +0,0 @@
{
"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
@@ -1,43 +0,0 @@
{
"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
@@ -1,91 +0,0 @@
# 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
@@ -1,123 +0,0 @@
{
"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
]
}
]
}
@@ -1,19 +0,0 @@
# Round 3 headset checks, 25 October 2026
These checks used the real Frame after the device hold ended. SteamOS was
0.5.6, build `20261022.6153909`. Each device session acquired
`/tmp/frame-test.lock` atomically and released only its own lock. The battery
read 100% and Full in the first check and after the OBS session; Steam and
SteamVR were left running.
| Question | Observation | Conclusion |
|---|---|---|
| Does active passthrough appear in SteamVR's `/dev/video99` mirror? | The mirror service was active. One 1920×1080 RGB frame read from `/dev/video99` had a single pixel value, `(24, 28, 53)`, throughout. The headset was unworn; no active passthrough session or wearer-visible room view was established. Frame Control has no verified remote passthrough toggle. The temporary frame was removed. | **Still unknown.** A uniform standby frame cannot answer whether SteamVR composites active passthrough into its mirror. No headset setting was changed. |
| Can Frame Control prompt when a headset text box opens? | No desktop `plasmashell` process was running during this check. There is no verified cross-app focus event from SteamVR, Lepton and the nested desktop; KDE Connect's `keyboardstate` packet describes keyboard support, not a focused text field. | **Not built.** A prompt triggered by an unverified signal would be misleading. The existing **Type on the headset** control remains available. |
| Does reverse clipboard exist? | Frame Control already has **Copy the headset's clipboard here**. Its earlier real Frame test used a temporary nested desktop and confirmed that text copied through Klipper reached a Linux PC clipboard. No desktop panel was open during this session, so it was not rerun here. | **Shipped and previously verified**, text only. Windows/macOS backends and arbitrary headset apps remain unverified on a real Frame. |
| Is an 80% charge limit available and writable? | `steamosctl get-max-charge-level` returned `UnknownInterface: ...BatteryChargeLimit1`. No `charge_control_*` attribute was found under `/sys/class/power_supply`. Only `pm8550b-charger/charging_pause` and `charging_enable` were present, each mode `644`, owner `root:root`. | **No supported user-writable control.** No battery attribute was written. |
The separate OBS transport check on the same build sent the mirror and the
default-output monitor to a Linux PC. A 4.021-second temporary capture had
H.264 video and AAC audio according to `ffprobe`. It was removed; OBS itself
and audible playback were not tested. See the OBS source PR and guide.
@@ -1,25 +0,0 @@
# 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.
@@ -1,70 +0,0 @@
# 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.
@@ -1,24 +0,0 @@
# v0.4.36 release smoke on a real Frame
On 25 October 2026 at 22:59 UTC, the release branch ran
`scripts/frame-smoke.sh` from a physical Linux host against a real Steam Frame.
The script and its Python implementation were byte-identical to the
`v0.4.36` tag `1701e622dc1f9888560412a058fe816b19ca7e79`.
The headset reported Frame OS 0.5.6, build `20261022.6153909`.
The test acquired `/tmp/frame-test.lock` with a unique owner marker before
reading device status or changing test titles. Its first read showed SteamVR
activity level 3 (standby), proximity raw 6.3125, and battery 99%. The test
ran only while that lock was owned. It released only its own marker and lock
after the final cleanup.
All **13 of 13** smoke steps passed: cleanup of reserved leftovers, devkit
service read, status read, install/launch/remove for reserved ARM64, x86_64
and Windows test titles, and final cleanup. The test did not pair a key. The
test output was saved locally under `/tmp/frame-v0436-smoke-results/`; this
summary omits the headset identifier and private device status fields.
The headset was in standby. This test does not show wearer-visible game or
Framey pixels, controller input, live Decky settings, or plugin actions.
See [release v0.4.36](https://github.com/saphid/frame-control/releases/tag/v0.4.36)
for the tagged installer and checksum checks.
@@ -1,24 +0,0 @@
# v0.4.37 release smoke on a real Frame
On 26 October 2026 at 00:27 UTC, a physical Linux host ran the release
`scripts/frame-smoke.sh` against a real Steam Frame. The smoke scripts were
byte-identical to tag `v0.4.37` (`5239dae1a82c35635ef89f59eac9721c5014e2ed`).
The headset reported Frame OS 0.5.6, build `20261022.6153909`.
The test acquired `/tmp/frame-test.lock` with a unique owner marker before
reading the headset. SteamVR activity stayed at 3 (standby); initial proximity
raw was 6.3125 and battery was 99%. The test released only its own lock after
cleanup. Battery read 100% afterward.
All **13 of 13** smoke steps passed. ARM64 and Windows test titles installed,
launched, and were removed. The x86_64 title installed and was removed; its
launch reported that Steam Linux Runtime 4.0 was found but not installed, so
this result does **not** establish x86_64 game execution. Devkit properties and
status reads passed. No key was paired. The final cleanup removed the reserved
test titles and processes. The private result remains under
`/tmp/frame-v0437-smoke-results/`; this summary omits headset identifiers and
device status details.
Because the headset was in standby, this test did not verify wearer-visible
pixels, physical controller input, a Framey panel, or official Decky plugin
settings in VR. Those checks remain on the [worn checklist](../framey-vr-check.md).
@@ -1,32 +0,0 @@
{
"tag": "v0.4.38",
"commit": "d87b863aa3c61c9833045b29ba79c7534a351ef4",
"startedUtc": "2026-10-26T01:47:23Z",
"frameOSVersion": "0.5.6",
"frameOSBuild": "20261022.6153909",
"preflight": {
"steamVRActivity": 3,
"proximityRaw": 6.3125,
"batteryPercent": 100
},
"postflight": {
"steamVRActivity": 3,
"proximityRaw": 6.25,
"batteryPercent": 100
},
"smoke": {
"passed": 13,
"failed": 0,
"arm64": "running",
"x86_64": "runtime missing",
"windowsExe": "running",
"finalCleanupPassed": true,
"ownedProcessesAfterCleanup": 0
},
"sharedLock": "unique owner released",
"limits": [
"No worn headset",
"No physical controller input",
"No official Decky panel tested in this smoke"
]
}
@@ -1,27 +0,0 @@
# v0.4.38 real Frame release smoke
On 2026-10-26 at 01:47 UTC, the exact `v0.4.38` tag
`d87b863aa3c61c9833045b29ba79c7534a351ef4` passed `scripts/frame-smoke.sh`
on a real Frame running SteamOS 0.5.6 build `20261022.6153909`. The wrapper
atomically acquired `/tmp/frame-test.lock` with a unique owner, then read
SteamVR activity 3 (standby), proximity raw 6.3125 and battery 100%. It ran
the smoke only after that preflight.
All 13 smoke steps passed. The ARM64 test title ran. The x86_64 title correctly
reported its runtime missing. The Windows executable ran through the ARM64
Proton path. The final cleanup found no test processes and removed the reserved
smoke titles. The read-only postflight still found activity 3 and battery 100%;
the wrapper released only its own lock. The public numeric summary is
[v0438-release-smoke-2026-10-26.json](v0438-release-smoke-2026-10-26.json).
Private raw logs and process paths remain in `/tmp` and were not published.
The new built-in shared-lock preflight on this PR's branch separately passed
the same 13/13 smoke steps at 01:46 UTC, with activity 3, proximity raw 6.3125
and battery 100% before the run. The lock was released after cleanup. The local
`tests.test_smoke_cleanup` suite passed 20 tests, including refusal on a busy
lock and release after a failed preflight. The built-in preflight was **not**
part of the v0.4.38 tag; that release used the external lock wrapper described
above.
These checks did not involve a worn headset or prove Framey panel visibility,
physical controller interaction, or optical comfort.
-212
View File
@@ -1,212 +0,0 @@
{
"tag": "v0.4.39",
"tagCommit": "fd0b0a946f11d92fd1cc480cd9d1a83be795bf71",
"releaseUrl": "https://github.com/saphid/frame-control/releases/tag/v0.4.39",
"releaseId": 423799494,
"releaseWorkflow": {
"runId": 40258561639,
"conclusion": "success",
"jobsPassed": 6
},
"publishedLatest": true,
"assets": [
{
"name": "Frame-Control-linux-amd64.deb",
"size": 131444552,
"digest": "sha256:9ca74551fe47ea244f500e1b4119c02307d185d1acea6287a02a5f6c055c674e"
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 156170292,
"digest": "sha256:66bd8f644f6a0b06f1e46ff635c5b50e53d3f2a9c6736f12b737ee0ad1667335"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 118529048,
"digest": "sha256:f6b476a2f3163028d0862a13d0c8890760bdc8fe52c1006f106d9e540de32a6a"
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 164587947,
"digest": "sha256:240023c89bea049f0864da75f83ec235c138375c4fd1c4612953a4c5863610ca"
},
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 162169626,
"digest": "sha256:c0243317555975b1a5588640ec6fe71db5014788858ff00dd45914b77c1fe99a"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 162054585,
"digest": "sha256:08191f80d6c78ac8576e2e2fe7937f5c1689ebed780528e63b6fb41b3c90d823"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 169230398,
"digest": "sha256:f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 169141014,
"digest": "sha256:bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 136500288,
"digest": "sha256:44a9f4c072b8c10ec81b060484764af10607763c69d55ecdad6bd0b103b7207e"
},
{
"name": "Frame-Control-win-x64.zip",
"size": 183446427,
"digest": "sha256:14f3dae327f054431ecb7ed204144dc529e04a03b35b646925bd9812dc54c504"
}
],
"sha256sumsDigest": "sha256:59cff0ab59e2a681656c0b15b3445ca0baa0546d03196f8c4ae58eae8785db25",
"updateJsonDigest": "sha256:afef87fa3869be3acf06dfaa4831a97b29ce9c5d275940a2202bc958c62f4d67",
"publisherDryRun": {
"passed": true,
"installerDigests": 10,
"digestAndChecksumChecks": 11,
"completeNotesCharacters": 3837
},
"downloadedAppImage": {
"name": "Frame-Control-linux-x86_64.AppImage",
"sha256": "240023c89bea049f0864da75f83ec235c138375c4fd1c4612953a4c5863610ca",
"isolatedXvfbHttpStatus": 200,
"serverStopped": true,
"serverHeaderPrefix": "FrameControl/1"
},
"downloadedIntelMacZip": {
"name": "Frame-Control-mac-x64.zip",
"sha256": "bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7",
"machOCpu": "x86_64",
"inspected": [
"app executable",
"bundled Python 3.12",
"Electron helper"
],
"physicalRuntime": "unverified"
},
"realFrameSmoke": {
"sourceTag": "v0.4.39",
"started": "2026-10-26T02:20:45Z",
"buildId": "20261022.6153909",
"preflight": {
"activity": 3,
"proximity": [
6.3125
],
"battery": [
100
]
},
"passed": 13,
"failed": 0,
"steps": [
{
"name": "cleanup: leftovers from earlier runs",
"passed": true,
"seconds": 8.2
},
{
"name": "devkit service: properties.json",
"passed": true,
"seconds": 0.0
},
{
"name": "status",
"passed": true,
"seconds": 0.6
},
{
"name": "arm64: install",
"passed": true,
"seconds": 8.0
},
{
"name": "arm64: launch",
"passed": true,
"seconds": 3.5
},
{
"name": "arm64: remove",
"passed": true,
"seconds": 6.8
},
{
"name": "x86_64: install",
"passed": true,
"seconds": 7.9
},
{
"name": "x86_64: launch",
"passed": true,
"seconds": 3.5
},
{
"name": "x86_64: remove",
"passed": true,
"seconds": 6.6
},
{
"name": "exe: install",
"passed": true,
"seconds": 8.0
},
{
"name": "exe: launch",
"passed": true,
"seconds": 3.4
},
{
"name": "exe: remove",
"passed": true,
"seconds": 6.5
},
{
"name": "cleanup: everything this run installed",
"passed": true,
"seconds": 8.1
}
],
"ownedLockReleased": true
},
"postReleasePr282": {
"headCommit": "ce6e8f807f44b614e5bbd5c5a5a6a2093236d917",
"mergedCommit": "67b9151513bc9fb550f5dfb2d2eec925df390ab3",
"exactHeadCiPassed": 11,
"unwornVrExercise": {
"passed": true,
"beforeActivity": 3,
"beforeBattery": [
99
],
"afterBattery": [
99
],
"overlayVisible": true,
"compositorChanged": true,
"stereoCaptureSha256": "c7edc11877aca35dd3aebe480feee93253d04825fe9bd75d02136bb7e3510b97",
"mirrorCaptureSha256": "8e5932fb0046b218a1781a6b06cfaa6e3c13dc7874799e287066ed6e43640cd4",
"syntheticPointerInputPassed": true,
"liveSettingReopened": true,
"nativeScrollMoved": false,
"powerRecoveryRemoved": true,
"ownedLockReleased": true,
"cleanupErrors": [],
"physicalControllerVerified": false,
"wornVisualVerified": false,
"officialPluginsInVrVerified": false
},
"realFrameSmoke": {
"passed": 13,
"failed": 0,
"beforeActivity": 3,
"battery": [
99
],
"buildId": "20261022.6153909",
"ownedLockReleased": true
}
}
}
-25
View File
@@ -1,25 +0,0 @@
# v0.4.39 release gate, 26 October 2026
The [published release](https://github.com/saphid/frame-control/releases/tag/v0.4.39) is GitHub's latest release. Its tag resolves to main commit `fd0b0a946f11d92fd1cc480cd9d1a83be795bf71`, after PRs #280 and #281 merged. The [tagged workflow](https://github.com/saphid/frame-control/actions/runs/40258561639) passed all six jobs: draft, Apple Silicon Mac, Intel Mac, Windows, Linux and checksums. The release has ten installers, `SHA256SUMS` and `update.json`. [Sanitized numeric results](v0439-release-2026-10-26.json) contain the exact asset sizes and GitHub digests.
| Gate | Result |
|---|---|
| `scripts/publish-release.sh --dry-run v0.4.39` from the exact tag | Passed: all ten installer digests, regenerated `SHA256SUMS` digest, complete update notes and ten-asset `update.json` |
| Downloaded Linux x86_64 AppImage | SHA-256 matched `240023c89bea049f0864da75f83ec235c138375c4fd1c4612953a4c5863610ca`; the tagged isolated Xvfb check received HTTP 200 with a `FrameControl/1` server header prefix and confirmed the server stopped |
| Downloaded Intel Mac ZIP | SHA-256 matched `bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7`; ZIP inspection found x86_64 Mach-O app executable, bundled Python 3.12 and Electron helper |
| Exact tagged `scripts/frame-smoke.sh` on the real Frame | **13/13** steps passed on SteamOS build `20261022.6153909`; locked preflight found SteamVR activity 3, proximity raw 6.3125 and battery 100%. Reserved test titles were removed and the script released its owner-token lock. |
| Published release readback | GitHub reports `draft=false`, `prerelease=false`, latest tag `v0.4.39`; downloaded `update.json` lists ten unique assets, the stable release URL and complete safety notes |
The first local AppImage launch attempt stopped before app startup because the execution sandbox denied Electron's sandbox host (`Operation not permitted`). Repeating the same tagged check with host permissions passed. The bundled release CI also ran its own AppImage launch check.
The unworn VR runner's candidate evidence is [documented separately](framey-vr-standby-gate-2026-10-26.md). Its successful stereo and `/dev/video99` panel captures and synthetic OpenVR input are not a worn or physical-controller test. Two other attempts found an intermittently hidden overlay and failed safely. No person wore the headset for this release check. Physical controller input, optical comfort, VR keyboard, native scroll and official-plugin wearer-visible behavior remain unverified.
The physical Intel MacBook Air ran an earlier candidate, but was unreachable for this exact tagged installer. The architecture and hash checks above do not prove physical Mac runtime. Physical Windows PC and macOS OBS/headset capture also remain unverified. No private room pixels or raw Steam log lines are included here.
## Post-release Framey library change (#282)
After v0.4.39 publication, [PR #282](https://github.com/saphid/frame-control/pull/282) merged at `67b9151513bc9fb550f5dfb2d2eec925df390ab3`. All eleven exact-head checks passed on `ce6e8f807f44b614e5bbd5c5a5a6a2093236d917`. This commit is **not** in v0.4.39. The [library-context evidence](framey-library-context-2026-10-26.md) covers its three newly partial official plugins and separate backend limits.
The exact #282 source ran `scripts/framey-vr-check.py --exercise` under the shared lock from standby (activity 3, battery 99%). The sample panel became visible; stereo compositor pixels changed and `/dev/video99` mirror bytes were captured. Trusted synthetic OpenVR pointer input reached the repository sample's live Decky setting and the saved value survived reopening. Native scroll did not move. The runner removed its recovery timer, restored original power values, reported no cleanup errors and released its owner-token lock. Private images and room pixels remain outside this repository. The source came from an exact commit archive, so the runner's `sourceCommit` field was empty; the archive object ID was verified before extraction.
Once the Frame returned to standby, the exact #282 source passed `scripts/frame-smoke.sh` **13/13** on the real SteamOS build `20261022.6153909`, with battery 99% at preflight. Test titles were removed and the lock was released. These checks cover the Framey sample and generic Frame smoke, not Steamback, PlayTime or Deck Settings in VR. No wearer or physical controller was involved; those limits remain open.
@@ -1,9 +0,0 @@
# v0.4.40 candidate: locked real Frame gates, 26 October 2026
Source commit `9aa26a8322f74144c13528b6fb41edc451ec7eea` contains merged PRs #286–#288 and the candidate 0.4.40 version bump. The Frame ran SteamOS 0.5.6 build `20261022.6153909`. These are source checks before tagged installers exist.
The unworn `scripts/framey-vr-check.py --exercise` acquired its random-owner `/tmp/frame-test.lock` from lxso2. Its locked read-only preflight found SteamVR activity 3 (standby), proximity 6.25, battery 100%, and original power values `false` and `5`. The repository sample panel became visible, the stereo compositor image changed, and `/dev/video99` supplied a mirror frame. Synthetic OpenVR pointer input produced a trusted click, changed the sample's live Decky setting, and the saved value survived a fresh iframe reopen. The quick action and native slider/text control paths completed; native scrolling did not move. The run exited 0, removed its temporary Decky/panel services and recovery timer, restored and read back the original power values, reported no cleanup errors, found battery 99%, and released its own lock. Private room and UI captures remain outside the repository.
After the Frame returned to standby, `scripts/frame-smoke.sh` acquired a separate random-owner lock. Its preflight found activity 3, proximity 6.25, and battery 100%. All **13/13** steps passed, including ARM64, x86-64 and Windows-executable install/launch/remove and final cleanup. The command exited 0, which includes successful release of its owned lock. Its private detailed result remains outside the repository.
These checks exercise Frame Control's repository sample and generic title paths. They do not run the official Deck Shelves backend or prove its panel or Steam Home shelves in VR. The run did not include a wearer, physical controller, VR keyboard, native scrolling, or optical comfort check. A mirror capture and changed compositor pixels do not by themselves prove wearer-visible passthrough for this run. The [compatibility table](../framey-compatibility.md) retains those limits.
-81
View File
@@ -1,81 +0,0 @@
# 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.
+1 -5
View File
@@ -84,11 +84,7 @@ counts them while they run.
- **Volume** and mute (`wpctl`).
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
one with a click as its own Lepton instance (it keeps its data and shows in the
Steam library), then launch, stop, test or remove it.
The catalogue can load while the installed-app list is still being read. If
that read fails, browsing remains available, but installation waits for a
confirmed list so Frame Control does not make a duplicate app instance.
**Report an APK** records
Steam library), then launch, stop, test or remove it. **Report an APK** records
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
installed app). Your reports are saved on your computer and change the verdicts
you see. With **Share compatibility results** on (Privacy & updates), they also
-103
View File
@@ -1,103 +0,0 @@
# 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 and API v1 bundles that use Decky's modern
frontend API initializer. 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.
For persistent backend settings, use Decky's `decky.DECKY_PLUGIN_SETTINGS_DIR` rather
than assuming an environment variable or Steam Deck home path. Validate stored
types when reading, write through a temporary file before replacing the old
file, and give the panel a safe default if settings are missing or damaged.
Removing a managed plugin leaves its Decky settings behind; document how users
can reset data your plugin owns.
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 Decky API 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 |
| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; recognized legacy IIFE bundles also use the legacy bridge | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
| Legacy Decky IIFE frontend, with no API manifest field | Wraps the returned initializer as an ES module inside the same opaque iframe; passes `serverAPI` and preserves the legacy title | MusicControl 1.1.6 exact official archive, local Chromium startup with backend methods blocked; fake Frame installation/pack/removal |
| Legacy `serverAPI.callPluginMethod(method, kwargs)` | Uses Decky's `loader/call_legacy_plugin_method` RPC, preserving keyword arguments and `{success, result}` replies | Fake Frame HTTP/CDP read/write with a synthetic backend; no official plugin backend action or worn VR result |
| 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; disabled controls stay inert during a backend call | Local browser and host unit test |
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
| `SteamClient` subscriptions and `DFL.Router.MainRunningApp` | Explicit inert registration methods return disposable handles; no events or attached running game. Mutating Steam/power methods are unavailable. | Host contract tests; these are safe fallbacks, not live Steam functionality |
| `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check |
| Optional `settings`, `frameyQuickActions` | Separate settings tab when content also exists; settings-only plugins have no empty Panel 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; real ARM64 Notebook save/reopen across backend restart and Simple Timer settings/recents persistence via local Chromium. No worn-VR claim. |
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
| `fetchNoCors`, 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.
Optional route segments must be at the end. Framey accepts local path segments
only; query strings, external URLs, Steam library routes and duplicate parameter
names are rejected. Removing an active route returns to the plugin panel.
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
An API v1 manifest does not guarantee a working Framey panel. Test the exact
store archive and do not assume SteamClient app details, library patches, or
destructive actions work in VR. The
[Storage Cleaner browser check](evidence/framey-api-v1-browser-2026-10-25.md)
used empty stub data and did not invoke its cleanup actions.
Use the Decky Python backend for settings that must survive browser cache loss.
The Framey `localStorage` bridge is for disposable frontend caches, including
the cache read attempted by Steam Status 1.2.3. It does not expose another
plugin's cache or the Frame Control UI key. A real Steam Status panel reopen
and a worn-headset settings check are still needed.
HLTB for Deck 2.0.12 demonstrates the limit of the panel shim: its official
bundle can start and produce a settings view with Framey's Steam module lookup
stub, but its game page and context menu patches have no Steam target in the
isolated panel. The tests do not call its HLTB network search or Python backend.
Do not claim game length badges work in VR from this result.
-313
View File
@@ -1,313 +0,0 @@
# Official Decky store compatibility with Framey
Snapshot: 2026-10-25T23:19:48.854504+00:00; 116 current visible official plugins from https://plugins.deckbrew.xyz/plugins.
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
**Works: 1 · Partial: 50 · Broken: 54 · N/A: 11.**
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
- **Broken** means a concrete archive, frontend or Steam dependency blocks the audited version. An artificial backend/network denial alone is not evidence that the real backend is broken.
- **N/A** means the plugin targets hardware or a display/UI surface that Framey does not provide. The store disables installation for these versions.
## Verification limits
All exact archives were hash-checked and statically inspected. The local Chromium host used Framey commit `779f9c5`, React 18.3.1 and an opaque `allow-scripts` iframe. Each frontend was attempted for 1.2 seconds, including React effects. All plugin backend calls, fetches and websockets were denied. Some plugins attempt mutating actions on mount; none reached a backend. Initialization (`ready`) does not guarantee React rendered, and delayed errors/actions may occur after the observation window. The JSON preserves initializer errors, capped console observations and attempted method names.
The optional full-catalog fake-Frame audit exercises the real HTTP/SSH installer, receipt, frontend bytes and removal, or expected rejection. It uses a private disposable Compose project with all plugin backend execution stubbed. It checks archive transport and safeguards, not Python/native execution. Run it with:
```sh
python3 scripts/framey-audit-store.py --cache /tmp/framey-archives --output /tmp/framey-observations.json
python3 scripts/framey-audit-fake.py --cache /tmp/framey-archives --output /tmp/framey-fake/results.json
python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives
```
Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. These three stay partial: real ARM64 Frame audio dependencies/session mapping remain unverified because the shared headset lock was occupied. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. Junk-Store loading/error views render, but its real backend cannot execute the extracted startup script, so it stays broken. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. TabMaster/Junk-Store ran in actual isolated amd64 Decky; no ARM64 or worn-VR result is claimed. Beyond the documented checks, official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, install folders and system information, plus IsThereAnyDeal's country dropdown in local Chromium. The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md).
## Every current plugin
The API column shows manifest / actual bundled frontend API. Many manifests say v1 while their bundle connects API2. Missing manifest API is labelled legacy; an unrecognized bundle is unknown. Globals and hardware are static signals, not exhaustive dependencies.
| Plugin / version | Manifest / bundle API | Status | Reason | Verified by |
| --- | --- | --- | --- | --- |
| 1: XR Gaming 1.6.2 | 1 / 2 | N/A | Not supported on Frame: Requires supported external XR glasses and the Breezy/XRLinuxDriver stack; it does not target Steam Frame displays. | static archive, local browser, fake-Frame |
| 7: CSS Loader 2.1.3 | legacy / legacy | partial | Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. | static archive, local browser, fake-Frame |
| 10: vibrantDeck 2.0.1 | legacy / legacy | N/A | Not supported on Frame: Changes X11 gamescope color properties, not the Frame SteamVR compositor. | static archive, local browser, fake-Frame |
| 11: Fantastic 0.5.1 | legacy / legacy | N/A | Not supported on Frame: Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. | static archive, local browser, fake-Frame |
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Legacy media controls initialize; player discovery and playback are unverified. | static archive, local browser, fake-Frame |
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings render locally with inert system-key subscriptions and empty detached Steam context. Live-game discovery, process pause/resume and suspend integration are unsupported/unverified; no backend actions ran. | static archive, local browser, fake-Frame |
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel. | static archive, local browser, fake-Frame |
| 18: Bluetooth 3.1.1 | 1 / 2 | partial | Controls render with fixture replies; actual Bluetooth discovery, labels and connections remain unverified. | static archive, local browser, fake-Frame |
| 19: Notebook 0.1.5 | legacy / legacy | works | Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Per-game auto-selection and background artwork unavailable; worn-VR input unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 21: PowerTools 2.0.3 | legacy / legacy | N/A | Not supported on Frame: Targets AMD Ryzen power controls; Frame uses an ARM64 SoC. | static archive, local browser, fake-Frame |
| 22: Memory Deck 0.1.8 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: memory-deck/bin/libscanmem.so.1.0.0. | static archive, local browser, fake-Frame |
| 23: Animation Changer 1.3.2 | legacy / legacy | partial | Initializer succeeds; forced backend failure prevents loading animations. Boot-video changes untested. | static archive, local browser, fake-Frame |
| 24: RadiYo! 1.3 | legacy / legacy | partial | Initializer succeeds; radio discovery and playback remain unverified. | static archive, local browser, fake-Frame |
| 26: HLTB for Deck 2.0.12 | 1 / 2 | partial | Settings render without the prior IndexedDB SecurityError; Steam library badges have no patch target, and live lookup is unverified. | static archive, local browser, fake-Frame |
| 27: AutoSuspend 2.2.0 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of null (reading '6'). | static archive, local browser, fake-Frame |
| 28: Emuchievements 2.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: Emuchievements/bin/hash. | static archive, local browser, fake-Frame |
| 30: TunnelDeck 1.0.4 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 35: MoonDeck 1.13.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: moondeck/python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so. Frontend initializes; native CPython wheels and executables in the published archive are x86_64. | static archive, local browser, fake-Frame |
| 36: SteamGridDB 1.8.1 | 1 / 2 | broken | Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel. | static archive, local browser, fake-Frame |
| 37: Discord Status 1.5.0 | 1 / 2 | broken | Detached app overviews are now available, but RunningApps/lifetime observations and Steam artwork helpers still lack a live mapping. Discord activity integration remains unavailable/unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | partial | Local Chromium discovers and changes a private silent stream through the actual Decky 3.3.0 Linux backend, with independent Pulse readback. Frame ARM64 libraries/audio session and Bluetooth playback controls remain unverified. | static archive, local browser, fake-Frame, isolated Linux backend |
| 40: Controller Tools 2.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: ControllerTools/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
| 43: Tailscale Control 0.1.3 | legacy / legacy | partial | Settings render locally after the SuspensefulImage fallback. Tailscale daemon/state, connection and exit-node changes unverified; bundled localhost QR asset remains unavailable. No network settings were changed. | static archive, local browser, fake-Frame |
| 44: DeckMTP 1.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: DeckMTP/bin/umtprd. | static archive, local browser, fake-Frame |
| 46: Steamback 1.0.0-1 | legacy / legacy | partial | Panel opens using the real Frame current-user/library snapshot. Account setup and empty save-list reads pass through actual Decky 3.3.0 amd64 in isolation. ARM64 backend, supported-save discovery, automatic backups and restore remain unverified; no backup/restore ran. | static archive, local browser, fake-Frame, real Frame SSH |
| 48: Quick Launch 1.2.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | Empty-data panel rendered; cleanup was deliberately not run because it can remove local data. | static archive, local browser, fake-Frame |
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'type'). | static archive, local browser, fake-Frame |
| 53: Shotty 0.1.0 | legacy / legacy | broken | Library snapshot fixes the frontend allApps crash. Actual published Decky 3.3.0 amd64 backend fails importing click.get_app_dir (ModuleNotFoundError); the archive does not bundle click. Screenshot aggregation/rescue was not run. | static archive, local browser, fake-Frame, real Frame SSH |
| 56: MangoPeel 0.0.7 | 1 / 2 | partial | Initializer succeeds; gamescope/MangoHud actions and backend are unverified. | static archive, local browser, fake-Frame |
| 58: Volume Boost 0.0.7 | legacy / legacy | partial | Local Chromium sync/separate sliders, numeric volume entry and cache reopen pass through the actual Decky 3.3.0 backend on a private virtual stereo sink. Frame pactl/default sink, boost above 100% and physical audio remain unverified. | static archive, local browser, fake-Frame, isolated Linux backend |
| 59: Web Browser 1.4.3 | legacy / legacy | broken | Frontend audit failed: deckyFrontendLib.getReactRoot is not a function. | static archive, local browser, fake-Frame |
| 60: DeckyFileServer 1.2.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend. | static archive, local browser, fake-Frame |
| 61: TabMaster 2.15.1 | 1 / 2 | partial | Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified. | static archive, local browser, fake-Frame |
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
| 63: Free Loader 1.6.0 | 1 / 2 | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. | static archive, local browser, fake-Frame |
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. ARM64 backend, live MobX/running-game observations and automatic recording remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
| 67: Battery Tracker 0.2.0 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 68: Wine Cellar 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-wine-cellar/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
| 70: MicroSDeck 0.11.2 | legacy / 2 | broken | Plugin contains a non-ARM64 Linux binary: MicroSDeck/bin/backend. | static archive, local browser, fake-Frame |
| 72: ScreenshotUploader 0.0.3 | legacy / legacy | partial | Initializer succeeds; screenshot upload requires external credentials/network and was not run. | static archive, local browser, fake-Frame |
| 73: EmuDecky 1.0.2-1 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: EmuDecky/bin/hello. | static archive, local browser, fake-Frame |
| 74: KDE Connect 0.1.0-1 | legacy / legacy | partial | Initializer succeeds; KDE Connect installation/pairing/file transfers unverified. | static archive, local browser, fake-Frame |
| 75: Reshadeck 0.2.0 | legacy / legacy | partial | Initializer succeeds; ReShade deployment into Proton games is unverified. | static archive, local browser, fake-Frame |
| 76: CheatDeck 1.1.6 | 1 / 2 | broken | Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
| 77: BT Wake Control 1.2.0 | 1 / 2 | partial | Initializer succeeds; backend uses DMI board paths and Bluetooth wake permissions, unverified on ARM64 Frame. | static archive, local browser, fake-Frame |
| 78: Brightness Bar 1.0.4 | legacy / legacy | N/A | Not supported on Frame: Targets Steam/QAM brightness shortcuts and a global Steam overlay; neither is attached to the Framey panel. | static archive, local browser, fake-Frame |
| 79: Decky Notifications 1.0.1 | legacy / legacy | partial | Initializer succeeds; device/account setup and notification delivery unverified. | static archive, local browser, fake-Frame |
| 80: MagicBlack 2.0.2 | 1 / 2 | N/A | Not supported on Frame: Targets a global Steam Deck screen-black overlay; the isolated panel cannot black out the Frame compositor. | static archive, local browser, fake-Frame |
| 81: IsThereAnyDeal for Deck 1.1.1 | legacy / legacy | partial | Country settings render and select the real Frame Steam country through the read-only bridge. Settings writes, deal lookups and Steam page integration remain unverified; plugin backend calls were denied. | static archive, local browser, fake-Frame, real Frame SSH |
| 82: MagicPods 2.0.21 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: MagicPodsDecky/bin/magicpodscore. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
| 84: Junk-Store 1.1.13 | legacy / legacy | broken | Local CSS lookup fixes the loading view crash. Actual Decky 3.3.0 amd64 startup/static InitActions return a script execution/JSON error; the published scripts are not executable after extraction. Store install/login/game launch and ARM64 remain unverified. | static archive, local browser, fake-Frame |
| 85: Decky-Undervolt 1.0.8 | 1 / 2 | N/A | Not supported on Frame: RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. | static archive, local browser, fake-Frame |
| 87: ScreenSaver 1.1.2 | legacy / legacy | partial | Initializer succeeds; global screen overlay and idle/suspend integration have no verified VR target. | static archive, local browser, fake-Frame |
| 88: steamdeck-input-disabler 1.0.2 | legacy / legacy | N/A | Not supported on Frame: Targets the built-in Steam Deck USB gamepad; Frame has different controllers. | static archive, local browser, fake-Frame |
| 89: DeckySpy 0.6.7 | legacy / legacy | partial | Initializer succeeds; account tokens and external service integration were not exercised. | static archive, local browser, fake-Frame |
| 90: Picture in Picture 1.0.0 | 1 / 2 | partial | Initializer succeeds; global picture-in-picture injection cannot escape the isolated panel. | static archive, local browser, fake-Frame |
| 91: PlayCount 1.7 | 1 / 2 | partial | Initializer succeeds; Steam game-page play-count patch has no supported VR target. | static archive, local browser, fake-Frame |
| 92: Crosshair 1.1.0 | 1 / 2 | partial | Initializer succeeds; global crosshair injection has no supported compositor target. | static archive, local browser, fake-Frame |
| 93: Deck Settings 1.2.6 | 1 / 2 | partial | Game picker renders with detached real Frame library/install-folder reads; search modal opens in local Chromium. Snapshot refreshes on panel reopen. Remote reports/proxy fetch, settings changes and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 94: Decky DoT DNS 0.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 95: Decky-SpoofDPI 0.12.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-spoof-dpi/bin/spoofdpi. | static archive, local browser, fake-Frame |
| 96: Simple Timer 1.0.0 | 1 / 2 | partial | Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 97: Decky-Framegen 0.11.15 | 1 / 2 | broken | Plugin archive size or SHA-256 did not match the store. | static archive, local browser, fake-Frame |
| 98: Decky-Lookup 0.1.0 | 1 / 2 | partial | Initializer succeeds; game-page context actions and live lookups remain unverified. | static archive, local browser, fake-Frame |
| 99: Decky Zerotier 0.3.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 100: Virtual Surround Sound 0.0.5 | 1 / 2 | partial | Initializer succeeds; avatar proxy is unavailable and live audio-routing backend unverified. | static archive, local browser, fake-Frame |
| 101: XIVOmega 1.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 102: DeckSP 1.1.3 | 1 / 2 | broken | Optional UI-audio lookup succeeds, but the published frontend raises "Cannot access PluginManager before initialization" when Steam MobX reaction lookup is unavailable. JamesDSP installation/audio routing and ARM64 remain unverified. | static archive, local browser, fake-Frame |
| 103: Decky Ludusavi 1.2.2 | 1 / 2 | partial | Initializer succeeds; Flatpak Ludusavi discovery and backup/restore unverified. | static archive, local browser, fake-Frame |
| 104: Game Sync 1.0.4 | 1 / 2 | partial | Backend get_config is intentionally rejected by the audit before initialization completes; actual backend compatibility is unknown. | static archive, local browser, fake-Frame |
| 105: WiFi Locker 1.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 106: LetMeReShade 1.8 | 1 / 2 | partial | Initializer succeeds; Deck-model assumptions, shader downloads and game modifications unverified. | static archive, local browser, fake-Frame |
| 107: Decky Sunshine 2025.10.27-dddf365 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 108: HueSync 3.8.0 | 1 / 2 | N/A | Not supported on Frame: Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. | static archive, local browser, fake-Frame |
| 109: MuraDeck 2.1.4 | 1 / 2 | N/A | Not supported on Frame: Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. | static archive, local browser, fake-Frame |
| 110: Dimmer Deck 0.0.4 | 1 / 2 | N/A | Not supported on Frame: Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. | static archive, local browser, fake-Frame |
| 111: Music Control 1.1.7 | legacy / legacy | partial | Panel initializer succeeds with backend/network blocked; actual controls and backend remain unverified. | static archive, local browser, fake-Frame |
| 112: Better Keyboard 0.3.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: window.SteamClient.UI.GetUIMode is not a function. | static archive, local browser, fake-Frame |
| 113: Decky LSFG-VK 0.12.2 | 1 / 2 | partial | Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. | static archive, local browser, fake-Frame |
| 114: Network Sentinel 1.0.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 115: Decky Clash 1.3.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 116: Muon 0.2.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 117: DeckyParty File Manager 1.0.0 | legacy / legacy | partial | Initializer succeeds; privileged file operations and backend are unverified. No file changes were run. | static archive, local browser, fake-Frame |
| 118: Speed Test 1.0.2 | 1 / 2 | partial | Initializer succeeds; automatic bandwidth tests were blocked by the audit network policy. | static archive, local browser, fake-Frame |
| 119: Decky Clipboard 1.0.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-clipboard/bin/xclip. | static archive, local browser, fake-Frame |
| 120: Reroll 0.2.1 | 1 / 2 | broken | Frontend now initializes with inert game-action subscriptions, but the archive contains x86_64 memscan.so and requests root. No process-memory access was run. | static archive, local browser, fake-Frame |
| 121: DeckPass 1.1.1 | 1 / 2 | partial | Initializer succeeds; KeePass Flatpak discovery and secrets integration were not exercised. | static archive, local browser, fake-Frame |
| 123: Steam Status 1.2.3 | 2 / 2 | partial | Settings/public status rendered locally; cache errors fixed. Read-only get_settings RPC returned actual Decky 3.3.0 defaults on ARM64 Frame. Persistence, live backend diagnostics and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 124: AlarMe 1.6.3 | 1 / 2 | partial | Initializer succeeds; timers, reminders, suspend inhibition and audio are unverified. | static archive, local browser, fake-Frame |
| 125: Decky Localsend 0.4.5 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core. | static archive, local browser, fake-Frame |
| 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
| 127: Decky Translator 0.9.0 | 1 / 2 | partial | Initializer succeeds; x86_64 download and hidraw/controller assumptions need an ARM64 audit. OCR/translation not exercised. | static archive, local browser, fake-Frame |
| 128: Quick User Switcher 1.0.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 129: ShadowDeck 1.1.11 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 130: Games Prefix Manager 1.0.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 131: Download All 1.1.0 | 1 / 2 | broken | Initializer succeeds; Steam download registration is absent, so the primary action is unavailable. | static archive, local browser, fake-Frame |
| 132: SuggestMe 1.5.3 | 1 / 2 | partial | Initializer succeeds; recommendation backend and Steam library data unverified. | static archive, local browser, fake-Frame |
| 133: Non-Steam Badges 0.1.0 | 1 / 2 | partial | Initializer succeeds; Non-Steam game badges have no supported Steam page injection target. | static archive, local browser, fake-Frame |
| 134: Deck Progress Tracker 1.3.6 | 1 / 2 | partial | Initializer succeeds; Steam game/library statistics and page patches unverified. | static archive, local browser, fake-Frame |
| 135: Deck Shelves 2.0.0 | 1 / 1 | partial | The exact chunked frontend now initializes and renders Deck Shelves settings in an opaque local Chromium panel. Backend calls were denied; Home-screen shelf injection has no verified Steam UI target in Framey. Settings writes, real backend behavior and worn VR are unverified. | static archive, local browser, fake-Frame |
| 136: Decky-Clipper 1.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: Decky-Clipper/bin/gstreamer-1.0/libgstproxy.so. | static archive, local browser, fake-Frame |
| 137: Decky Proton Launch 0.9.0 | 1 / 2 | partial | Initializer succeeds; Proton wrapper installation and launch changes were not run. | static archive, local browser, fake-Frame |
| 138: Docky 1.4.13 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 139: Output Manager 1.4.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 140: IPv6 Enabler Plugin 0.0.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 141: DeckyClocker 1.0.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 142: MuteOnSleep 1.0.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 143: Desktop Apps 0.1.0 | 1 / 2 | partial | Initializer succeeds; Flatpak app discovery and Steam shortcut changes unverified. | static archive, local browser, fake-Frame |
| 144: Mixer 0.1.0 | 1 / 2 | partial | Local Chromium numeric stream volume and filter save/reopen pass through the actual Decky 3.3.0 Linux backend; filters survive its restart. Frame pw-dump/wpctl, real audio session and worn VR remain unverified. | static archive, local browser, fake-Frame, isolated Linux backend |
| 145: VARTA 1.0.21 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
## API, UI and backend inventory
DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stores and router/toaster/QAM signals are listed separately. No signal means the static scan did not detect one, not that no dependency exists. Native binaries are classified by ELF machine or Windows MZ header; Python files and source links are preserved in the JSON.
| Plugin | SteamClient methods | DFL / @decky/ui exports | Other Steam globals / UI surfaces | Backend | Hardware / OS signals |
| --- | --- | --- | --- | --- | --- |
| XR Gaming | none detected | ButtonItem, ConfirmModal, DialogButton, DialogButtonPrimary, DialogButtonSecondary, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, Spinner, TextField, ToggleField, definePlugin, gamepadDialogClasses, showModal, staticClasses | window.SP_REACT | Python | Requires supported external XR glasses and the Breezy/XRLinuxDriver stack; it does not target Steam Frame displays. |
| CSS Loader | none detected | ButtonItem, ColorPickerModal, ConfirmModal, DialogButton, DialogCheckbox, Dropdown, DropdownItem, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SidebarNavigation, SliderField, Tabs, TextField, Toggle, ToggleField, classMap, definePlugin, findModuleChild, gamepadDialogClasses, gamepadSliderClasses, getGamepadNavigationTrees, replacePatch, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| vibrantDeck | none detected | PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, window.SP_REACT | Python | Changes X11 gamescope color properties, not the Frame SteamVR compositor. |
| Fantastic | none detected | Field, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT, QAM/global tabs | Python + native payload; x86_64 | Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. |
| MusicControl | none detected | ButtonItem, DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Pause Games | Apps, Apps.RegisterForGameActionTaskChange, GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, System.UI.RegisterForFocusChangeEvents, System.UI.RegisterForSystemKeyEvents | Button, Field, Focusable, Marquee, PanelSection, PanelSectionRow, Router, Toggle, ToggleField, beforePatch, sleep | Router, window.SP_REACT, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| ProtonDB Badges | none detected | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, Navigation, PanelSection, PanelSectionRow, ScrollPanel, ScrollPanelGroup, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleExport, showModal, staticClasses, useParams | SteamUIStore, appStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Audio Loader | GameSessions.RegisterForAppLifetimeNotifications, d.ts | ButtonItem, DialogButton, Dropdown, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, afterPatch, beforePatch, definePlugin, findModuleChild, gamepadDialogClasses, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Bluetooth | none detected | DialogButton, Field, Focusable, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | none detected | Python | /sys/class/power_supply |
| Notebook | System.UI.RegisterForFocusChangeEvents | DialogButton, Dropdown, Marquee, PanelSection, PanelSectionRow, Router, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| PowerTools | Apps.RegisterForGameActionEnd, Apps.RegisterForGameActionStart, GameSessions.RegisterForAppLifetimeNotifications, User.GetLoginUsers, User.RegisterForCurrentUserChanges | ButtonItem, DialogButton, Dropdown, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, QuickAccessTab, Router, SliderField, Spinner, TextField, ToggleField, definePlugin, showModal, staticClasses | Router, appStore, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam stores | Python + native payload; x86_64 | Targets AMD Ryzen power controls; Frame uses an ARM64 SoC. |
| Memory Deck | none detected | ButtonItem, DialogButton, DropdownItem, Focusable, PanelSection, PanelSectionRow, Router, SteamSpinner, definePlugin, gamepadDialogClasses, joinClassNames, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python + native payload; x86_64 | gamescope |
| Animation Changer | none detected | ButtonItem, DialogButton, Dropdown, DropdownItem, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, Spinner, Tabs, TextField, ToggleField, definePlugin, findModule, findSP, showModal, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| RadiYo! | none detected | ConfirmModal, DialogButton, Dropdown, Focusable, ModalRoot, Navigation, PanelSection, QuickAccessTab, Router, SliderField, Spinner, TextField, definePlugin, showModal, staticClasses | Router, window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| HLTB for Deck | none detected | ButtonItem, DialogButtonPrimary, DropdownItem, MenuItem, Navigation, PanelSection, PanelSectionRow, SliderField, SteamSpinner, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, definePlugin, fakeRenderComponent, findInReactTree, findInTree, findModuleChild, removeHookStubs, staticClasses | routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
| AutoSuspend | Downloads.RegisterForDownloadItems, Input.RegisterForControllerInputMessages, Settings.RegisterForSettingsChanges, System, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, System.ShutdownPC, URL.ExecuteSteamURL, User.GetLoginUsers, User.RegisterForShutdownDone | ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DropdownItem, Field, Focusable, Navigation, SidebarNavigation, SliderField, TextField, ToggleField, definePlugin, findModuleChild, showModal, sleep, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Emuchievements | Apps.RegisterForAppDetails, GameSessions.RegisterForAppLifetimeNotifications, User.RegisterForLoginStateChange | ButtonItem, Dropdown, Field, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarItem, ProgressBarWithInfo, Router, SidebarNavigation, TextField, ToggleField, afterPatch, beforePatch, callOriginal, definePlugin, findInTree, findModuleChild, replacePatch, sleep, staticClasses | Router, appDetailsStore, appStore, collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| TunnelDeck | none detected | ButtonItem, Field, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | systemctl |
| AutoFlatpaks | InstallFolder.GetInstallFolders | ConfirmModal, DialogButton, Dropdown, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, ScrollPanel, SteamSpinner, Tabs, TextField, Toggle, ToggleField, definePlugin, findModuleChild, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | flatpak; x86_64 |
| MoonDeck | Apps.AddShortcut, Apps.CancelGameAction, Apps.GetResolutionOverrideForApp, Apps.RegisterForAppDetails, Apps.RegisterForGameActionEnd, Apps.RegisterForGameActionStart, Apps.RegisterForGameActionTaskChange, Apps.RemoveShortcut, Apps.RunGame, Apps.SetAppLaunchOptions, Apps.SetAppResolutionOverride, Apps.SetShortcutName, Apps.SetThirdPartyControllerConfiguration, Apps.TerminateApp, Apps.ToggleOverrideResolutionForInternalDisplay, GameSessions.RegisterForAppLifetimeNotifications, System.Audio.GetDevices, System.DisplayManager.RegisterForStateChanges, User.RegisterForLoginStateChange, User.StartRestart | Button, ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogButtonPrimary, DialogButtonSecondary, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, Menu, MenuItem, ModalRoot, NavEntryPositionPreferences, Navigation, PanelSection, PanelSectionRow, SidebarNavigation, Spinner, TextField, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, basicAppDetailsSectionStylerClasses, createReactTreePatcher, findInReactTree, findModuleExport, findSP, joinClassNames, playSectionClasses, showContextMenu, showModal, sleep, staticClasses | appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | /dev/input; /sys/socket.h; amd64; flatpak; x86_64 |
| SteamGridDB | Apps.ClearCustomArtworkForApp, Apps.RegisterForAppDetails, Apps.SetCustomArtworkForApp, Apps.SetShortcutName, User.StartRestart | ConfirmModal, ControlsList, DialogBody, DialogBodyText, DialogButton, DialogButtonPrimary, DialogCheckbox, DialogControlsSection, DialogControlsSectionHeader, DialogFooter, DialogHeader, Dropdown, Field, Focusable, IconsModule, Marquee, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBar, ReorderableList, SliderField, Spinner, Tabs, TextField, ToggleField, afterPatch, appDetailsHeaderClasses, callOriginal, createModuleMapping, definePlugin, fakeRenderComponent, findClassModule, findInReactTree, findInTree, findModule, findModuleByExport, findModuleExport, findSP, joinClassNames, quickAccessMenuClasses, replacePatch, showContextMenu, showModal, useParams, wrapReactType | appDetailsStore, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Discord Status | Apps.RegisterForAppDetails, Apps.RunGame, GameSessions.RegisterForAppLifetimeNotifications, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | ButtonItem, DropdownItem, Field, PanelSection, PanelSectionRow, Router, Spinner, sleep, staticClasses | Router, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Volume Mixer | none detected | DialogButton, Focusable, PanelSection, PanelSectionRow, SliderField, definePlugin, staticClasses | window.SP_REACT | Python | PulseAudio/PipeWire, libpulse/libdbus and D-Bus; optional Bluetooth playback |
| Controller Tools | none detected | ButtonItem, PanelSection, PanelSectionRow, ToggleField, definePlugin, gamepadDialogClasses, joinClassNames, staticClasses | toaster, window.SP_REACT, toasts | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| Tailscale Control | none detected | ButtonItem, ConfirmModal, DropdownItem, Field, Focusable, PanelSection, PanelSectionRow, SuspensefulImage, TextField, ToggleField, definePlugin, showModal, staticClasses | window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| DeckMTP | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| Steamback | GameSessions.RegisterForAppLifetimeNotifications, InstallFolder.GetInstallFolders | ButtonItem, ConfirmModal, Navigation, PanelSection, PanelSectionRow, Router, definePlugin, showModal, staticClasses | Router, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Quick Launch | Apps.AddShortcut, Apps.RunGame, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Apps.SpecifyCompatTool | ButtonItem, ConfirmModal, DialogButton, Dropdown, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, ToggleField, definePlugin, showModal, staticClasses | appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | flatpak |
| Decky Cloud Save | GameSessions.RegisterForAppLifetimeNotifications, Settings, Settings.GetCurrentLanguage | ButtonItem, ConfirmModal, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField, definePlugin, showContextMenu, showModal, sleep, staticClasses | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| Storage Cleaner | Apps.RegisterForAppDetails | ButtonItem, ConfirmModal, DialogCheckbox, PanelSection, PanelSectionRow, definePlugin, showModal, staticClasses | window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Game Theme Music | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SliderField, SteamSpinner, Tabs, TextField, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, fakeRenderComponent, findInReactTree, findInTree, findModuleChild, showContextMenu, showModal, staticClasses, useParams | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Shotty | GameSessions.RegisterForScreenshotNotification | ButtonItem, PanelSection, PanelSectionRow, Router, definePlugin, staticClasses | Router, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | gamescope |
| MangoPeel | none detected | ButtonItem, ConfirmModal, DropdownItem, Field, Focusable, Marquee, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, definePlugin, findModuleChild, showModal, sleep, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Volume Boost | none detected | PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | PulseAudio/PipeWire and pactl; hard-coded /run/user/1000 |
| Web Browser | GameSessions.RegisterForAppLifetimeNotifications, Input.ControllerKeyboardSetKeyState, Overlay.RegisterForOverlayActivated, System, System.RegisterForOnResumeFromSuspend | ButtonItem, ConfirmModal, DialogBodyText, DialogButton, DialogButtonSecondary, DialogSubHeader, DropdownItem, Field, Focusable, GamepadButton, Marquee, Menu, MenuGroup, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SimpleModal, SteamSpinner, Tabs, TextField, ToggleField, afterPatch, definePlugin, findClassModule, findInReactTree, findModuleChild, gamepadContextMenuClasses, gamepadDialogClasses, gamepadTabbedPageClasses, getFocusNavController, getReactRoot, quickAccessControlsClasses, replacePatch, showContextMenu, showModal, sleep, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| DeckyFileServer | none detected | ButtonItem, DialogBody, DialogButton, DialogFooter, DialogHeader, DialogSubHeader, DropdownItem, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, Toggle, ToggleField, definePlugin, showModal, staticClasses | window.SP_REACT | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| TabMaster | Apps.GetStoreTagLocalization, GameSessions.RegisterForAppLifetimeNotifications, InstallFolder.RegisterForInstallFolderChanges, System, System.RegisterForOnResumeFromSuspend, System.RestartPC, User.RegisterForLoginStateChange | Button, ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogCheckbox, DialogControlsSection, DialogHeader, Dropdown, DropdownItem, Field, Focusable, GamepadButton, Marquee, Menu, MenuGroup, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, ReorderableList, ScrollPanelGroup, SidebarNavigation, SliderField, TextField, ToggleField, achievementClasses, afterPatch, findInReactTree, findInTree, findModuleExport, gamepadContextMenuClasses, gamepadDialogClasses, gamepadSliderClasses, gamepadTabbedPageClasses, joinClassNames, mainMenuAppRunningClasses, quickAccessControlsClasses, quickAccessMenuClasses, replacePatch, scrollPanelClasses, showContextMenu, showModal, sleep, wrapReactType | DeckyPluginLoader, appStore, collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| TS3 QuickAccess | none detected | DialogBody, DialogButton, DialogFooter, DialogHeader, Dropdown, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, SliderField, TextField, definePlugin, showModal | none detected | Python + native payload; x86_64 | flatpak |
| Free Loader | none detected | ButtonItem, ConfirmModal, DialogButton, Focusable, Navigation, PanelSection, PanelSectionRow, QuickAccessTab, SliderField, SteamSpinner, ToggleField, showModal | DeckyPluginLoader, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Syncthing | none detected | DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBarItem, QuickAccessTab, Router, SidebarNavigation, Spinner, SteamSpinner, TextField, Toggle, definePlugin, showModal, sleep, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python + native payload; x86_64 | flatpak; gamescope |
| Decky Terminal | none detected | DialogButton, Dropdown, Field, Focusable, GamepadButton, PanelSection, Router, SidebarNavigation, SteamSpinner, TextField, ToggleField, definePlugin, staticClasses, useParams | Router, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| PlayTime | Apps.RegisterForAppDetails, Input.RegisterForControllerInputMessages, Storage.GetJSON, Storage.SetObject, System, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, User, User.RegisterForLoginStateChange, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | ButtonItem, ConfirmModal, DialogButton, Dropdown, Field, Focusable, Menu, MenuGroup, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SidebarNavigation, Tabs, TextField, afterPatch, definePlugin, findClass, findSP, showContextMenu, showModal, staticClasses, useParams | SteamUIStore, appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Battery Tracker | none detected | PanelSection, PanelSectionRow, Router, SliderField, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | /sys/class/power_supply/BAT1/capacity; /sys/class/power_supply/BAT1/current_now; /sys/class/power_supply/BAT1/status; /sys/class/power_supply/BAT1/voltage_now |
| Wine Cellar | Apps.GetAvailableCompatTools, Apps.SpecifyCompatTool, InstallFolder.GetInstallFolders, Settings.GetGlobalCompatTools, User.StartRestart | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Dropdown, Field, Focusable, GamepadButton, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBar, Router, ScrollPanelGroup, SidebarNavigation, TextField, definePlugin, showContextMenu, showModal, staticClasses | Router, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| MicroSDeck | none detected | ConfirmModal, DialogButton, DialogCheckbox, Field, Focusable, Menu, MenuItem, ModalPosition, Navigation, PanelSection, ReorderableList, ScrollPanelGroup, SideMenu, SidebarNavigation, SimpleModal, TextField, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, findInReactTree, findModule, findModuleChild, gamepadDialogClasses, quickAccessControlsClasses, showContextMenu, showModal, staticClasses | collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| ScreenshotUploader | GameSessions.RegisterForScreenshotNotification, Screenshots.UploadLocalScreenshot | DropdownItem, Field, PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| EmuDecky | none detected | ButtonItem, DialogButton, Dropdown, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SteamSpinner, ToggleField, definePlugin, showContextMenu, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| KDE Connect | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Reshadeck | Input.RegisterForControllerStateChanges, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest | ButtonItem, Dropdown, PanelSection, PanelSectionRow, definePlugin, staticClasses | toaster, window.SP_REACT, toasts | Python | gamescope |
| CheatDeck | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions | DialogBodyText, DialogButton, DialogHeader, Dropdown, Field, Focusable, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, SidebarNavigation, TextField, ToggleField, afterPatch, definePlugin, fakeRenderComponent, findInReactTree, findInTree, findModuleByExport, showModal, staticClasses, useParams | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| BT Wake Control | none detected | ButtonItem, IconsModule, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | toaster, window.SP_REACT, toasts | Python | /sys/devices/virtual/dmi/id/board_name; /sys/devices/virtual/dmi/id/board_vendor; bluetoothctl |
| Brightness Bar | System.Display.RegisterForBrightnessChanges | ButtonItem, ColorPickerModal, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup | Python | Targets Steam/QAM brightness shortcuts and a global Steam overlay; neither is attached to the Framey panel. |
| Decky Notifications | none detected | ButtonItem, Menu, MenuItem, PanelSection, PanelSectionRow, definePlugin, showContextMenu, staticClasses | DeckyPluginLoader, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| MagicBlack | System.Audio.GetDevices, System.Audio.SetDeviceVolume | DialogButton, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, findModuleChild, findModuleExport, gamepadDialogClasses, joinClassNames, quickAccessMenuClasses, showModal, staticClasses | SteamUIStore, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | Targets a global Steam Deck screen-black overlay; the isolated panel cannot black out the Frame compositor. |
| IsThereAnyDeal for Deck | User.GetIPCountry | DialogButton, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| MagicPods | Input.RegisterForControllerStateChanges, System.Audio.GetDevices, System.Audio.RegisterForDeviceVolumeChanged, System.Audio.SetDeviceVolume, System.Bluetooth, System.Bluetooth.RegisterForStateChanges, System.Bluetooth.SetEnabled | ButtonItem, DialogButton, Field, Focusable, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, ToggleField, afterPatch, definePlugin, findClassModule, findModuleChild, findModuleDetailsByExport, findSP, gamepadDialogClasses, gamepadSliderClasses, getGamepadNavigationTrees, joinClassNames, quickAccessMenuClasses, showContextMenu, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| Junk-Store | Apps.AddShortcut, Apps.GetAvailableCompatTools, Apps.RemoveShortcut, Apps.RunGame, Apps.SetAppLaunchOptions, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Apps.SetShortcutStartDir, Apps.SpecifyCompatTool, GameSessions.RegisterForAppLifetimeNotifications, Input.RegisterForControllerInputMessages, Installs.OpenInstallWizard | Button, ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogLabel, Dropdown, Field, Focusable, GamepadButton, Marquee, Menu, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBar, ScrollPanelGroup, SidebarNavigation, SliderField, Spinner, SteamSpinner, Tabs, TextField, ToggleField, afterPatch, definePlugin, findClassModule, gamepadDialogClasses, gamepadTabbedPageClasses, joinClassNames, quickAccessControlsClasses, quickAccessMenuClasses, scrollPanelClasses, showContextMenu, showModal, sleep, staticClasses, useParams | Router, SteamUIStore, appDetailsStore, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | sudo |
| Decky-Undervolt | GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend | ButtonItem, DialogButton, DropdownItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, TextField, ToggleField, definePlugin | Router, appStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python + native payload; x86_64 | RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. |
| ScreenSaver | Input.RegisterForControllerInputMessages, Input.RegisterForControllerStateChanges, Settings.SetSetting, System.RegisterForOnSuspendRequest, System.UpdateSettings, User.RegisterForPrepareForSystemSuspendProgress | PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| steamdeck-input-disabler | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | Targets the built-in Steam Deck USB gamepad; Frame has different controllers. |
| DeckySpy | System.RegisterForOnResumeFromSuspend | ButtonItem, Field, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Picture in Picture | none detected | ButtonItem, ConfirmModal, DropdownItem, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, findModuleChild, getGamepadNavigationTrees, quickAccessMenuClasses, showModal | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
| PlayCount | none detected | Button, ButtonItem, DialogBody, DialogButton, DialogControlsSection, DialogHeader, DropdownItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, Tabs, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
| Crosshair | none detected | ButtonItem, PanelSection, PanelSectionRow, staticClasses | window.SP_REACT, QAM/global tabs | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Deck Settings | InstallFolder.GetInstallFolders, System.GetSystemInfo | ButtonItem, ConfirmModal, DialogBody, DialogButton, Dropdown, Field, Focusable, GamepadButton, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, ScrollPanelGroup, SliderField, Spinner, TextField, ToggleField, gamepadDialogClasses, scrollPanelClasses, showModal, staticClasses | Router, collectionStore, toaster, window.SP_REACT, toasts, QAM/global tabs, Steam stores | Python | /sys/class/dmi/id/sys_vendor; /sys/class/dmi/id/sys_vendor, |
| Decky DoT DNS | none detected | ButtonItem, Dropdown, PanelSection, PanelSectionRow, TextField, ToggleField, staticClasses | window.SP_REACT | Python | systemctl |
| Decky-SpoofDPI | User.StartRestart | ButtonItem, PanelSection, PanelSectionRow, TextField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| Simple Timer | none detected | Button, ButtonItem, ConfirmModal, Focusable, PanelSection, PanelSectionRow, ToggleField, findModuleChild, showModal, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Decky-Framegen | none detected | ButtonItem, ConfirmModal, PanelSection, PanelSectionRow, showModal | toaster, window.SP_REACT, toasts | Python + native payload; Windows | No specific hardware requirement detected statically; runtime requirements unverified. |
| Decky-Lookup | none detected | ButtonItem, PanelSection, PanelSectionRow, Router, staticClasses | Router, window.SP_REACT | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
| Decky Zerotier | none detected | ConfirmModal, DialogBody, DialogButton, DialogSubHeader, Field, Focusable, PanelSection, PanelSectionRow, TextField, ToggleField, showModal | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Virtual Surround Sound | none detected | ConfirmModal, DialogButton, Dropdown, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, showModal, staticClasses | Router, window.SP_REACT, QAM/global tabs | Python | pactl |
| XIVOmega | none detected | ButtonItem, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, Spinner, TextField, ToggleField, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | /sys/net/ethertypes.h; /sys/net/if_pflog.h; /sys/net/pfvar.h; /sys/socket.h.auto.html; /sys/sys/socket.h; sudo; x86_64 |
| DeckSP | Input.ControllerKeyboardSetKeyState, System, System.Audio.GetDevices, System.Audio.RegisterForDeviceAdded, UI.RegisterForUIModeChanged, User.RegisterForShutdownStart | ButtonItem, ConfirmModal, DialogButton, Dropdown, DropdownItem, EUIMode, Field, Focusable, GamepadButton, Marquee, Menu, MenuGroup, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SimpleModal, SliderField, TextField, ToggleField, findModuleExport, gamepadContextMenuClasses, gamepadDialogClasses, quickAccessControlsClasses, quickAccessMenuClasses, scrollPanelClasses, showContextMenu, showModal, sleep | SteamUIStore, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | flatpak; systemctl |
| Decky Ludusavi | Apps.GetLaunchOptionsForApp, GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, DialogButton, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField, showModal | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python | flatpak |
| Game Sync | GameSessions.RegisterForAppLifetimeNotifications, GameSessions.RegisterForScreenshotNotification, Screenshots.DeleteLocalScreenshot | ButtonItem, ConfirmModal, DialogButton, Field, Focusable, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, ReorderableList, SidebarNavigation, TextField, ToggleField, afterPatch, fakeRenderComponent, findInReactTree, findInTree, findModuleByExport, showContextMenu, showModal, sleep, useParams | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | amd64 |
| WiFi Locker | none detected | ButtonItem, Field, PanelSection, PanelSectionRow, staticClasses | toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| LetMeReShade | Apps.SetAppLaunchOptions | ButtonItem, ConfirmModal, DropdownItem, PanelSection, PanelSectionRow, ToggleField, showModal | window.SP_REACT | Python | /sys/devices/virtual/dmi/id/board_name; /sys/devices/virtual/dmi/id/product_name; /sys/devices/virtual/dmi/id/sys_vendor |
| Decky Sunshine | none detected | ButtonItem, DialogButton, DialogButtonPrimary, Field, ModalRoot, PanelSection, PanelSectionRow, Spinner, TextField, definePlugin, showModal | window.SP_REACT | Python | flatpak |
| HueSync | Settings.GetCurrentLanguage, System.GetSystemInfo, System.RegisterForBatteryStateChanges, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | DialogButton, DropdownItem, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, definePlugin, findModuleChild, gamepadSliderClasses, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. |
| MuraDeck | System.Display.RegisterForBrightnessChanges | ButtonItem, ConfirmModal, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SideMenu, SidebarNavigation, SliderField, ToggleField, quickAccessControlsClasses, showModal | SteamUIStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python | Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. |
| Dimmer Deck | Settings.GetCurrentLanguage, System.Display.RegisterForBrightnessChanges, System.Display.SetBrightness | PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts, Steam stores | Python | Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. |
| Music Control | none detected | ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Better Keyboard | Settings.GetCurrentLanguage, UI.GetUIMode, UI.RegisterForUIModeChanged | ButtonItem, EUIMode, PanelSection, PanelSectionRow, ToggleField, staticClasses | SteamUIStore, window.SP_REACT, Steam stores | Python | /dev/input |
| Decky LSFG-VK | none detected | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, Spinner, TextField, Toggle, ToggleField, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | flatpak; x86_64 |
| Network Sentinel | none detected | ButtonItem, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Decky Clash | Settings.GetCurrentLanguage | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Dropdown, DropdownItem, Field, Focusable, GamepadButton, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, Spinner, TextField, ToggleField, showContextMenu, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Muon | System, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest | ButtonItem, DialogButton, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, Toggle, findModuleExport, showModal, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | sudo; systemctl |
| DeckyParty File Manager | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | sudo |
| Speed Test | none detected | ButtonItem, PanelSection, PanelSectionRow, staticClasses | none detected | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
| Decky Clipboard | none detected | ButtonItem, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Field, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, TextField, ToggleField, staticClasses | Router, routerHook, toaster, local/Steam routes, toasts | Python + native payload; x86_64 | gamescope; sudo |
| Reroll | Apps.RegisterForGameActionUserRequest, GameSessions.RegisterForAppLifetimeNotifications, Settings.GetCurrentLanguage | ButtonItem, DialogButton, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, SliderField, libraryAssetImageClasses, mainMenuAppRunningClasses, quickAccessMenuClasses, showModal | SteamUIStore, QAM/global tabs, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| DeckPass | Apps.AddShortcut, Apps.RunGame, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Input.ControllerKeyboardSendText | Button, ButtonItem, DialogButton, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, TextField, findSP, showModal, sleep, staticClasses | Router, appDetailsStore, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | flatpak |
| Steam Status | none detected | ButtonItem, ConfirmModal, DialogButton, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, showModal, staticClasses | toaster, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| AlarMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SidebarNavigation, SliderField, TextField, ToggleField, findModuleChild, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Decky Localsend | none detected | ButtonItem, DialogBody, DialogButton, DialogButtonPrimary, DialogFooter, DialogHeader, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, TextField, ToggleField, showModal, staticClasses | Router, routerHook, toaster, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| Launch Options | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogButtonPrimary, DialogControlsSection, DialogControlsSectionHeader, DialogFooter, DialogHeader, Dropdown, Field, Focusable, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanel, SidebarNavigation, SteamSpinner, Tabs, TextField, Toggle, ToggleField, afterPatch, createReactTreePatcher, fakeRenderComponent, findInReactTree, findInTree, findModule, findModuleByExport, showModal, staticClasses, useParams | SteamUIStore, routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | flatpak; gamescope |
| Decky Translator | User.RegisterForPrepareForSystemSuspendProgress | ButtonItem, DialogButton, Dropdown, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, findModuleChild, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | /dev/hidraw; /sys/class/hidraw/hidraw{i}; /sys/class/hidraw/hidraw{i}/device/uevent; x86_64 |
| Quick User Switcher | none detected | ButtonItem, ConfirmModal, Focusable, PanelSection, PanelSectionRow, ToggleField, afterPatch, showModal, staticClasses, wrapReactType | Router, routerHook, local/Steam routes, Steam page patches/module lookup | Python | sudo |
| ShadowDeck | none detected | ButtonItem, DialogButton, DropdownItem, Focusable, PanelSection, PanelSectionRow, TextField, Toggle, showModal, staticClasses | toaster, toasts | Python | amd64 |
| Games Prefix Manager | none detected | ButtonItem, DialogButton, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, definePlugin, showModal, staticClasses | none detected | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Download All | Downloads, Downloads.RegisterForDownloadItems | ButtonItem, PanelSection, PanelSectionRow, SliderField | appStore, toaster, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| SuggestMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBar, SidebarNavigation, SliderField, Spinner, TextField, ToggleField, showModal, staticClasses | appStore, collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Non-Steam Badges | none detected | DialogButton, DropdownItem, Navigation, PanelSection, PanelSectionRow, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, findInReactTree, getGamepadNavigationTrees | collectionStore, routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Deck Progress Tracker | Apps.GetAllApps, Apps.GetMyAchievementsForApp | ButtonItem, DialogButton, Focusable, Navigation, PanelSection, PanelSectionRow, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, findInReactTree, staticClasses | appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Deck Shelves | Apps.RegisterForAppDetails, System.GetOSVersion, System.GetSystemInfo | showContextMenu | SteamUIStore, appDetailsStore, appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Decky-Clipper | none detected | ButtonItem, ModalRoot, Navigation, PanelSection, Router, ToggleField, showModal, staticClasses | Router | Python + native payload; x86_64 | gamescope |
| Decky Proton Launch | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions, User.StartRestart | ConfirmModal, DialogButton, DropdownItem, Focusable, GamepadButton, ModalRoot, PanelSection, PanelSectionRow, TextField, ToggleField, showModal, staticClasses | toaster, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Docky | none detected | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, ToggleField, definePlugin, showModal | toaster, window.SP_REACT, toasts | Python | /sys/bus/usb/devices/; /sys/bus/usb/devices/*; /sys/bus/usb/drivers/usb/; /sys/bus/usb/drivers/usb/%s; /sys/class/drm/*/status; /sys/class/drm/card*-*/; /sys/class/hwmon/hwmon*; /sys/class/hwmon/hwmonN; /sys/class/power_supply/*/online; /sys/class/thermal; /sys/class/thermal/thermal_zone*/temp; amdgpu; flatpak; gamescope; hwmon; pactl; sudo; systemctl |
| Output Manager | none detected | ButtonItem, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField, staticClasses | none detected | Python | /sys/class/drm; /sys/class/drm/card{card_number}/device; /sys/kernel/debug/dri; pactl |
| IPv6 Enabler Plugin | none detected | DropdownItem, Field, PanelSection, PanelSectionRow, staticClasses | toaster, toasts | Python | /sys/net/ipv6/conf |
| DeckyClocker | none detected | ButtonItem, Field, PanelSection, PanelSectionRow, SliderField | toaster, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| MuteOnSleep | none detected | PanelSection, PanelSectionRow, ToggleField | Steam stores | Python | pactl; sudo |
| Desktop Apps | Apps.AddShortcut, Apps.RemoveShortcut, Apps.RunGame, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Apps.SetShortcutStartDir, GameSessions.RegisterForAppLifetimeNotifications | DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, showContextMenu, staticClasses | appStore, toaster, toasts, Steam stores | Python | flatpak |
| Mixer | none detected | ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, SliderField, TextField, showModal, staticClasses | toaster, toasts | Python | PipeWire, pw-dump and wpctl |
| VARTA | Store.SetWishlist, StoreItems.SetWishlist, User, User.GetSteamID | ButtonItem, DropdownItem, Field, PanelSection, PanelSectionRow, Spinner, ToggleField, afterPatch, appDetailsClasses, beforePatch, createReactTreePatcher, findInReactTree, findModule, findModuleByExport, findModuleExport, staticClasses, useParams | routerHook, toaster, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
## Native payload portability
All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. The three Windows DLLs belong to Decky-Framegen. The installer still rejects these archives. Apart from the explicitly described LocalSend experiment, ARM64 builds were not attempted: the constraints below remain unresolved.
[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) covers a reproducible MIT helper build and its isolated real-Frame protocol 2.0 info response. The official x86 archive remains blocked; full plugin integration and file transfers are unverified.
| Plugin | Native payloads | ARM64 result / remaining blocker |
| --- | --- | --- |
| Fantastic | bin/backend | Fan backend/driver is Deck-specific; an ARM64 rebuild would not provide compatible Frame hardware. |
| PowerTools | bin/libryzenadj.so, bin/ryzenadj, bin/backend | RyzenAdj and AMD power backend cannot control an ARM64 Frame SoC. |
| Memory Deck | bin/libscanmem.so.1.0.0 | libscanmem must be rebuilt for aarch64 and validated against target process architecture; memory writes were not tested. |
| Emuchievements | bin/hash | The bin/hash helper needs an ARM64 build and cross-platform hashing tests; no ARM64 artifact is published. |
| MoonDeck | python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_parser.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/reader_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/mask.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_writer.cpython-313-x86_64-linux-gnu.so, … 26 total; full list in JSON | Rebuild vendored CPython 3.13 extension wheels for the headset Python ABI and ARM64; buddy/helper dependencies also need ports. |
| Controller Tools | bin/backend | Native backend needs an ARM64 build and controller/HID dependencies; its direct websocket also bypasses the Framey bridge. |
| DeckMTP | bin/umtprd | umtprd needs ARM64 libusb/MTP dependencies and USB gadget permissions; root user-service blocker remains. |
| Decky Cloud Save | bin/rclone | rclone needs an ARM64 artifact; this ZIP also fails safe member validation. Cloud writes were not exercised. |
| DeckyFileServer | bin/backend | Native file-server backend needs ARM64 dependencies; root requirement also blocks the user service. |
| TS3 QuickAccess | bin/ts3-qs4sd.so | TeamSpeak client plugin must match an ARM64 TeamSpeak client and SDK ABI, not merely rebuild the .so. |
| Syncthing | bin/decky-syncthing-watchdog | The watchdog needs an ARM64 build, plus a supported Syncthing runtime/package and safe path validation. |
| Wine Cellar | bin/backend | Native backend needs an ARM64 build; direct websocket and Wine runtime assumptions also need integration. |
| MicroSDeck | bin/backend | Native backend needs an ARM64 build and block-device/mount permission audit; no storage actions were run. |
| EmuDecky | bin/hello | Published bin/hello is x86_64 even if unused; upstream must remove it or publish an ARM64 archive, plus remove root dependency. |
| MagicPods | bin/magicpodscore | magicpodscore requires an upstream ARM64 artifact/source and protocol tests; frontend uses a direct websocket. |
| Decky-Undervolt | bin/libryzenadj.so, bin/ryzenadj | RyzenAdj is AMD-specific and cannot be ported to control the Frame SoC. |
| Decky-SpoofDPI | bin/spoofdpi | SpoofDPI is a separate upstream helper; needs an ARM64 artifact/protocol and network-permission validation. |
| Decky-Framegen | bin/nvngx.dll, bin/dlssg_to_fsr3_amd_is_better.dll, bin/nvapi64.dll | Windows DLL payload targets x86 game translation rather than a native ARM backend; archive is 75,140,408 bytes, beyond the 50 MiB installer limit. |
| HueSync | bin/hello, py_modules/portio.so | portio and handheld LED backends depend on x86 I/O/unsupported hardware; ARM64 compilation alone is insufficient. |
| Decky Clipboard | bin/xclip | xclip needs ARM64 X11 libraries/server; root and gamescope assumptions also block this archive. |
| Reroll | bin/memscan.so | memscan.so needs ARM64 process-memory support and ABI testing; root requirement remains. Frontend initializes but no memory access was tested. |
| Decky Localsend | bin/localsend-core | MIT LocalSend-Go 66edc509a28b9f877e7374504892dcbc653a4da4 reproducibly cross-built with Go 1.25.7/CGO_ENABLED=0 for ARM64; SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Help/official backend CLI flags and protocol 2.0 info endpoint passed on real Frame in an isolated network namespace. No file transfer or full plugin/CEF integration tested; exact source match to shipped helper unproven. Official ZIP stays x86_64 and blocked. |
| Launch Options | bin/hello | Remove the template x86 bin/hello or publish an ARM64 rebuild; missing Steam module mapping also prevents frontend initialization. |
| Decky-Clipper | bin/gstreamer-1.0/libgstproxy.so, bin/gstreamer-1.0/libgstmidi.so, bin/gstreamer-1.0/libgstmpegtsmux.so, bin/gstreamer-1.0/gst-plugin-scanner, bin/gstreamer-1.0/libgstivfparse.so, … 115 total; full list in JSON | Rebuild all bundled GStreamer libraries/plugins for ARM64 and verify capture/codec ABI against Frame SteamVR; gamescope capture differs. |
## Evidence and maintenance
The canonical JSON contains full archive hashes, native paths, source URLs, frontend observations and fake-Frame outcomes. Store compatibility is advisory except known installation/hardware blockers; removal always remains available. A panel retains its compatibility reason after the initializer reports ready. Upstream fixes require a new digest-scoped audit. Do not mark a plugin works based on archive installation, fake backend replies or initializer completion.
-255
View File
@@ -1,255 +0,0 @@
# 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.
See the [complete official-store compatibility table](framey-compatibility.md) for version-specific statuses, blockers and verification limits. Both stores and installed panels show the same digest-scoped results; a new published archive starts unverified.
## 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
API v2 bundles and API v1 bundles using Decky's modern frontend initializer,
with `dist/index.js`. Legacy `(function (...) {...})(DFL, SP_REACT)` bundles are
also accepted: their returned initializer receives the legacy `serverAPI`, and
keyword backend calls use Decky's legacy RPC. The wrapper runs as a blob ES
module inside the same sandbox; it does not require `eval`. Other installed plugins remain manageable but have no
Framey panel. API v1 admission is a compatibility attempt, not a guarantee
that the plugin's controls work in VR. 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`.
Framey gives each plugin a bounded `localStorage` substitute because browser
storage is denied to an opaque-origin iframe. The parent panel keeps a separate
cache for each plugin: up to 64 keys, 64 KiB per value and 128 KiB total. It
restores the cache when a panel reopens if the parent's browser storage is
available. If storage is denied or cleared, the cache lasts only for the current
panel. Use the plugin backend for settings that must survive cache loss.
Decky API v1/v2 `addEventListener` and `removeEventListener` receive real backend
events while the panel is open. The parent resolves the plugin name from its
installed store receipt, polls Steam's existing Decky event connection, and sends
only subscribed events to that panel session. A subscription is established
before a backend call can emit its response. The bridge allows 32 event names,
queues at most 64 events and 48 KiB, and drops any event whose arguments exceed
8 KiB. Polling occurs about once a second, so this is unsuitable for high-rate
input/audio streams. Closing/reopening a panel removes its subscriptions;
abandoned CEF subscriptions expire after 15 seconds. This does not deliver
global notifications while a Framey panel is closed.
The [26 October live checks](evidence/framey-events-backend-2026-10-26.json)
used the actual official Notebook and Simple Timer Python backends in a private
removable ARM64 Decky service. Notebook's default notebook saved a pointer stroke
and loaded it after a backend restart. Simple Timer's start/cancel controls,
countdown, completion toast and persisted recents/subtle mode worked. Simple
Timer remains partial: its own expired-timer startup raises a missing
`timer_task` error, sound/suspend are unsupported, and worn VR was not checked.
DFL module and class lookup searches a finite registry of Framey's own controls,
CSS maps and an explicitly unsupported optional UI-audio player. It follows DFL's
module/export filter and mapping contracts; unknown Steam modules return
`undefined`, and unknown module mappings are empty. Returned IDs describe local
Framey exports, not Steam webpack IDs. Sound playback throws an unsupported
error, and Steam audio stores, function-source signatures and global patches are
not fabricated. The [module lookup checks](evidence/framey-module-lookup-2026-10-26.md)
record the resulting TabMaster documentation support and remaining blockers.
The host currently supplies sections, rows, buttons, toggles, text fields,
sliders, grouped 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 pinned official Steam Status 1.2.3 frontend also
[rendered in local Chromium](evidence/framey-steam-status-browser-2026-10-25.md)
with a stubbed settings reply. Its public status request worked, but its cache
logged `localStorage` errors in Framey's v0.4.34 opaque iframe. A later
[local browser check](evidence/framey-session-cache-2026-10-25.md) confirmed
that a panel-lifetime substitute avoids those errors while preserving the
opaque origin. A [second browser check](evidence/framey-cache-browser-2026-10-25.md)
tested the per-plugin cache after reopening a panel. These checks used a stub
backend and no worn headset.
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4,
Steam Status 1.2.3, and Storage Cleaner 1.4.0 archives, checks their API v1/v2
manifests and frontend bytes, and verifies they can be packed as Framey panels.
It does not execute their actual backend code or prove their frontend controls
work in VR. Storage Cleaner rendered an
[empty-data panel in local Chromium](evidence/framey-api-v1-browser-2026-10-25.md).
Its cleanup actions were not tested; they may remove local game saves.
Framey also supplies local tabs/sidebar pages, spinners, dialog/menu wrappers
and progress bars. Its explicit SteamClient subscription fallbacks return
disposable handles without emitting fabricated events. There is no attached
running game in `DFL.Router`, and power, app launch and hardware mutation methods
are not supplied. Plugins that need live Steam stores still require integration
work. IndexedDB is hidden because the browser exposes it but denies access in
an opaque iframe; localForage can then select Framey's bounded localStorage.
Direct IndexedDB users remain unsupported.
`SuspensefulImage` renders a native image. SteamClient `Storage.GetJSON` and
`SetObject` use the same bounded per-plugin cache, including panel reopen
persistence; they do not read Steam's account/global JSON store. Missing keys
reject with `Not found`. Detached `SteamUIStore.RunningApps` is a frozen empty
list, and the suspend store has no methods. Optional Steam app/account globals
are undefined. These fallbacks let settings render without inventing live
games or account identity. [The exact-bundle follow-up](evidence/framey-ui-fallbacks-2026-10-26.json)
verifies Pause Games and Tailscale Control settings in local Chromium; their
primary backend actions remain unverified. PlayTime still requires Steam's
real app store and game observers.
Three parameterless SteamClient reads use Steam's actual CEF context:
`User.GetIPCountry`, `InstallFolder.GetInstallFolders` and `System.GetSystemInfo`.
The authenticated headset-only endpoint checks that the plugin is installed,
selects an expression from a fixed allowlist, and caps replies at 256 KiB.
The iframe cannot supply a Steam property path or executable source, and replies
are discarded if the panel session changed. Unsupported APIs remain unavailable.
[Locked Frame checks](evidence/framey-steam-reads-2026-10-26.json) verified all
three reads and IsThereAnyDeal's country dropdown in local Chromium. Its backend
settings/deal calls stayed denied; Deck Settings still needs real Steam library
context. A deployed headset HTTP endpoint and worn-VR behavior remain unverified.
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. Both `root` and `_root`
store tags mark a plugin as needing root, and duplicate store IDs are omitted
because an ID alone could select a different archive than the visible row.
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. After a polling error, Install, Update, and Remove
stay disabled while the saved job's result is unknown; use **Check result**
before starting another change.
Refresh the installed list before starting another
operation after an error. The headset panel and desktop app send the hash of
the version you saw; if the official store changes that version before the job
starts, Frame Control stops before downloading or changing the headset. Refresh
the store and review the new version before retrying. 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. A plugin with only `settings` opens that view directly;
Framey shows tabs only when both views exist. 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. It ignores extra taps while an action is pending,
and a failed action can be retried. Invalid action descriptors do not count
toward the eight-action limit. 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 [Decky API compatibility table](frame-plugin-author.md#framey-decky-api-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 --worn-confirmed` 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.
## Unattended VR checks
The [unattended check](framey-vr-check.md) can wake, inspect and capture the
real compositor, send OpenVR mouse input, and exercise a temporary sample
against live Decky with restoration and shared-lock protection. Its
[device evidence](evidence/framey-vr-2026-10-25/README.md) distinguishes these
checks from physical controller and wearer validation. A trusted pointer event
and active-headset telemetry are both reproducible without wearing the Frame;
they must not be labelled as physical controller proof.
-120
View File
@@ -1,120 +0,0 @@
# Checking Framey without wearing the Frame
Use the existing paired SSH alias, directly or through lxso2. Start with the
read-only check, then a temporary wake. A full input exercise is opt in:
```sh
python3 scripts/framey-vr-check.py --via lxso2 --output /tmp/framey-inspect
python3 scripts/framey-vr-check.py --via lxso2 --wake --output /tmp/framey-wake
python3 scripts/framey-vr-check.py --via lxso2 --exercise --output /tmp/framey-exercise
```
Omit `--via` when the computer can already `ssh frame`; use `--host` for another
paired alias. No SSH trust or credentials are changed. The output folder is
private by default. Stereo screenshots and the OBS mirror can contain room
pixels. Review/redact them before committing or sharing. JSON reports have
screenshot hashes and no UI access keys.
The runner acquires `/tmp/frame-test.lock` by atomic `mkdir`, writes a random
owner, then reads battery, proximity and SteamVR state. A foreign lock prevents
sensor reads, wake, installation and input. Unknown battery or below 20% aborts.
`--wake` and `--exercise` also require SteamVR activity 3 (standby); awake or
unknown activity exits before power changes, service installation or input.
Battery is read again before release. The default check never wakes or
installs anything; it returns nonzero when the panel is absent/hidden.
`--wake` saves the exact current `power.pauseCompositorOnStandby` and
`power.turnOffScreensTimeout`, sets them to `0` and `120`, calls the supported
`vrcmd --handlewakeup` and requests the Frame Control dashboard panel. It reads
OpenVR state, captures a stereo compositor screenshot through the production
`ui/frame_vrshot.py` API, and captures `/dev/video99`, the same SteamVR mirror
used by OBS. The mirror reader drains 60 frames before selecting its image,
because the first loopback buffer can contain a previous session. It restores the saved power values and reads them back. It hides
the test dashboard if the dashboard was originally closed. It does not change
the proximity sensor, tracking driver, display hardware, root filesystem,
room setup, or spatial panel placement. An unreachable suspended Frame still
needs a physical wake; an SSH command cannot reach it.
`--exercise` requires the managed headset panel and Decky services to be absent;
it refuses to replace an existing installation. It creates a random test
plugin folder, checks that its settings folder does not exist, and installs
the repository's actual sample frontend/backend, with only the plugin name
and receipt changed to distinguish this test. It installs the verified official
ARM64 Decky release through Frame Control's existing installer, followed by the
normal headset app bundle. Only the test bundle's browser startup is modified:
loopback-only `--remote-debugging-port=0` and a disposable `/tmp` browser profile.
Production browser startup is unchanged. Decky's installer can reload Steam's
web helper; this is not a Steam/SteamVR restart, but its current UI can change.
The test should run while the headset is unattended.
CDP opens the actual plugin through `openFrameyPanel`, observes the opaque
iframe through its own debugging target, and reads control geometry. OpenVR's
`IVROverlayView_003.PostOverlayEvent` supplies mouse move/down/up to the
actual gamescope overlay. The check verifies delivery to the intended element
and a trusted pointer event, toggles the sample, reads its real Decky settings
file, reopens a fresh iframe, and checks the saved value. It also clicks the
Settings/Panel tabs and quick action. A smooth-scroll event is attempted and its measured scroll offset is reported.
Temporary controls exercise the shipped
`DFL.SliderField` and `DFL.TextField`; their values remain UI-only. Text uses
CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input,
not proof of a physical controller or the SteamVR virtual keyboard.
SteamVR can leave a recreated panel hidden with a stale focus request. The
exercise waits briefly for the new overlay, then retries once by removing and
reapplying `STEAM_GAME` on its own test
browser window, then requests focus again. It never retags another application.
If the overlay stays hidden, it records a small read-only registration/service
diagnostic and stops before synthetic input. The report records whether this
recovery was needed. Registration, dashboard
visibility, actual panel visibility, and pixel capture are separate checks.
A visible docked panel can report `IsDashboardVisible=false` and
`IsActiveDashboardOverlay=false`. A zero-sized main texture or failed image
read must not be described as a texture-refresh measurement. The compositor
before/after images provide independent pixel evidence.
Normal cleanup removes only the test-created managed services and random
plugin/profile paths, restores power, cancels its power watchdog, checks battery
and settings, then releases its own lock. A four-minute independent systemd
user timer restores the saved power values even if the controlling process is
killed or disconnected. It deliberately leaves the lock and test artifacts
for recovery. `recovery.json` records the host, owner, timer, remote restore
script and original values as soon as the timer starts. The final report is
`result.json`. Cleanup errors force a nonzero exit and retain the lock; inspect
that report before manually releasing it. Never remove another job's lock.
A power watchdog is not a promise to complete service cleanup after a hard
kill. The next owner must finish that recorded cleanup first.
Exit 0 means the requested machine checks and cleanup passed. It never means
a wearer has approved readability, comfort, controller rays, haptics or text
entry on the VR keyboard. The [real-device evidence](evidence/framey-vr-2026-10-25/README.md)
records the exact tested version and limits. No public CI runner should contact
the shared headset: run this command on a paired, serialized self-hosted worker.
Ordinary CI runs `tests/test_framey_vr_check.py`, the sample backend tests, and
the same-plugin navigation regression without a headset.
## Three-minute worn checklist
1. **0:00–0:30:** Coordinate the shared lock. With an installed managed panel,
Decky backend and harmless sample, wear the headset and open SteamVR →
Frame Control. Check the panel is in front of you, both eyes agree, text is
legible, and the size is comfortable. Do not use a save-cleaning plugin.
2. **0:30–1:15:** Run
`python3 scripts/framey-worn-check.py --via lxso2 --worn-confirmed`. Within its 35-second
interaction window, use the physical controller to open the sample and
toggle **Example setting**. Confirm the laser aims where expected and the
script records a fresh input/panel event and visible stereo output. The flag
is the operator’s statement; sensor activity and trusted pointers can also
be produced synthetically and cannot identify controller hardware.
3. **1:15–2:15:** Use the controller to open Settings, return to Panel, invoke
**Read saved setting**, close/reopen the plugin, and confirm the value
remains. Try controller scroll, a slider and a harmless text field in an
installed compatible plugin; check SteamVR's actual keyboard and Back.
4. **2:15–3:00:** Restore the original sample setting. Switch away and back,
close/reopen the Frame Control panel, and confirm controller input still
works. Record comfort/legibility, controller model, any haptic behavior,
keyboard behavior and the command's JSON separately. Confirm battery and
release only your lock. Do not infer these human checks from synthetic input.
The watchdog's creation and cancellation are exercised on the real Frame.
A forced host kill/network loss followed by timer recovery has not been fault
injected. Its saved recovery script remains available on a cleanup failure.
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more