Compare commits

...
Author SHA1 Message Date
SAPHID a68c5584a9 Document v0.4.41 tagged release verification 2026-10-26 05:03:11 +00:00
Alex Southwell b5d50a8d89 Merge pull request #297 from saphid/release/v0.4.41
Prepare Frame Control v0.4.41
2026-10-26 04:50:27 +00:00
SAPHID 0c9bed1254 Merge remote-tracking branch 'origin/main' into release/v0.4.41 2026-10-26 04:40:51 +00:00
Alex Southwell 741735aa98 Merge pull request #296 from saphid/test/framey-native-scroll
Measure native Framey scroll in an overflowed test region
2026-10-26 04:40:21 +00:00
SAPHID 4133ea1c7d Update support matrix for merged ARM64 audio evidence 2026-10-26 04:39:11 +00:00
SAPHID 3aa510a0e0 Merge remote-tracking branch 'origin/test/framey-native-scroll' into release/v0.4.41 2026-10-26 04:36:17 +00:00
SAPHID 637813024f Record v0.4.41 candidate Frame smoke 2026-10-26 04:34:32 +00:00
SAPHID c87e5c6e8a Prepare Frame Control v0.4.41 2026-10-26 04:32:18 +00:00
Alex Southwell c9777851c9 Merge pull request #293 from saphid/feat/framey-audio-arm64
Verify official audio mixers on the ARM64 Frame
2026-10-26 04:30:38 +00:00
SAPHID af03081f27 Record exact-main Framey sample check after input guard 2026-10-26 04:27:58 +00:00
SAPHID ef99d4953b Merge remote-tracking branch 'origin/main' into test/framey-native-scroll 2026-10-26 04:27:37 +00:00
Alex Southwell 17a7b0b80e Merge pull request #295 from saphid/docs/v0440-release-evidence
Document v0.4.40 release and post-tag Frame checks
2026-10-26 04:26:56 +00:00
Alex Southwell c38e7e8da4 Merge pull request #294 from saphid/fix/framey-sample-setting-guard
Guard Framey sample setting writes against rapid input
2026-10-26 04:25:34 +00:00
SAPHID fc00878a23 Measure Framey native scroll on an overflowed test region 2026-10-26 04:24:43 +00:00
SAPHID 657b4449f7 Document v0.4.40 release and post-tag Frame verification 2026-10-26 04:16:43 +00:00
SAPHID 30a65facde docs(framey): verify official per-app mixers on ARM64 Frame 2026-10-26 04:14:17 +00:00
SAPHID 9135f355f4 Guard sample plugin setting writes against rapid input 2026-10-26 04:14:10 +00:00
Alex Southwell 784118b71e Merge pull request #292 from saphid/fix/framey-quick-action-guard
Guard Framey quick actions against repeated taps
2026-10-26 04:13:32 +00:00
SAPHID e0115f356d Guard Framey quick actions against repeated taps 2026-10-26 04:04:53 +00:00
Alex Southwell 56bde24be1 Merge pull request #290 from saphid/fix/framey-pending-job
Hold Framey plugin changes until saved job resolves
2026-10-26 03:56:02 +00:00
Alex Southwell 59c270a7ad Merge pull request #289 from saphid/feat/framey-module-exports
Enable local Decky module lookups and TabMaster docs
2026-10-26 03:55:43 +00:00
Alex Southwell a4d8073e31 Merge pull request #291 from saphid/release/v0.4.40
Prepare Frame Control v0.4.40
2026-10-26 03:54:16 +00:00
SAPHID c0275b483a Record combined main regression checks for Framey module lookup 2026-10-26 03:39:24 +00:00
SAPHID ff49b44350 Record v0.4.40 candidate real Frame gates 2026-10-26 03:39:09 +00:00
SAPHID 7e6fb7e342 Keep Framey store locked while job result is unknown 2026-10-26 03:38:51 +00:00
SAPHID 57c1c4564b Merge remote-tracking branch 'origin/main' into feat/framey-module-exports
# Conflicts:
#	docs/framey-compatibility.md
#	scripts/framey-compatibility-doc.py
2026-10-26 03:35:18 +00:00
SAPHID 9aa26a8322 Merge remote-tracking branch 'origin/main' into release/v0.4.40 2026-10-26 03:34:08 +00:00
Alex Southwell 38c388f3f3 Merge pull request #288 from saphid/fix/framey-stale-status
Clear stale Framey store status after refresh failures
2026-10-26 03:33:52 +00:00
Alex Southwell 27e3c42ed0 Merge pull request #286 from saphid/feat/framey-chunked-frontend
Load chunked Decky frontends in Framey
2026-10-26 03:33:21 +00:00
SAPHID c2be12fa75 Merge remote-tracking branch 'origin/main' into feat/framey-module-exports 2026-10-26 03:32:53 +00:00
SAPHID c164606fe3 Support bounded Framey-local Decky module and CSS lookup 2026-10-26 03:32:53 +00:00
SAPHID 8d9d4807c3 Prepare Frame Control v0.4.40 version 2026-10-26 03:26:00 +00:00
SAPHID 7f8a743cb5 Clarify real Frame gate scope in Deck Shelves evidence 2026-10-26 03:21:40 +00:00
SAPHID 40df801cdb Clear stale Framey store state after status failure 2026-10-26 03:21:16 +00:00
SAPHID 35d53e6010 Merge remote-tracking branch 'origin/main' into feat/framey-chunked-frontend 2026-10-26 03:21:10 +00:00
SAPHID 1b02d7f558 Record exact Frame gates and verify installed chunk on fake Frame 2026-10-26 03:21:09 +00:00
Alex Southwell edfa48d1f5 Merge pull request #287 from saphid/fix/framey-settings-only-panel
Framey: hide empty Panel tab for settings-only plugins
2026-10-26 03:20:12 +00:00
Alex Southwell c89ddffcbd Merge pull request #285 from saphid/docs/mac-v0439-runtime
Document official Intel Mac runtime test and x64 download
2026-10-26 03:12:42 +00:00
Alex Southwell 5183f30068 Merge pull request #284 from saphid/fix/mac-server-shutdown
Fix server cleanup interrupted by late SIGTERM on Mac quit
2026-10-26 03:12:06 +00:00
SAPHID 37e2faacc7 Hide empty Panel tab for settings-only Framey plugins 2026-10-26 03:10:40 +00:00
SAPHID b0c4647b85 Support chunked official Decky frontends in Framey 2026-10-26 03:09:57 +00:00
Alex Southwell ff3b0dd6f9 Record official Intel Mac runtime checks and remaining opening limits 2026-10-26 14:02:08 +11:00
Alex Southwell 2b6b05a175 Let SIGTERM request server shutdown without interrupting cleanup 2026-10-26 13:59:17 +11:00
Alex Southwell 5c9ca110ed Merge pull request #283 from saphid/docs/v0439-release-evidence
Document v0.4.39 tagged release evidence
2026-10-26 02:57:01 +00:00
SAPHID e3540cab5e Separate released and post-release Framey coverage 2026-10-26 02:41:33 +00:00
SAPHID 0eaa9f2088 Record post-release Framey hardware checks 2026-10-26 02:39:39 +00:00
SAPHID 59f7348aa5 Merge remote-tracking branch 'origin/main' into docs/v0439-release-evidence 2026-10-26 02:39:07 +00:00
Alex Southwell 67b9151513 Merge pull request #282 from saphid/feat/framey-library-context
Framey: add read-only Steam library context for plugin panels
2026-10-26 02:37:07 +00:00
SAPHID 67a95d4f4c Document v0.4.39 tagged release verification 2026-10-26 02:31:10 +00:00
SAPHID ce6e8f807f Merge remote-tracking branch 'origin/main' into feat/framey-library-context 2026-10-26 02:26:52 +00:00
SAPHID e7fe34d865 Add detached Steam library context for Framey panels 2026-10-26 02:26:52 +00:00
Alex Southwell fd0b0a946f Merge pull request #281 from saphid/fix/framey-stale-installed-receipt
Framey: reject stale plugin update and remove requests
2026-10-26 02:19:25 +00:00
Alex Southwell 2895d5711e Merge pull request #280 from saphid/fix/framey-vr-standby-gate
Require standby before Framey VR wake or exercise
2026-10-26 02:18:50 +00:00
SAPHID 1ad6a97a36 Reject stale Decky plugin update and remove requests 2026-10-26 02:10:13 +00:00
SAPHID b795c1dc56 Wait briefly for real Framey overlay visibility 2026-10-26 02:08:41 +00:00
SAPHID db079eec9f Stop unworn input when SteamVR overlay stays hidden 2026-10-26 02:06:34 +00:00
SAPHID bc72c41be5 Update v0.4.39 support matrix with new smoke and VR evidence 2026-10-26 02:00:32 +00:00
SAPHID 697e68f098 Merge remote-tracking branch 'origin/main' into fix/framey-vr-standby-gate 2026-10-26 01:59:54 +00:00
Alex Southwell 3921e59035 Merge pull request #279 from saphid/fix/frame-smoke-shared-lock
Guard real Frame smoke with shared lock and standby preflight
2026-10-26 01:58:29 +00:00
Alex Southwell b863527c33 Merge pull request #278 from saphid/feat/framey-audio-compat
Framey: support exact slider values and verify official audio backends
2026-10-26 01:55:51 +00:00
SAPHID c08f5df677 Refuse unworn Framey wake on active headset 2026-10-26 01:54:19 +00:00
Alex Southwell c62bffc8ef Merge pull request #277 from saphid/release/v0.4.39
Prepare Frame Control v0.4.39
2026-10-26 01:51:17 +00:00
SAPHID aeb6bb5294 Guard real Frame smoke with shared lock and standby preflight 2026-10-26 01:50:32 +00:00
SAPHID 65964f0e46 Support exact slider values and verify isolated official audio backends 2026-10-26 01:34:53 +00:00
SAPHID 792e9c016e Merge remote-tracking branch 'origin/main' into release/v0.4.39 2026-10-26 01:14:31 +00:00
Alex Southwell aeac07cf54 Merge pull request #276 from saphid/docs/framey-rich-toast-sample
Framey: show rich toasts in the author sample
2026-10-26 01:14:15 +00:00
SAPHID d90d111218 Prepare Frame Control 0.4.39 2026-10-26 01:07:02 +00:00
SAPHID 8fc1117209 Demonstrate rich Framey toasts in author sample 2026-10-26 01:05:55 +00:00
Alex Southwell 442b5ecfd8 Merge pull request #264 from saphid/docs/real-mac-air-test
Document the physical Intel Mac test and honest opening steps
2026-10-26 01:05:41 +00:00
Alex Southwell 5afffbef89 Merge pull request #262 from saphid/fleet/es4ntcpvra
Build native Intel Mac installers and check bundled architectures
2026-10-26 01:05:16 +00:00
Alex Southwell 76f619280a Merge pull request #275 from saphid/docs/framey-localsend-arm64
Framey: verify a reproducible ARM64 LocalSend core in isolation
2026-10-26 00:58:53 +00:00
Alex Southwell 2b92599d94 Merge pull request #274 from saphid/feat/framey-steam-reads
Framey: relay three read-only SteamClient APIs from the Frame
2026-10-26 00:58:12 +00:00
Alex Southwell d87b863aa3 Merge pull request #273 from saphid/release/v0.4.38
Prepare Frame Control v0.4.38
2026-10-26 00:51:38 +00:00
SAPHID 6aa74619d5 Document reproducible ARM64 LocalSend core and isolated Frame health probe 2026-10-26 00:46:39 +00:00
Alex Southwell 0a463dea23 Merge remote-tracking branch 'origin/main' into docs/real-mac-air-test
# Conflicts:
#	docs/current-support-matrix.md
2026-10-26 11:44:18 +11:00
SAPHID e44ea65080 Record refreshed store and full frontend regression results 2026-10-26 00:43:11 +00:00
SAPHID ebb1091a37 Merge remote-tracking branch 'origin/main' into release/v0.4.38 2026-10-26 00:41:48 +00:00
Alex Southwell e6430c39ed Merge pull request #272 from saphid/docs/v0437-smoke-evidence
Record v0.4.37 real Frame smoke evidence
2026-10-26 00:41:35 +00:00
SAPHID b518f8c741 Note managed plugin name collision guard in release matrix 2026-10-26 00:41:18 +00:00
SAPHID 1bb5ed6216 Merge remote-tracking branch 'origin/main' into feat/framey-steam-reads 2026-10-26 00:40:46 +00:00
SAPHID 93a4ae95fa Prepare Frame Control 0.4.38 2026-10-26 00:40:46 +00:00
SAPHID e3bb608dde Read allowlisted SteamClient data from the Frame CEF context 2026-10-26 00:40:46 +00:00
Alex Southwell f2e73d98f4 Merge pull request #271 from saphid/feat/framey-ui-fallbacks
Framey: restore detached settings and private Steam JSON cache
2026-10-26 00:39:30 +00:00
Alex Southwell 78d284b154 Merge pull request #270 from saphid/feat/framey-next-compat
Framey: relay real Decky backend events to open panels
2026-10-26 00:33:44 +00:00
SAPHID 2c4581f122 Record v0.4.37 real Frame release smoke 2026-10-26 00:31:56 +00:00
SAPHID 65fee8ca8d Fill detached Steam UI and private JSON cache fallbacks 2026-10-26 00:29:30 +00:00
Alex Southwell 4372c913a6 Merge pull request #269 from saphid/fix/framey-update-name-collision
Framey: reject plugin name collisions on update
2026-10-26 00:26:08 +00:00
Alex Southwell 5239dae1a8 Merge pull request #268 from saphid/release/v0.4.37
Prepare Frame Control v0.4.37
2026-10-26 00:25:20 +00:00
SAPHID 52a0974a84 Merge remote-tracking branch 'origin/main' into feat/framey-next-compat
# Conflicts:
#	docs/frame-plugin-author.md
2026-10-26 00:19:38 +00:00
SAPHID f910c3b278 Relay scoped Decky backend events to Framey panels 2026-10-26 00:19:19 +00:00
SAPHID 9bc5294f36 Reject Decky plugin name collisions during updates 2026-10-26 00:15:48 +00:00
SAPHID 7ed08c5bbd Document Framey toast limits for 0.4.37 2026-10-26 00:14:17 +00:00
SAPHID 449146bbdb Merge remote-tracking branch 'origin/main' into release/v0.4.37 2026-10-26 00:14:04 +00:00
Alex Southwell e115006ac9 Merge pull request #267 from saphid/fix/framey-react-toasts
Framey: render React content in plugin toasts
2026-10-26 00:13:48 +00:00
SAPHID 30fbadbb23 Prepare Frame Control 0.4.37 2026-10-26 00:12:23 +00:00
Alex Southwell cae1270796 Merge pull request #266 from saphid/feat/framey-compatibility-catalog
Audit every official Decky plugin and show Frame compatibility
2026-10-26 00:11:02 +00:00
SAPHID dd62de4f6d Merge remote-tracking branch 'origin/main' into fix/pr266-windows-doc 2026-10-26 00:01:16 +00:00
SAPHID 5a4848cab9 Use UTF-8 for Framey compatibility documentation 2026-10-25 23:48:02 +00:00
SAPHID 480c6dcc36 Merge remote-tracking branch 'origin/feat/framey-safe-ui-shims' into feat/framey-compatibility-catalog 2026-10-25 23:42:47 +00:00
SAPHID 30b20cbdd6 Audit all official Decky plugins and show digest-scoped Frame compatibility 2026-10-25 23:42:47 +00:00
Alex Southwell 19e8b826c6 Merge remote-tracking branch 'origin/main' into docs/real-mac-air-test 2026-10-26 10:35:28 +11:00
Alex Southwell aea65bb282 Merge remote-tracking branch 'origin/main' into fleet/es4ntcpvra 2026-10-26 10:34:56 +11:00
Alex Southwell 2c7b93f756 Fix lipo architecture verification argument order 2026-10-26 10:34:56 +11:00
Alex Southwell 879e84468b Record physical Intel Mac testing and clarify unsigned app opening 2026-10-26 10:30:30 +11:00
Alex Southwell 6ea9eb2618 Build native Intel Mac installers and verify bundled architectures 2026-10-26 10:20:58 +11:00
94 changed files with 19855 additions and 171 deletions

No files matched your search

+14
View File
@@ -70,6 +70,9 @@ 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
@@ -110,6 +113,17 @@ 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
+36 -7
View File
@@ -128,6 +128,7 @@ 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`) |
@@ -150,8 +151,8 @@ The [source](https://github.com/saphid/frame-control) and [release build workflo
日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
**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,
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.
@@ -173,16 +174,44 @@ distribution's. If you already have `adb`, the app uses yours.
<details>
<summary><b>macOS: the app isn't notarized</b></summary>
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:
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):
```sh
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
```
The first time, macOS also asks to allow local network access (for SSH) and
control of Terminal (for the password prompts).
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.
</details>
<details>
+2 -1
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 | win x64 | linux x64 arm64
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
@@ -15,6 +15,7 @@ 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"],
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.36",
"version": "0.4.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.36",
"version": "0.4.41",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+7 -5
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.36",
"version": "0.4.41",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -9,10 +9,11 @@
"scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"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": "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: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: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"
},
"devDependencies": {
"electron": "^44.4.5",
@@ -55,7 +56,8 @@
"*.html",
"*.js",
"*.css",
"telemetry.json"
"telemetry.json",
"framey-compatibility.json"
]
},
{
+7 -6
View File
@@ -1,16 +1,17 @@
# What we have tested
Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
Release baseline: Frame Control **v0.4.41**, 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 |
|---|---|---|---|---|
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.36 tag `1701e62`, Frame OS 0.5.6 build `20261022.6153909`: [release smoke evidence](evidence/v0436-release-smoke-2026-10-25.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
| 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 shipped in v0.4.40 with physical-Mac source regression coverage, but the tagged Intel installer was not launched on physical Intel hardware | 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 ran the exact v0.4.41 tagged source against the real Frame; 13/13 reserved title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Optional Steam Linux Runtime 4 was absent, so the x86_64 test executable did not actually launch. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.41 tag `b5d50a8`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0441-release-2026-10-26.md); [earlier candidate smoke](evidence/v0441-candidate-frame-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 | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
| OBS headset source on 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) |
@@ -22,8 +23,8 @@ Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified. The new large read-only status screen has browser layout and packaging checks only | 25 Oct, v0.4.29 device check and v0.4.31 status screen: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [PR #220](https://github.com/saphid/frame-control/pull/220), [panel guide](headset-app.md) |
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. v0.4.35 treats `_root` store tags as root-required and hides duplicate IDs; this has test coverage, not a live store check. v0.4.36 stops a store install or update when the catalog hash changed after review; this was tested with a fake Frame, not with a live store change on the headset. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.36 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [PR #246](https://github.com/saphid/frame-control/pull/246), [PR #253](https://github.com/saphid/frame-control/pull/253), [compatibility table](decky-frame.md) |
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Official Steam Status rendered in local Chromium; a session-only cache avoided its earlier storage errors. A later local browser check restored a bounded per-plugin cache after panel reopen; live Decky and offline behavior were not tested. Fake Frame tests cover setting read/write through update and reinstall and install/remove two pinned official API v2 archives plus the modern API v1 Storage Cleaner archive. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle with a settings tab and read-only backend quick action. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set. Storage Cleaner 1.4.0 showed an empty-data panel and four disabled cleanup buttons in local Chromium with a stub backend; no cleanup action was tested. HLTB for Deck 2.0.12 built a shallow settings view and Bluetooth 3.1.1 built its initial panel in local frontend tests without effects or backend calls. Their badges, search, pairing and Bluetooth changes remain unverified. A hash match, local panel and fake Frame install do not prove VR use. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.36: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [#236](https://github.com/saphid/frame-control/pull/236), [#237](https://github.com/saphid/frame-control/pull/237), [#238](https://github.com/saphid/frame-control/pull/238), [#239](https://github.com/saphid/frame-control/pull/239), [#240](https://github.com/saphid/frame-control/pull/240), [#241](https://github.com/saphid/frame-control/pull/241), [#243](https://github.com/saphid/frame-control/pull/243), [#244](https://github.com/saphid/frame-control/pull/244), [#245](https://github.com/saphid/frame-control/pull/245), [#247](https://github.com/saphid/frame-control/pull/247), [#248](https://github.com/saphid/frame-control/pull/248), [#251](https://github.com/saphid/frame-control/pull/251), [#252](https://github.com/saphid/frame-control/pull/252), [#253](https://github.com/saphid/frame-control/pull/253), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.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.40: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [post-#292 unworn sample exercise](evidence/v0440-release-2026-10-26.md#post-tag-292-panel-regression), [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. v0.4.41 classifies the 116 exact-version plugins as 3 works, 48 partial, 54 broken and 11 not applicable. Notebook works for its default flow; Volume Mixer and Mixer work for tested per-app silent-stream volume control only. TabMaster is partial. 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. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR 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.41: [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), [ARM64 audio evidence](evidence/framey-audio-arm64-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; v0.4.40 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.40: [post-#294 unworn sample and scroll evidence](evidence/framey-scroll-probe-2026-10-26.md#post-294-exact-main-sample-check), [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.
+10 -2
View File
@@ -88,8 +88,16 @@ entries. Install, update, and remove need an explicit confirmation. They only
change Frame Control owned plugin folders under
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
back an update if the service restart fails. The current VR shell does not
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
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.
@@ -0,0 +1,384 @@
{
"schemaVersion": 1,
"timeUtc": "2026-10-26T04:05:15.145624+00:00",
"baseCommit": "56bde24",
"frameOS": "0.5.6",
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"scope": "Local Chromium opaque Framey panels, production SSH/CDP bridge and actual unmodified official backends in ARM64 Decky; real user audio session, silent test stream only; no worn VR",
"plugins": [
{
"id": 38,
"name": "Volume Mixer",
"version": "1.2.1-1",
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3"
},
{
"id": 58,
"name": "Volume Boost",
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc"
},
{
"id": 144,
"name": "Mixer",
"version": "0.1.0",
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106"
}
],
"readOnlySession": {
"timeUtc": "2026-10-26T04:03:54.509107+00:00",
"before": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"volumeMixerLogExists": false,
"defaultSinkVolume": {
"code": 0,
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
"stderr": ""
},
"pactlInfoExit": 0,
"pwDumpExit": 0
},
"after": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"volumeMixerLogExists": false,
"defaultSinkVolume": {
"code": 0,
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
"stderr": ""
},
"pactlInfoExit": 0,
"pwDumpExit": 0
},
"calls": [
{
"id": 38,
"method": "get_sink_inputs",
"args": [
{}
],
"legacy": true,
"version": "1.2.1-1",
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
"result": {
"success": true,
"streamCount": 1,
"volumes": [
1
]
}
},
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"legacy": true,
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
"result": {
"result": {
"result": 0,
"success": true
}
}
},
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "right"
}
],
"legacy": true,
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
"result": {
"result": {
"result": 0,
"success": true
}
}
},
{
"id": 144,
"method": "get_settings",
"args": [],
"legacy": false,
"version": "0.1.0",
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
"result": {
"result": {
"filters": [],
"boostLimit": 0
}
}
},
{
"id": 144,
"method": "get_playbacks",
"args": [],
"legacy": false,
"version": "0.1.0",
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
"result": {
"count": 6,
"internalAudioNodeCount": 5,
"steamStreamCount": 1
}
}
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"fixedLogAbsent": true
},
"lockReleased": true
},
"liveSilentStream": {
"before": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"volumeMixerLogExists": false,
"defaultSinkVolume": {
"code": 0,
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
"stderr": ""
},
"pactlInfoExit": 0,
"pwDumpExit": 0
},
"after": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"volumeMixerLogExists": false,
"defaultSinkVolume": {
"code": 0,
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
"stderr": ""
},
"pactlInfoExit": 0,
"pwDumpExit": 0
},
"source": "paplay --raw --format=s16le --rate=48000 --channels=2 --client-name=FrameySilentARM64 --stream-name=\"Framey ARM64 silent verification\" /dev/zero",
"writes": [
{
"id": 38,
"method": "set_input_volume",
"args": [
{
"index": 31400,
"volume": 0.5
}
],
"result": {
"result": true,
"success": true
},
"independentReadback": [
{
"index": 31400,
"name": "FrameySilentARM64",
"volume": {
"front-left": {
"value": 32768,
"value_percent": "50%",
"db": "-18.06 dB"
},
"front-right": {
"value": 32768,
"value_percent": "50%",
"db": "-18.06 dB"
}
}
}
]
},
{
"id": 38,
"method": "set_input_volume",
"args": [
{
"index": 31400,
"volume": 0.45
}
],
"result": {
"result": true,
"success": true
},
"independentReadback": [
{
"index": 31400,
"name": "FrameySilentARM64",
"volume": {
"front-left": {
"value": 29491,
"value_percent": "45%",
"db": "-20.81 dB"
},
"front-right": {
"value": 29491,
"value_percent": "45%",
"db": "-20.81 dB"
}
}
}
]
},
{
"id": 144,
"method": "set_volume",
"args": [
199,
37
],
"result": true,
"independentReadback": [
{
"index": 31400,
"name": "FrameySilentARM64",
"volume": {
"front-left": {
"value": 24248,
"value_percent": "37%",
"db": "-25.91 dB"
},
"front-right": {
"value": 24248,
"value_percent": "37%",
"db": "-25.91 dB"
}
}
}
]
},
{
"id": 144,
"method": "set_volume",
"args": [
199,
38
],
"result": true,
"independentReadback": [
{
"index": 31400,
"name": "FrameySilentARM64",
"volume": {
"front-left": {
"value": 24904,
"value_percent": "38%",
"db": "-25.21 dB"
},
"front-right": {
"value": 24904,
"value_percent": "38%",
"db": "-25.21 dB"
}
}
}
]
}
],
"volumeMixerReopen": {
"id": 38,
"method": "get_sink_inputs",
"args": [
{}
],
"result": {
"result": [
{
"name": "FrameySilentARM64",
"index": 31400,
"volume": 0.3800048828125
}
],
"success": true
}
},
"mixerReadAfterChange": {
"id": 144,
"method": "get_playbacks",
"args": [],
"result": [
{
"id": 199,
"name": "FrameySilentARM64",
"mediaName": "Framey ARM64 silent verification",
"volume": 38
}
]
},
"volumeBoostReads": [
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
}
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"fixedLogAbsent": true
},
"lockReleased": true
},
"independentCleanupAudit": {
"testPathsAbsent": true,
"units": {
"framey-audio-kp0z857q58-32363fd7": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
"framey-audio-kp0z857q58-c08f3f5d": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
"framey-audio-kp0z857q58-c08f3f5d-silence": "MainPID=0\nLoadState=not-found\nActiveState=inactive"
},
"silentStreamsAbsent": true
},
"limitations": [
"Physical audible output, default-sink writes, boost above 100%, Bluetooth playback controls and worn VR unverified.",
"Mixer also exposes internal Frame PipeWire nodes and Reset; neither was changed or invoked.",
"Private Decky/installed-plugin outer status fixture; real RPC used production framey_panel.call_method over SSH and live Steam CEF, not a deployed headset HTTP endpoint test.",
"Mixer filter save/restart was verified on amd64 in the earlier evidence, not repeated on ARM64."
],
"catalogRefresh": {
"timeUtc": "2026-10-26T04:11:59.449535+00:00",
"visibleCount": 116,
"idsVersionsDigestsUnchanged": true
}
}
@@ -0,0 +1,111 @@
# Official audio plugins on the ARM64 Frame
Volume Mixer 1.2.1-1 (38) and Mixer 0.1.0 (144) now qualify as **works for
per-app volume control**. Their exact official, unmodified Python backends ran
in published Decky 3.3.0 ARM64 on Frame OS 0.5.6. Local Chromium rendered the
actual Framey panels and changed only a deliberately created silent playback
stream in the Frame's real user audio session. Independent Pulse readbacks
confirmed the changes. This is useful backend behavior, not initializer-only
or fake-backend evidence.
Volume Boost 0.0.7 (58) remains **partial**. Its real ARM64 default-sink reads
matched independent `pactl` reads, and its controls rendered. Default-sink writes
were denied throughout the real-Frame run. Its earlier slider/numeric write
checks used an isolated amd64 virtual sink.
[Digest-pinned observations and cleanup](framey-audio-arm64-2026-10-26.json)
record both runs. No physical audible output or worn-VR result is claimed.
| Plugin | Real Frame result | Remaining limits |
| --- | --- | --- |
| Volume Mixer | `pulsectl`/`ravel` import; real Pulse session discovery; silent-stream slider click 100 → 50%, ArrowDown → 45%; reopen reads back the 38% subsequently set by Mixer | Bluetooth discovery/playback commands, audible output and worn-VR input unverified. The range input rounds the reopened 0.38000488 backend value to its configured step; no extra write occurred. |
| Volume Boost | `pactl` default sink and stereo channel parsing; both backend reads return 0 and agree with independent 0% / 0% sink reads; local panel shows 0 | No real default-sink writes or boosts; no audible output or worn-VR result. Zero alone is not proof: upstream returns 0 when its command fails. |
| Mixer | `pw-dump`/`wpctl` available; real playbacks/settings reads; exact silent-stream numeric input 37%, ArrowUp → 38%, independently read back | Exposes internal Frame PipeWire nodes as well as apps. Internal-node controls and Reset were not exercised. Filter persistence passed on amd64 earlier, not ARM64. Audible output and worn-VR input unverified. |
## Runtime and transport
The normal atomic `/tmp/frame-test.lock` was acquired separately for each run.
An existing owner would abort testing; the lock was never stolen. The headset
was not woken, and no power setting was changed.
The actual Frame UID is 1000. The legacy plugins' hard-coded
`XDG_RUNTIME_DIR=/run/user/1000` therefore reaches this Frame's existing audio
session. Volume Mixer's bundled pure Python wrappers successfully loaded the
system ARM64 Pulse and D-Bus libraries. No native rebuild was needed. It also
sets `HOME=/home/deck` inside its own plugin process; this test does not establish
that arbitrary Deck paths work on the Frame.
The upstream `PluginLoader-arm64` release was verified against GitHub's
published SHA-256 and ELF machine 183 before transfer. Two private, bounded,
removable user-service installs used their own Decky plugins/settings bases.
Neither service was enabled. The normal managed Decky service was already
failed, and the normal headset-panel service inactive; both states remained
unchanged. A pre-existing `/tmp/VolumeMixer-decky.log` would have aborted the
run because the upstream plugin truncates that fixed path during import.
The local relay served actual `ui/headset.html`, vendored React, `framey-host.js`
and digest-checked official frontend bytes in an opaque `allow-scripts` iframe.
Only outer status/install responses were fixtures. Plugin RPC used production
`framey_panel.call_method`, SSH through lxso2, `frame_steam.Page`, the live Steam
CEF `DeckyBackend.call`, and actual Decky plugin subprocesses. Private install
paths were redirected to the temporary base. No second Decky websocket client
was attached. This does not verify the deployed headset HTTP endpoint or a VR
compositor interaction.
## Safe control check
The first run was read-only. Independent `pactl info`, default-sink volume and
`pw-dump` commands exited successfully, so empty/zero fallback values were not
accepted on their own. Volume Mixer saw a live Steam stream; Mixer also saw
Frame spatialization, echo-cancellation and ALSA internal nodes.
For the second run, a separate task-owned transient user service ran:
```sh
paplay --raw --format=s16le --rate=48000 --channels=2 \
--client-name=FrameySilentARM64 \
--stream-name='Framey ARM64 silent verification' /dev/zero
```
This sends zero-valued samples to the existing user server. It is an explicit
silent client, not the earlier private null-server fixture. No server, device,
default-route or physical-sink configuration was changed. The relay resolved
its live stream IDs before every write and allowed only this exact application
name, bounded to 80% volume. The spatialization node with a similar prefix was
excluded. All Volume Boost writes, Mixer Reset/settings writes and Bluetooth
commands were denied. Existing streams and internal nodes were never write
targets. This broader real-session check supersedes the earlier suggested
null-server-only ARM64 follow-up; that older fixture could not prove access to
the Frame's real session.
Every browser-triggered volume RPC was followed by independent
`pactl --format=json list sink-inputs` channel readback. Only the named silent
stream's rows are published. Background streams can appear/disappear naturally;
no whole-session stability claim is made.
## Cleanup and catalog interpretation
Both runs removed their private Decky units/bases and the test-created fixed
plugin log. The live run also stopped its transient silent client. Decky's main
process exited during shutdown but left children waiting; those remaining
processes were killed only within the test service's systemd cgroup before
cleanup completed. Both locks were released after cleanup. An independent
fresh-lock audit confirmed all three test units unloaded/inactive with PID 0,
private paths absent, and no named silent stream. Battery was 100 → 100 and
independent default-sink reads stayed 0% / 0% in both runs.
The canonical store data and generated compatibility table now report
**3 works / 48 partial / 54 broken / 11 N/A**. “Works” describes the tested
per-app control behavior; read each row's remaining limits. Historical
[fake-Frame archive checks](framey-module-lookup-2026-10-26.md) cover transport
with plugin execution stubbed, and the
[earlier local audio checks](framey-audio-backends-2026-10-26.md) cover isolated
amd64 controls/filter persistence. Neither is relabelled as a real ARM64 test.
## Repository checks
The full Python suite passed 1,621 tests (16 skips), and the focused Framey
suite passed all 43 tests. `scripts/framey-compatibility-doc.py --check` and
`git diff --check` passed. A fresh official-store fetch at 04:11:59 UTC found
all 116 IDs, versions and digests unchanged. This PR changes catalog/evidence
and generated documentation; it adds no backend shim or plugin patch.
@@ -0,0 +1,677 @@
{
"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
}
}
}
@@ -0,0 +1,110 @@
# Official audio plugins: local real-backend checks
Historical amd64 run. The [later ARM64 Frame check](framey-audio-arm64-2026-10-26.md)
supersedes the ARM64 gaps and follow-up plan below; it promotes the two per-app
mixers to works for their verified scope. Volume Boost remains partial.
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.
@@ -0,0 +1,56 @@
{
"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
}
}
@@ -0,0 +1,13 @@
# 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.

After

Width:  |  Height:  |  Size: 59 KiB

@@ -0,0 +1,418 @@
{
"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."
]
}
@@ -0,0 +1,326 @@
{
"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
}
}
@@ -0,0 +1,72 @@
# 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.
@@ -0,0 +1,97 @@
{
"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"
]
}
@@ -0,0 +1,57 @@
# 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.
@@ -0,0 +1,222 @@
{
"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
}
}
@@ -0,0 +1,64 @@
# 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.
@@ -0,0 +1,66 @@
{
"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": []
}
@@ -0,0 +1,23 @@
# Unworn Framey scroll transport probe, 26 October 2026
The previous `scripts/framey-vr-check.py --exercise` runs reported `scrollY=0` before and after one injected smooth-scroll event. That was inconclusive because the repository sample may fit inside the iframe. This probe changed only the **test script**: it created a temporary 160-pixel-high scroll region with 716 pixels of content, moved the native OpenVR pointer over it, and measured its own `scrollTop` and captured wheel events. No production Framey panel code changed.
Two local diagnostic versions ran on the real Frame from base main `784118b` after PR #292. The final version in this PR tried OpenVR `VREvent_ScrollSmooth` (309) and `VREvent_ScrollDiscrete` (305), using Valve's documented `VREvent_Scroll_t` ABI. The overlay listing showed the temporary Frame Control dashboard visible with `+smoothscroll`; a trusted native pointer move reached the iframe. Neither scroll event reached Chromium's capture listener and the region stayed at `scrollTop=0`. This establishes only that `IVROverlayView_003.PostOverlayEvent` did not deliver these synthetic scroll events in this run. A physical controller may follow a different path.
| Check | First probe | Final probe |
| --- | --- | --- |
| Unique shared lock and standby gate | Acquired; activity 3, battery 99% | Acquired; activity 3, battery 100% |
| Scroll region | 160 px viewport, 716 px content | 160 px viewport, 716 px content |
| Native pointer | Moved over region | Trusted pointer move reached iframe |
| Injected scroll | Smooth 309 | Smooth 309, then discrete 305 |
| Wheel events / offset | 0 / 0 → 0 | 0 / 0 → 0 |
| Other sample checks | Passed | Passed |
| Cleanup | Power restored to `false/5`, recovery timer removed, owned lock released; battery 99% | Same cleanup; battery 99% |
Both runs captured changed stereo compositor frames and a `/dev/video99` mirror frame, saved only in private folders `/tmp/framey-native-scroll-probe/` and `/tmp/framey-native-scroll-events/`. No room pixels are committed. The final run's `result.json` reported `passed=true`, `cleanupErrors=[]`, `powerRecoveryRemoved=true` and `lockReleased=true`. Here `passed` covers the sample setting, quick action, slider, text and cleanup checks; **native scroll did not pass**. The final immediate SteamVR activity was 0 after the temporary wake; original power settings were restored and read back. No person wore the headset, and physical controller scrolling, rays, haptics, keyboard, optical readability and comfort remain unverified. The hourly worn-check schedule remains enabled.
## Post-#294 exact-main sample check
After PR #294 merged, the **unmodified** `scripts/framey-vr-check.py --exercise` from exact main `c38e7e8da459e44a9a717d15e6a07ab8a8e8ef8d` exited 0 on the real Frame. Locked preflight found activity 3 standby, proximity 6.3125 and battery 99%. The sample saved `{"enabled": true}` through trusted synthetic OpenVR input and the live Decky backend, read the file independently, then reopened a fresh iframe with the value retained. Quick action, slider value 50 and text entry completed; compositor frames changed and the mirror capture returned a frame. The old scroll diagnostic still reported no offset movement.
The run removed its temporary services and recovery timer, restored/read back power `false/5`, reported no cleanup errors, and released only its owned lock. Its immediate activity was 0 after the temporary wake. Private captures/result are under `/tmp/framey-post294/` outside Git. This was one setting activation, so it does not prove the new rapid-double-input guard on physical controllers. No person wore the headset.
@@ -0,0 +1,99 @@
{
"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"
}
}
@@ -0,0 +1,107 @@
{
"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"
]
}
@@ -0,0 +1,120 @@
{
"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
}
]
}
@@ -0,0 +1,50 @@
# 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.

After

Width:  |  Height:  |  Size: 204 KiB

+116
View File
@@ -0,0 +1,116 @@
# 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
@@ -0,0 +1,102 @@
# 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.

After

Width:  |  Height:  |  Size: 529 KiB

@@ -0,0 +1,24 @@
# 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).
@@ -0,0 +1,32 @@
{
"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"
]
}
@@ -0,0 +1,27 @@
# 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
@@ -0,0 +1,212 @@
{
"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
@@ -0,0 +1,25 @@
# 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.
@@ -0,0 +1,9 @@
# 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.
+45
View File
@@ -0,0 +1,45 @@
# v0.4.40 tag and post-tag Frame checks, 26 October 2026
The v0.4.40 annotated tag dereferences to main merge commit `a4d8073e3199671873d304b80a6acc8cdbd33866` from PR #291. The tag contains the 0.4.40 package and lockfile version. PR #291 passed all reported exact-head checks before merge, including Linux, Windows, macOS, iOS, fake Frame e2e and installer preview builds.
## Real Frame, tagged source
From an exact `v0.4.40` checkout, `scripts/frame-smoke.sh` acquired its unique `/tmp/frame-test.lock` owner before any headset read. Locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On SteamOS 0.5.6 build `20261022.6153909`, all **13/13** reserved title steps passed: ARM64, x86_64 and Windows executable install, launch and remove, plus final cleanup. The command exited 0, which includes successful removal of its own lock. Its detailed result remains private under `tests/smoke/results/2026-10-26T035641Z-20261022.6153909.json` in the test checkout.
## Real Frame, next main source
After #289 and #290 merged, `scripts/framey-vr-check.py --exercise` ran from main commit `56bde24be1d38ac060e8ff287455182765e48564` under its own unique shared lock. The locked preflight found activity 3, proximity 6.25, battery 99%, dashboard already visible, and original SteamVR power values `false` and `5`.
The repository sample panel appeared in the stereo compositor; before/after compositor hashes differed and `/dev/video99` returned a mirror frame. Synthetic OpenVR pointer events were trusted by the panel. A real Decky 3.3.0 backend saved the sample setting from off to on; an independent settings-file read returned `{"enabled": true}`, and a fresh iframe reopen showed on. The quick action, slider value 50 and text entry path completed. Native scroll was attempted but its measured offset stayed 0. This exercise did not run an official store plugin.
The command exited 0. It removed test services and temporary profile/plugin paths, restored and read back the original power values, removed its recovery timer, reported no cleanup errors, found battery still 99%, and released its own lock. Its immediate final SteamVR activity was 0 after the temporary wake; the saved standby power behavior was restored. Private room and UI captures remain outside Git.
No person wore the headset. A stereo screenshot, changed pixels, trusted synthetic input and mirror frame do **not** prove wearer-visible passthrough, physical controller usability, VR keyboard behavior, native scrolling, optical legibility or comfort. The [worn checklist](../framey-vr-check.md#three-minute-worn-checklist) and hourly check remain necessary. Official TabMaster, Deck Shelves, ProtonDB Badges and other store panels were not exercised in this run.
## Downloaded tagged installers
The draft release's GitHub digest for `Frame-Control-linux-x86_64.AppImage` is `sha256:cded3c660e41eb04e835e28057da81345075c4dbe9a009f397aea1da59655b3d`. A fresh download matched it. After setting the download's executable bit, `scripts/check-appimage-launch.py` launched that exact file under isolated Xvfb: its bundled server answered HTTP 200 with `FrameControl/1`, then stopped on shutdown.
The draft release's GitHub digest for `Frame-Control-mac-arm64.zip` is `sha256:98f6d118d0db0cba10c3e4bfcd618953f10ba1c35246ce26041ecd39a1d3aff0`. Fresh local and physical-Mac copies matched. On a reachable physical arm64 Mac running macOS 26.5.2, a private temporary extraction reported bundle version 0.4.40; `lipo -verify_arch arm64` passed for the app executable, bundled Python, adb and Mac viewer. Its bundled Python imported `http.server`, `ssl` and `sqlite3` and reported OpenSSL 3.5.8. The temporary directory was removed and checked absent.
That Mac already had a person's Frame Control app running, so no tagged GUI was launched, replaced or quit. The physical Intel Air was unreachable from this worker; the tagged Intel artifact's build check and bundle-architecture CI are separate from a physical Intel runtime test. No macOS Gatekeeper browser-download path was tested.
## Tagged workflow and checksum gate
The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40266393941) ran at `a4d8073e3199671873d304b80a6acc8cdbd33866` and passed all six jobs: draft, Apple Silicon Mac, Intel Mac, Windows, Linux and checksums. Its Linux job launched the built AppImage under Xvfb; its Mac jobs checked bundled binary architectures and Python imports; its Windows job installed the Setup executable, compared installed resources and ran the bundled Python PC-library tests.
The draft has exactly ten installers and `SHA256SUMS`, each with a GitHub SHA-256 digest. Regenerating the checksum file from the ten asset digests produced bytes identical to the downloaded `SHA256SUMS`; its own digest matches `7457da8f292bc1b2b74cb989b4d179858055380721b26e23ccd595994701d1f8`.
A fresh `Frame-Control-mac-x64.zip` download matched GitHub digest `52f257fc0dfc1b41e89ab0c7a551d667af4cc46f800e4f57e2689d47c856977c`. Independent ZIP inspection found x86_64 Mach-O app, bundled Python 3.12 and Mac viewer executables. This is packaging evidence, not a physical Intel Mac launch.
## Publication and updater readback
`scripts/publish-release.sh --dry-run v0.4.40` passed the exact tag, ten installer digests and regenerated `SHA256SUMS` gate. The release script then published [v0.4.40](https://github.com/saphid/frame-control/releases/tag/v0.4.40) at 2026-10-26 04:14:35 UTC. A direct GitHub latest-release read returned `draft=false`, `tag_name=v0.4.40`, ten installers, `SHA256SUMS` and `update.json`. A fresh download of the public `update.json` parsed as version 0.4.40, its stable tag page, ten assets with SHA-256 digests and the unworn-test limits in its notes. This release contains the tagged `a4d8073` commit, not later main changes.
## Post-tag #292 panel regression
PR #292 passed all 11 hosted checks on exact head `e0115f356d3b33369a61cfba034b080a7764559f` and merged as `784118b71e3463156574988121f47354d87358c7`. From that exact main commit, `python3 scripts/framey-vr-check.py --exercise --output /tmp/framey-v0440-post292` exited 0 on the real Frame. Its unique `/tmp/frame-test.lock` owner was acquired before any read and released on exit. Preflight: battery 99%, proximity 6.25, SteamVR activity 3 standby, dashboard already visible, saved power `false/5`.
The temporary repository sample appeared in stereo compositor and mirror captures; compositor frame hashes changed. Trusted synthetic OpenVR pointer input saved a Decky setting to `{"enabled": true}`, independently read from the settings file and retained on a fresh iframe reopen. A quick action, slider value 50 and CDP text entry after OpenVR focus completed. Native scroll remained at offset 0. The test reported `passed=true`, no cleanup errors, removed its recovery timer, restored/read back power `false/5`, battery 99%, and released only its own lock. The private result and room captures remain under `/tmp/framey-v0440-post292/` outside Git.
This exercises one quick-action activation. It does not prove the new rapid duplicate-tap guard with physical controllers. No person wore the headset; no official Decky plugin panel was tested. The hourly worn-headset schedule remains enabled for wearer-visible passthrough, controller, scrolling, keyboard and comfort checks.
@@ -0,0 +1,7 @@
# v0.4.41 candidate: real Frame title smoke, 26 October 2026
Source commit `c87e5c6` bumps the app and lockfile to 0.4.41 on top of main after PRs #289, #290, #292–#295. It includes the exact official ARM64 audio evidence from #293. The release tag and installers do not exist at this candidate stage.
`scripts/frame-smoke.sh` acquired its unique owner in `/tmp/frame-test.lock` before any headset read. Locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, all **13/13** steps passed: existing reserved-title cleanup, devkit identity/status, install/launch/remove for ARM64, x86_64 and Windows test executables, and final cleanup. The command exited 0, which includes successfully releasing only its own lock. Its detailed result remains private under `tests/smoke/results/2026-10-26T043229Z-20261022.6153909.json` in the candidate checkout.
No person wore the headset. This checks title management and process cleanup, not wearer-visible rendering, physical controllers, official plugin VR panels, audio output or installed desktop packages. After PR #294, a separate exact-main unworn Framey sample exercise passed live setting persistence, synthetic OpenVR input, stereo compositor change and cleanup; it did not prove rapid physical controller taps or native scroll. The [v0.4.40 publication evidence](v0440-release-2026-10-26.md) records the previous tagged installer gates. New v0.4.41 tagged installers and checksums must be verified before publication.
+23
View File
@@ -0,0 +1,23 @@
# v0.4.41 tagged release, 26 October 2026
The annotated `v0.4.41` tag dereferences to main merge commit `b5d50a8d896f790a94baf4e937db914eab475ed9` from PR #297. The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40271174791) passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs. The platform jobs launched the built Linux AppImage, checked the Windows installation and bundled PC Python libraries, and checked Mac bundle architectures. These CI results do not establish a physical Windows or Mac launch.
## Real Frame, exact tag
From an exact tag checkout, `scripts/frame-smoke.sh` acquired its unique owner in `/tmp/frame-test.lock` before reading the headset. Preflight found SteamVR activity 3 (standby) and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, all **13/13 reserved title steps passed**; the command exited 0, including test title and process cleanup and release of its owned lock. ARM64 and Proton test executables launched. The x86_64 launch step recorded that optional Steam Linux Runtime 4 was **not installed**, so that executable did not actually launch. The private detailed result is `tests/smoke/results/2026-10-26T045140Z-20261022.6153909.json` in the exact-tag checkout.
No person wore the headset. This title smoke does not verify active passthrough pixels, wearer-visible panels, physical controller input, official Decky plugin panels, audio output, comfort or VR keyboard use. The separate [unworn Framey sample exercise](framey-scroll-probe-2026-10-26.md) established live sample setting persistence, stereo compositor changes and trusted synthetic OpenVR input; it did not establish a worn result. The hourly worn check remains enabled.
## Downloaded installer and checksum checks
The draft held ten installers plus `SHA256SUMS`, each with a GitHub SHA-256 digest. `scripts/publish-release.sh --dry-run v0.4.41` checked the exact tag and all ten installer digests, regenerated `SHA256SUMS` from those digests and matched its own GitHub digest. A fresh downloaded checksum list independently matched `e7555956aeb9ec3a464081aaafe1ed6762f65a17c04edaa6ea45286f10927109`.
A fresh Linux x86_64 AppImage download matched `5d1f50dc8527faf299f2058c7b5ed19057ec7c81c41449f6ccd44d68e6b4d212`. `scripts/check-appimage-launch.py` launched that exact file under isolated Xvfb: the bundled server answered HTTP 200 with `FrameControl/1`, then stopped. The first local attempt failed at Chromium sandbox startup inside the command sandbox; rerunning the same downloaded file with normal host permissions passed. The release workflow's independent Linux launch also passed.
Fresh Mac ZIP downloads matched GitHub digests: arm64 `84615ec8a0c62481a9f33bae1815d55ec902db9aa9d4bd767fcf9380c3d44d7a`, x64 `2800b749cda98d15207950eca4a6bd205d35f30551f16efd7720b41347edc7ca`. Both contain bundle version 0.4.41; the app executable Mach-O CPU types are arm64 and x86_64 respectively. The physical Mac was unreachable over SSH, so neither tagged ZIP was launched there. The physical Intel Air was also not tested with this tag.
## Publication and updater readback
The final dry run passed with the release notes and hardware limits in the draft. `scripts/publish-release.sh v0.4.41` published [v0.4.41](https://github.com/saphid/frame-control/releases/tag/v0.4.41) at 2026-10-26 05:01:47 UTC. GitHub's latest-release API read back `draft=false`, tag `v0.4.41`, ten installers, `SHA256SUMS` and `update.json`. A fresh public latest-download of `update.json` parsed as version 0.4.41 with the stable release page, ten SHA-256 digests and the unworn-test limits in its notes. The release contains the tagged `b5d50a8` commit; no post-tag changes are implied.
The exact-version official Decky catalog in this release is **3 works / 48 partial / 54 broken / 11 N/A**. Volume Mixer and Mixer are works only for the tested per-app silent-stream volume scope on real ARM64 Decky; [audio evidence](framey-audio-arm64-2026-10-26.md) describes the independent Pulse readbacks. Volume Boost remains partial. Audible playback, Bluetooth, other mixer controls and official-plugin worn VR remain unverified.
+4 -3
View File
@@ -66,8 +66,9 @@ none of these frontend controls has passed a worn-headset check yet.
| `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; up to eight actions with result text | Host unit test; these fields are Framey additions |
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
| 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. |
@@ -75,7 +76,7 @@ none of these frontend controls has passed a worn-headset check yet.
| `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`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey |
| `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
+313
View File
@@ -0,0 +1,313 @@
# 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: 3 · Partial: 48 · 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. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. Volume Boost stays partial: default-sink reads matched, but real sink writes were denied. Audible output, Bluetooth playback and worn VR remain unverified. 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 | works | Per-app stream discovery and slider/keyboard volume changes pass in local Chromium through real Decky 3.3.0 ARM64 on the Frame audio session, with independent Pulse readback of a silent test stream. Bluetooth playback, audible output and worn-VR controls unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
| 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 | Real Decky 3.3.0 ARM64 default-sink reads match independent Frame pactl channel volumes; controls render locally. Sliders/numeric writes passed on an isolated amd64 virtual sink only. Real default-sink writes, boost above 100%, audible output and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
| 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 | works | Per-app discovery and numeric volume changes pass in local Chromium through real Decky 3.3.0 ARM64 on the Frame audio session, independently read back on a silent test stream. Filters persist on amd64; ARM64 filter persistence, Reset, internal audio nodes, audible output and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
| 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.
+62 -4
View File
@@ -6,6 +6,8 @@ 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
@@ -49,6 +51,34 @@ 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,
@@ -92,6 +122,28 @@ 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
@@ -114,7 +166,10 @@ Install, Update, and Remove show the current job stage in the headset panel.
The store verifies each downloaded archive and the headset applies it with a
rollback if Decky cannot start. The panel remembers the current job ID for this
browser session, so **Check result** can resume polling after a navigation or
temporary connection error. Refresh the installed list before starting another
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
@@ -142,13 +197,16 @@ and the fake Frame flow before asking users to install it.
An API v2 plugin's normal `content` is its default VR panel. Framey also shows
an optional `settings` React element under a **Settings** tab and an optional
`titleView` above it. For one-tap headset controls, return
`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. These descriptor fields are Framey additions; Decky
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 [API v2 compatibility table](frame-plugin-author.md#framey-api-v2-compatibility)
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
+15 -5
View File
@@ -16,8 +16,10 @@ 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 and SteamVR state. A foreign lock prevents sensor
reads, wake, installation and input. Unknown battery or below 20% aborts.
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.
@@ -51,16 +53,24 @@ iframe through its own debugging target, and reads control geometry. OpenVR's
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.
Settings/Panel tabs and quick action. The scroll diagnostic creates a temporary
160-pixel-high region with more than 700 pixels of content, moves the native
pointer over it, then attempts OpenVR smooth and discrete scroll events. It
reports region geometry, trusted pointer events, wheel events and `scrollTop`.
No wheel event or offset change means this synthetic injection path remains
unverified; it does not establish whether a physical controller can scroll.
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 retries once by removing and reapplying `STEAM_GAME` on its own test
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.
The report records whether this recovery was needed. Registration, dashboard
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
+3 -2
View File
@@ -41,8 +41,9 @@ frontend host is browser-tested, but wearer-visible VR use remains unverified.
The panel also lists Decky plugins and can install, update, or remove compatible
plugins from the official store when the opt-in managed loader is running.
Each change asks for confirmation. The Frame VR shell does not show Decky's
Steam Deck Quick Access Menu, so Decky plugin pages, settings, routes, and
toasts are not available in this panel yet. The [compatibility table](decky-frame.md)
Steam Deck Quick Access Menu. Framey's separate panel can show compatible
plugin pages, settings, local routes, and local toasts, but these controls
still need a worn-headset check. The [compatibility table](decky-frame.md)
separates backend loading from VR use. Authors can use the
[Frame plugin and extension guide](frame-plugin-author.md).
+3 -3
View File
@@ -13,7 +13,7 @@ count. So a build reaches people only when you publish it, after testing it.
```
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
attaches everything to a **draft** release for that tag. After all eight
attaches everything to a **draft** release for that tag. After all ten
installers upload, the workflow also attaches `SHA256SUMS` built from
GitHub's SHA-256 digests of those uploaded bytes. Nobody is
offered a draft. Editing a draft while installers are uploading can make
@@ -39,9 +39,9 @@ count. So a build reaches people only when you publish it, after testing it.
scripts/publish-release.sh v0.4.0
```
The script checks that the tag is on GitHub and that all eight installers
The script checks that the tag is on GitHub and that all ten installers
are attached, each with the SHA-256 digest GitHub records. It also checks
that the uploaded `SHA256SUMS` digest matches a fresh list of those eight
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
installer digests. It attaches
`update.json` (the version, the notes, the release page and each
installer's digest), then publishes the release and marks it latest. It
+7
View File
@@ -208,6 +208,13 @@ It resolves the checkout path independently of the current working directory.
`/path/to/checkout/scripts/frame-smoke.sh --help` is safe to run offline and
does not contact the headset.
Before reading headset status or changing titles, the smoke script atomically
acquires the shared `/tmp/frame-test.lock` with a unique owner token. It reads
SteamVR activity, proximity and battery while locked. An awake headset, unknown
activity, battery below 20%, or a busy lock stops the run without installing
anything. It releases only its own lock after cleanup. The JSON result includes
the read-only preflight for completed smoke runs.
It checks `properties.json` and the status, then installs, launches and
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
+6 -1
View File
@@ -4,7 +4,9 @@ This is a small Decky API v2 plugin for authors. Its panel uses the official
`@decky/api`, `@decky/ui`, and `@decky/rollup` packages. A toggle calls a Python
backend method, stores one boolean under `decky.DECKY_PLUGIN_SETTINGS_DIR`,
and reads it back when the panel opens. The backend rejects other keys and
values. The frontend also shows a local toast after a successful save. A
values. The frontend also shows a local toast with a React body after a
successful save. The toast stays inside the Framey panel; it is not a Steam
system notification. A
Framey settings tab exposes the same toggle, and a read-only quick action
fetches the saved value without changing it. These two fields are Framey
additions; Decky's own menu still uses the main panel content. The quick
@@ -12,6 +14,9 @@ action calls the backend, so it reads the persisted setting rather than a
browser cache. A missing, damaged, or old non-boolean value reads as the
caller's default;
the next save atomically replaces the sample's settings file.
The panel also guards its own read and write calls synchronously. If two
controller events arrive before React redraws a disabled control, the second
event is ignored; after the request settles, a new change can be saved.
The **About this setting** button opens a local detail page at
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
+13 -1
View File
@@ -30,7 +30,11 @@ function Content() {
const [enabled, setEnabled] = SP_REACT.useState(false);
const [busy, setBusy] = SP_REACT.useState(true);
const [message, setMessage] = SP_REACT.useState('Loading setting…');
const pending = SP_REACT.useRef(false);
async function refresh() {
if (pending.current)
return;
pending.current = true;
setBusy(true);
try {
const value = await getSetting('enabled', false);
@@ -41,22 +45,30 @@ function Content() {
setMessage(`Could not read setting: ${String(error)}`);
}
finally {
pending.current = false;
setBusy(false);
}
}
SP_REACT.useEffect(() => { void refresh(); }, []);
async function change(value) {
// Two controller events can arrive before React redraws the disabled toggle.
// Keep writes ordered so a late first reply cannot replace the final choice.
if (pending.current)
return;
pending.current = true;
setBusy(true);
try {
await setSetting('enabled', value);
setEnabled(value);
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
toaster.toast({ title: 'Framey sample', body: 'Setting saved' });
toaster.toast({ title: 'Framey sample',
body: SP_JSX.jsxs("span", { children: ["Setting saved: ", SP_JSX.jsx("strong", { children: value ? 'on' : 'off' })] }) });
}
catch (error) {
setMessage(`Could not save setting: ${String(error)}`);
}
finally {
pending.current = false;
setBusy(false);
}
}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return <PanelSection title=\"Sample detail page\">\n <PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>\n <PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => {\n routerHook.addRoute('/framey-sample/details/:topic?', Details);\n return Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the mainLine truncated
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useRef, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n const pending = useRef(false);\n\n async function refresh() {\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n // Two controller events can arrive before React redraws the disabled toggle.\n // Keep writes ordered so a late first reply cannot replace the final choice.\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample',\n body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return <PanelSection title=\"Sample detail page\">\n <PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>\n <PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back Line truncated
+12 -2
View File
@@ -1,6 +1,6 @@
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
import {useEffect, useState} from 'react';
import {useEffect, useRef, useState} from 'react';
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
const setSetting = callable<[key: string, value: boolean], boolean>('set_setting');
@@ -9,8 +9,11 @@ function Content() {
const [enabled, setEnabled] = useState(false);
const [busy, setBusy] = useState(true);
const [message, setMessage] = useState('Loading setting…');
const pending = useRef(false);
async function refresh() {
if (pending.current) return;
pending.current = true;
setBusy(true);
try {
const value = await getSetting('enabled', false);
@@ -19,6 +22,7 @@ function Content() {
} catch (error) {
setMessage(`Could not read setting: ${String(error)}`);
} finally {
pending.current = false;
setBusy(false);
}
}
@@ -26,15 +30,21 @@ function Content() {
useEffect(() => {void refresh();}, []);
async function change(value: boolean) {
// Two controller events can arrive before React redraws the disabled toggle.
// Keep writes ordered so a late first reply cannot replace the final choice.
if (pending.current) return;
pending.current = true;
setBusy(true);
try {
await setSetting('enabled', value);
setEnabled(value);
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
toaster.toast({title: 'Framey sample', body: 'Setting saved'});
toaster.toast({title: 'Framey sample',
body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});
} catch (error) {
setMessage(`Could not save setting: ${String(error)}`);
} finally {
pending.current = false;
setBusy(false);
}
}
+1 -1
View File
@@ -249,7 +249,7 @@ final class WebSocket {
var payload = Data(buffer[(start + off)..<(start + off + len)])
if masked {
let mask = [UInt8](buffer[(start + maskOff)..<(start + maskOff + 4)])
payload.withUnsafeMutableBytes { p in
payload.withUnsafeMutableBytes { (p: UnsafeMutableRawBufferPointer) in
for i in 0..<len { p[i] ^= mask[i & 3] }
}
}
+4 -2
View File
@@ -1,11 +1,13 @@
#!/bin/sh
# Builds the Mac streaming agent into mac/bin/frame-mac-view (arm64, macOS 14+).
# Builds the Mac streaming agent into mac/bin/frame-mac-view (arm64 or x86_64, macOS 14+).
# Frame Control runs it for "Mac in the headset"; the Electron app bundles it.
set -eu
here=$(cd "$(dirname "$0")" && pwd)
out=${1:-$here/../bin/frame-mac-view}
arch=${FRAME_MAC_ARCH:-$(uname -m)}
case "$arch" in arm64|x86_64) ;; *) echo "unsupported Mac architecture: $arch" >&2; exit 1 ;; esac
mkdir -p "$(dirname "$out")"
xcrun swiftc -O -swift-version 5 -target arm64-apple-macos14.0 \
xcrun swiftc -O -swift-version 5 -target "$arch-apple-macos14.0" \
-import-objc-header "$here/Sources/CGVirtualDisplay.h" \
-o "$out" "$here"/Sources/*.swift
echo "built $out"
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Serve exact official frontends to a local Chromium audit, with all actions denied.
Open the printed loopback URL with T3's collaborative browser. Run `runAudit()`
in the page, then inspect `results` or download its JSON. This is initialization
evidence only. No official Python/native backend is imported or executed.
"""
import argparse
import hashlib
import io
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
import sys
from unittest.mock import patch
import zipfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT/'ui'))
import framey_panel
PRELUDE = '''<script>
const report=(kind,message)=>parent.postMessage({type:'audit:log',kind,message:String(message)},'*');
addEventListener('error',event=>report('error',event.message));
addEventListener('unhandledrejection',event=>report('rejection',event.reason?.stack||event.reason));
for(const key of ['error','warn']){const original=console[key];console[key]=(...args)=>{
report(key,args.map(String).join(' '));original(...args);};}
window.fetch=()=>Promise.reject(Error('Audit blocks plugin network'));
window.WebSocket=class{constructor(){throw Error('Audit blocks WebSocket');}};
</script>'''
PAGE = '''<!doctype html><meta charset=utf-8><title>Framey catalog frontend audit</title>
<h1>Framey catalog frontend audit</h1>
<p>Exact archived frontend code runs in an opaque iframe. All backend calls and network requests are denied.
Ready means initializer completion, not useful plugin behavior. No plugin backend executes.</p>
<button id=run>Run catalog</button> <button id=download>Download observations</button><p id=progress></p>
<iframe id=panel sandbox="allow-scripts" style="width:1100px;height:740px"></iframe>
<script>
const plugins=CATALOG_DATA, librarySnapshot=LIBRARY_DATA;
window.results=[];let active=null,running=false;
addEventListener('message',event=>{
if(event.source!==panel.contentWindow||!active)return;
const data=event.data;
if(data.type==='audit:log'&&active.logs.length<8)active.logs.push({kind:data.kind,message:data.message.split('\\n')[0].slice(0,400)});
if(data.type==='framey:error')active.error=String(data.message).slice(0,500);
if(data.type==='framey:ready')active.ready=true;
if(data.type==='framey:events')event.source.postMessage({type:'framey:reply',id:data.id,result:{events:[],cursor:0}},'*');
if(data.type==='framey:steam')event.source.postMessage({type:'framey:reply',id:data.id,
...(data.method==='GetLibrarySnapshot'&&librarySnapshot?
{result:librarySnapshot}:{error:'Read-only compatibility audit: live Steam API blocked'})},'*');
if(data.type==='framey:call'){
if(!active.methods.includes(data.method))active.methods.push(data.method);
event.source.postMessage({type:'framey:reply',id:data.id,error:'Read-only compatibility audit: backend method blocked'},'*');}
if(data.type==='framey:cache:init')event.source.postMessage({type:'framey:cache:seed',session:data.session,entries:{}},'*');
});
window.runAudit=async(ids)=>{
if(running)throw Error('Audit already running');running=true;run.disabled=true;
try{for(const plugin of plugins.filter(plugin=>!ids||ids.includes(plugin.id))){
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],
library:librarySnapshot?'snapshot replay':'unavailable'};
progress.textContent=plugin.id+' '+plugin.name;panel.src='/panel/'+plugin.id;
await new Promise(resolve=>setTimeout(resolve,1200));results.push(active);active=null;
panel.src='about:blank';await new Promise(resolve=>setTimeout(resolve,30));
}}finally{running=false;run.disabled=false;progress.textContent='Done: '+results.length;}
return results;
};
run.onclick=()=>runAudit();
download.onclick=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify(results,null,2)],{type:'application/json'}));
const anchor=document.createElement('a');anchor.href=url;anchor.download='framey-browser-audit.json';anchor.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
</script>'''
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cache', type=Path, required=True)
parser.add_argument('--catalog', type=Path, default=ROOT/'ui/framey-compatibility.json')
parser.add_argument('--port', type=int, default=18765)
parser.add_argument('--library-snapshot', type=Path,
help='Optional detached Steam library JSON; replay only, not live Steam/backend evidence')
args = parser.parse_args()
plugins = json.loads(args.catalog.read_text())['plugins']
records = {p['id']: p for p in plugins}
library = None
if args.library_snapshot:
raw = args.library_snapshot.read_bytes()
if len(raw) > 2*1024*1024:
parser.error('library snapshot exceeds 2 MiB')
library = json.loads(raw)
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
path = self.path.split('?')[0]
content_type = 'text/html; charset=utf-8'
module_asset = False
if path == '/':
# Only names/IDs are needed by the UI; never embed raw executable source.
catalog = json.dumps([{'id': p['id'], 'name': p['name']} for p in plugins]).replace('<', '\\u003c')
body = PAGE.replace('CATALOG_DATA', catalog).replace(
'LIBRARY_DATA', json.dumps(library).replace('<', '\\u003c')).encode()
elif path.startswith('/panel/'):
try:
record = records[int(path.split('/')[-1])]
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
if hashlib.sha256(data).hexdigest() != record['sha256']:
raise ValueError('Archive hash mismatch')
with zipfile.ZipFile(io.BytesIO(data)) as archive:
bundle = archive.read(record['folder']+'/dist/index.js')
with patch.object(framey_panel, 'local_bundle', return_value=bundle):
body = framey_panel.panel(record['id']).replace(
b'<script id="framey-bundle"', PRELUDE.encode()+b'<script id="framey-bundle"')
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
self.send_error(404, 'Verified frontend archive unavailable')
return
elif path.startswith('/framey/module/'):
try:
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
if not framey_panel.MODULE_PATH.fullmatch(name):
raise ValueError('Invalid module path')
record = records[int(plugin_id)]
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
if hashlib.sha256(data).hexdigest() != record['sha256']:
raise ValueError('Archive hash mismatch')
with zipfile.ZipFile(io.BytesIO(data)) as archive:
body = archive.read(record['folder']+'/dist/'+name)
content_type = 'text/javascript; charset=utf-8'
module_asset = True
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
self.send_error(404, 'Verified frontend module unavailable')
return
else:
files = {'/framey-runtime/react.js': 'ui/vendor/react-18.3.1.min.js',
'/framey-runtime/react-dom.js': 'ui/vendor/react-dom-18.3.1.min.js',
'/framey-runtime/host.js': 'ui/framey-host.js'}
if path not in files:
self.send_error(404)
return
body = (ROOT/files[path]).read_bytes()
content_type = 'text/javascript'
self.send_response(200)
self.send_header('Content-Type', content_type)
if module_asset:
self.send_header('Access-Control-Allow-Origin', 'null')
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline' blob:; style-src 'unsafe-inline'; img-src data:; frame-src 'self'; connect-src 'none'")
self.end_headers()
self.wfile.write(body)
print(f'Frontend audit: http://127.0.0.1:{args.port}', flush=True)
ThreadingHTTPServer(('127.0.0.1', args.port), Handler).serve_forever()
if __name__ == '__main__':
main()
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Audit a verified catalog cache in a private disposable fake-Frame project."""
import argparse
import json
from pathlib import Path
import subprocess
import tempfile
import uuid
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--cache', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
root = Path(__file__).resolve().parents[1]
cache = args.cache.resolve()
output = args.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
records = json.loads((root/'ui/framey-compatibility.json').read_text())['plugins']
if not all((cache/(p['sha256']+'.zip')).is_file() for p in records):
parser.error('cache must contain all exact catalog archives; run framey-audit-store.py first')
# Unique project avoids tearing down another job's fake Frame or its volumes.
project = 'framey-catalog-'+uuid.uuid4().hex[:10]
subprocess.run(['docker', 'build', '-q', '-t', 'fakeframe-frame', '-f',
str(root/'tests/fakeframe/Containerfile'), str(root/'tests/fakeframe')], check=True)
subprocess.run(['docker', 'build', '-q', '-t', 'fakeframe-host', '-f',
str(root/'tests/fakeframe/host.Containerfile'), str(root/'tests/fakeframe')], check=True)
with tempfile.TemporaryDirectory(prefix='framey-catalog-') as temp:
override = Path(temp)/'compose.json'
override.write_text(json.dumps({'services': {'host': {
'volumes': [f'{cache}:/audit-cache:ro', f'{output.parent}:/audit-output'],
'environment': {'FRAMEY_AUDIT_CACHE': '/audit-cache',
'FRAMEY_AUDIT_OUTPUT': '/audit-output/'+output.name}}}}))
compose = ['docker', 'compose', '-p', project, '-f', str(root/'tests/fakeframe/compose.yaml'), '-f', str(override)]
try:
subprocess.run(compose+['up', '-d', '--wait'], check=True)
subprocess.run(compose+['exec', '-T', '-w', '/repo/tests/e2e', 'host', 'python3', '-m',
'unittest', '-v', 'test_framey_catalog.CatalogAudit'], check=True)
finally:
subprocess.run(compose+['down', '-v', '--remove-orphans'], check=True)
if __name__ == '__main__':
main()
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Inspect official Decky archives without importing or executing plugin code.
Archives stay in a caller-selected cache. SHA-256 is checked before inspection.
The resulting observations are static evidence, not compatibility guarantees.
"""
import argparse
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
import hashlib
import io
import json
from pathlib import Path
import re
import urllib.request
import zipfile
STORE = 'https://plugins.deckbrew.xyz/plugins'
CDN = 'https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/'
INIT = '__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit'
def fetch(url, limit):
request = urllib.request.Request(url, headers={'User-Agent': 'Frame-Control-compatibility-audit'})
with urllib.request.urlopen(request, timeout=60) as response:
if int(response.headers.get('Content-Length', '0')) > limit:
raise ValueError('download exceeds audit size limit')
data = response.read(limit + 1)
if len(data) > limit:
raise ValueError('download exceeds audit size limit')
return data
def inspect(item, cache):
version = item['versions'][0]
digest = version['hash']
result = {'id': item['id'], 'name': item['name'], 'version': version['name'],
'sha256': digest, 'tags': item.get('tags', [])}
try:
path = cache / (digest + '.zip')
data = path.read_bytes() if path.exists() else fetch(CDN + digest + '.zip', 200 * 1024 * 1024)
if hashlib.sha256(data).hexdigest() != digest:
raise ValueError('official archive SHA-256 mismatch')
path.write_bytes(data)
with zipfile.ZipFile(io.BytesIO(data)) as archive:
entries = archive.infolist()
if len(entries) > 6000 or sum(e.file_size for e in entries) > 500 * 1024 * 1024:
raise ValueError('archive exceeds audit size limit')
manifests = [e.filename for e in entries if e.filename.count('/') == 1 and e.filename.endswith('/plugin.json')]
if len(manifests) != 1:
raise ValueError('expected one top-level plugin.json')
manifest = json.loads(archive.read(manifests[0]))
folder = manifests[0].split('/')[0]
frontend_path = folder + '/dist/index.js'
frontend = archive.read(frontend_path).decode('utf-8', errors='replace') if frontend_path in archive.namelist() else ''
result.update(folder=folder, archiveBytes=len(data), api=manifest.get('api_version', 'legacy'), flags=manifest.get('flags', []),
modernBridge=INIT in frontend, frontend=bool(frontend),
frontendApi=('legacy' if re.match(r'\s*\(function\s*\(', frontend) else
next(iter(re.findall(r'API_VERSION\s*=\s*(\d+)', frontend)), 'unknown')))
python_files, native, sources = [], [], []
for entry in entries:
if entry.is_dir():
continue
with archive.open(entry) as stream:
header = stream.read(20)
if header[:4] == b'\x7fELF':
machine = int.from_bytes(header[18:20], 'little' if header[5] == 1 else 'big')
native.append({'file': entry.filename, 'elfMachine': machine,
'arch': {62: 'x86_64', 183: 'aarch64', 3: 'x86', 40: 'arm'}.get(machine, str(machine))})
elif header[:2] == b'MZ':
native.append({'file': entry.filename, 'arch': 'Windows'})
if entry.filename.endswith('.py') and entry.file_size < 2 * 1024 * 1024:
python_files.append(entry.filename)
sources.append(archive.read(entry).decode('utf-8', errors='replace'))
backend = '\n'.join(sources)
metadata = json.dumps(manifest)
for suffix in ('/package.json', '/README.md'):
name = folder + suffix
if name in archive.namelist() and archive.getinfo(name).file_size < 1024 * 1024:
metadata += '\n' + archive.read(name).decode('utf-8', errors='replace')
repositories = set(re.findall(r'https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', metadata + '\n' + backend))
repositories.update('https://github.com/' + match for match in re.findall(
r'https://raw\.githubusercontent\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)', metadata))
result.update(pythonFiles=python_files, native=native,
steamClient=sorted(set(re.findall(r'SteamClient\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)', frontend))),
dfl=sorted(set(re.findall(r'(?:DFL|__DFL__|deckyFrontendLib)\.([A-Za-z_$][\w$]*)', frontend))),
globals=sorted(set(re.findall(r'\b(?:Router|routerHook|toaster|SteamUIStore|appStore|appDetailsStore|collectionStore|achievementStore|window\.SP_[A-Z_]+|DeckyPluginLoader|webpackChunksteamui)\b', frontend))),
uiSurfaces=[label for label, pattern in (
('local/Steam routes', r'routerHook|addRoute|removeRoute'),
('toasts', r'\btoaster\b|\.toast\('),
('QAM/global tabs', r'quickAccessMenu|addGlobalComponent|addTab|QAM'),
('Steam page patches/module lookup', r'afterPatch|beforePatch|findModule|createModuleMapping'),
('Steam stores', r'\b\w+Store\b'),
) if re.search(pattern, frontend)],
hardwareSignals=sorted(set(re.findall(r'(?:/sys/[^\s\x22\x27)]+|/dev/(?:hidraw|input|i2c|drm)[\w/*-]*|\b(?:ryzenadj|intel_pstate|amdgpu|hwmon|gamescope|pactl|bluetoothctl|flatpak|systemctl|sudo|x86_64|amd64)\b)', backend))),
sourceURLs=sorted(repositories)[:20])
except Exception as failure:
result['auditError'] = str(failure)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--catalog', type=Path, help='saved official catalog (otherwise fetch current)')
parser.add_argument('--cache', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
records = json.loads(args.catalog.read_bytes() if args.catalog else fetch(STORE, 4 * 1024 * 1024))
records = [r for r in records if r.get('visible') is not False and r.get('versions')]
args.cache.mkdir(parents=True, exist_ok=True)
with ThreadPoolExecutor(max_workers=6) as workers:
plugins = list(workers.map(lambda item: inspect(item, args.cache), records))
args.output.write_text(json.dumps({'checkedAtUtc': datetime.now(timezone.utc).isoformat(),
'source': STORE, 'method': 'SHA-256-verified official archives; static inspection only; plugin code was not executed.',
'plugins': plugins}, indent=2) + '\n')
print(f"Audited {len(plugins)} official plugins; {sum('auditError' in p for p in plugins)} errors. {args.output}")
if __name__ == '__main__':
main()
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Render the documentation from the same digest-scoped data used by the store."""
import argparse
from collections import Counter
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def cell(value):
if isinstance(value, list):
value = ', '.join(str(v) for v in value) or 'none detected'
return str(value).replace('|', '&#124;').replace('\n', ' ').replace('<', '&lt;')
def render(data):
counts = Counter(p['status'] for p in data['plugins'])
lines = ['# Official Decky store compatibility with Framey', '',
f"Snapshot: {data['checkedAtUtc']}; {len(data['plugins'])} current visible official plugins from {data['source']}.", '',
'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.', '',
f"**Works: {counts['works']} · Partial: {counts['partial']} · Broken: {counts['broken']} · N/A: {counts['na']}.**", '',
'- **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', '',
f"All exact archives were hash-checked and statically inspected. The local Chromium host used Framey commit `{data['hostCommit']}`, 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. '
'An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. Volume Boost stays partial: default-sink reads matched, but real sink writes were denied. Audible output, Bluetooth playback and worn VR remain unverified. '
'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 |',
'| --- | --- | --- | --- | --- |']
for p in data['plugins']:
lines.append('| '+ ' | '.join(map(cell, [f"{p['id']}: {p['name']} {p['version']}",
f"{p['api']} / {p['frontendApi']}", 'N/A' if p['status']=='na' else p['status'], p['reason'], p['verification']]))+' |')
lines += ['', '## 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 |',
'| --- | --- | --- | --- | --- | --- |']
for p in data['plugins']:
native = sorted({n['arch'] for n in p['native']})
lines.append('| '+' | '.join(map(cell, [p['name'],p['steamClient'],p['dfl'],p['globals']+p.get('uiSurfaces',[]),
p['backend']+('; '+', '.join(native) if native else ''),p['hardware']]))+' |')
lines += ['', '## 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 |', '| --- | --- | --- |']
for p in data['plugins']:
if p['native']:
binaries = [n['file'].removeprefix(p['folder']+'/') for n in p['native']]
if len(binaries)>5:
binaries = binaries[:5]+[f"… {len(binaries)} total; full list in JSON"]
lines.append('| '+' | '.join(map(cell,[p['name'],binaries,p['nativePort']]))+' |')
lines += ['', '## 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.', '']
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--check', action='store_true')
args = parser.parse_args()
output = ROOT/'docs/framey-compatibility.md'
expected = render(json.loads((ROOT/'ui/framey-compatibility.json').read_text(encoding='utf-8')))
if args.check:
if not output.exists() or output.read_text(encoding='utf-8') != expected:
raise SystemExit('Compatibility documentation differs from canonical store data')
else:
output.write_text(expected, encoding='utf-8')
if __name__ == '__main__':
main()
+50 -3
View File
@@ -66,12 +66,16 @@ finally: lib.VR_ShutdownInternal()
battery=[]
for p in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
if (p.parent/'type').read_text().strip()=='Battery': battery.append(int(p.read_text()))
proximity=[]
for p in pathlib.Path('/sys/bus/iio/devices').glob('iio:device*/in_proximity_raw'):
try: proximity.append(float(p.read_text().strip()))
except (OSError,ValueError): pass
def cmd(*args):
p=subprocess.run(args,capture_output=True,text=True,timeout=8)
if p.returncode: raise RuntimeError(p.stderr or p.stdout)
return p.stdout.strip()
vr='/opt/steamvr/bin/linuxarm64/vrcmd'
print(json.dumps({'battery':battery,'dashboardVisible':dashboard,'stats':json.loads(cmd(vr,'--stats')),
print(json.dumps({'battery':battery,'proximity':proximity,'dashboardVisible':dashboard,'stats':json.loads(cmd(vr,'--stats')),
'overlays':cmd(vr,'--overlays'),
'pause':cmd(vr,'--settings-bool','power.pauseCompositorOnStandby'),
'timeout':cmd(vr,'--settings-float','power.turnOffScreensTimeout')}))
@@ -95,6 +99,33 @@ finally: vr.close()
'''
def wait_overlay(remote, attempts=8):
"""Give SteamVR a bounded moment to expose its newly registered panel."""
for attempt in range(attempts):
snapshot = remote.python(diagnostic_source())
if snapshot['state']['visible']:
return snapshot
if attempt < attempts - 1:
time.sleep(1)
return snapshot
HIDDEN_OVERLAY_DIAGNOSTIC = '''
import json,pathlib,subprocess
key='valve.steam.desktopgame.2001947813'
p=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--overlays'],
capture_output=True,text=True,timeout=8)
lines=[line.strip() for line in p.stdout.splitlines() if "'"+key+"'" in line]
service=subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],
capture_output=True,text=True,timeout=8)
state=pathlib.Path.home()/'.local/share/frame-control/headset/state.json'
try: window=json.loads(state.read_text()).get('window')
except (OSError,ValueError): window=None
print(json.dumps({'overlayLines':lines[:2], 'overlayCommandExit':p.returncode,
'serviceActive':service.stdout.strip()=='active', 'windowRegistered':bool(window)}))
'''
def debug_bundle(original, profile):
"""Only the test-owned browser gets CDP and a disposable profile."""
out = io.BytesIO()
@@ -151,6 +182,11 @@ def main(argv=None):
pause, timeout = settings(before)
if not before['battery'] or min(before['battery']) < 20:
raise RuntimeError('Battery unknown or below 20%')
if args.wake or args.exercise:
stats = before.get('stats')
if (not isinstance(stats, list) or not stats or
not isinstance(stats[0], dict) or stats[0].get('activity_level') != 3):
raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched')
if args.wake or args.exercise:
# Independent systemd timer restores power even if the controller is
# killed or SSH disappears. It retains the test lock for recovery.
@@ -202,7 +238,7 @@ print(json.dumps({'created':True}))
remote(VRCMD + ' --handlewakeup')
remote(VRCMD + ' --showdashboard ' + KEY)
time.sleep(2)
report['overlay'] = remote.python(diagnostic_source())
report['overlay'] = wait_overlay(remote) if args.wake or args.exercise else remote.python(diagnostic_source())
if args.exercise and not report['overlay']['state']['visible']:
report['retaggedTestWindow'] = True
remote.python('''
@@ -217,7 +253,18 @@ subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--showdashboard','valve.ste
time.sleep(2)
print('{}')
''')
report['overlay'] = remote.python(diagnostic_source())
report['overlay'] = wait_overlay(remote)
if args.exercise and not report['overlay']['state']['visible']:
try:
report['hiddenOverlay'] = remote.python(HIDDEN_OVERLAY_DIAGNOSTIC)
except Exception as exc:
report['hiddenOverlayError'] = str(exc)
raise RuntimeError('Frame Control overlay stayed hidden after focus retry; no input sent')
if args.exercise:
lines = remote(VRCMD + ' --overlays').splitlines()
report['testOverlayListing'] = [line.strip() for index, line in enumerate(lines)
if line.startswith("'" + KEY + "'") or
(index and lines[index - 1].startswith("'" + KEY + "'"))]
if args.wake or args.exercise:
shot = remote.python((ROOT/'ui/frame_vrshot.py').read_text(), timeout=20)['path']
try:
+4 -2
View File
@@ -93,9 +93,11 @@ class Overlay:
event = Event(kind, 0xFFFFFFFF, 0, Data(mouse=Mouse(x, y, button)))
C.CFUNCTYPE(None, C.c_uint64, C.POINTER(Event))(view[2])(self.handle, C.byref(event))
def post_scroll(self, xdelta, ydelta):
def post_scroll(self, xdelta, ydelta, kind=309):
if kind not in (305, 309):
raise ValueError('Only OpenVR discrete and smooth scroll events are allowed')
view = self.interface(b'FnTable:IVROverlayView_003')
event = Event(309, 0xFFFFFFFF, 0, Data(scroll=Scroll(xdelta, ydelta, 0, 1)))
event = Event(kind, 0xFFFFFFFF, 0, Data(scroll=Scroll(xdelta, ydelta, 0, 1)))
C.CFUNCTYPE(None, C.c_uint64, C.POINTER(Event))(view[2])(self.handle, C.byref(event))
def image_hash(self):
+37 -6
View File
@@ -46,7 +46,7 @@ def exercise(profile, plugin_id, folder):
def text(): return panel.eval('document.body.innerText')
def click(selector):
def point(selector):
# Account for CSS pixels, iframe position, border, HiDPI scaling, and
# OpenVR's bottom-left mouse origin; read geometry after scrolling.
panel.eval(f'document.querySelector({json.dumps(selector)}).scrollIntoView({{block:"center"}})')
@@ -58,6 +58,10 @@ def exercise(profile, plugin_id, folder):
y = sy - (outer['y'] + pos['y']) * sy / outer['h']
if not 0 <= x <= sx or not 0 <= y <= sy:
raise RuntimeError('Control is outside the visible viewport')
return x, y
def click(selector):
x, y = point(selector)
count = panel.eval('__vrClickCount')
vr.post_mouse(300, x, y)
vr.post_mouse(301, x, y, 1)
@@ -113,12 +117,39 @@ def exercise(profile, plugin_id, folder):
# CDP only types text; OpenVR supplies pointer focus into the field.
command(parent, 'Input.insertText', {'text':'framey-ci'})
result['textValue'] = wait(lambda:panel.eval('document.querySelector("#vr-controls input[type=text]").value'), lambda v:v == 'framey-ci')
panel.eval('window.scrollTo(0,0)')
result['scrollBefore'] = panel.eval('scrollY')
vr.post_scroll(0,-3)
time.sleep(.5)
result['scrollAfter'] = panel.eval('scrollY')
# The sample alone may be shorter than its iframe. Use an owned,
# visibly scrollable region so a zero offset is a meaningful result.
panel.eval('''(()=>{
const box=document.createElement('div');box.id='vr-scroll-probe';
box.style.cssText='height:160px;overflow-y:scroll;border:2px solid #72baf0';
box.innerHTML='<div style="height:700px;padding:8px">Scroll probe</div>';
document.body.append(box);
window.__vrWheelEvents=[];
document.addEventListener('wheel',e=>__vrWheelEvents.push({trusted:e.isTrusted,
deltaY:e.deltaY,target:e.target?.id||e.target?.tagName}),{capture:true,passive:true});
return true;
})()''')
result['scrollGeometry'] = panel.eval('(()=>{let e=document.getElementById("vr-scroll-probe");return {height:e.clientHeight,content:e.scrollHeight}})()')
if result['scrollGeometry']['content'] <= result['scrollGeometry']['height']:
raise RuntimeError('Scroll diagnostic did not create overflow')
x, y = point('#vr-scroll-probe')
panel.eval('window.__vrScrollPointer=[];document.addEventListener("pointermove",e=>__vrScrollPointer.push({trusted:e.isTrusted,target:e.target?.id||e.target?.tagName}),{capture:true});true')
vr.post_mouse(300, x, y)
time.sleep(.2)
result['scrollPointer'] = panel.eval('__vrScrollPointer')
result['scrollBefore'] = panel.eval('document.getElementById("vr-scroll-probe").scrollTop')
result['scrollAttempts'] = []
for kind in (309, 305):
vr.post_scroll(0,-3,kind)
time.sleep(.5)
offset = panel.eval('document.getElementById("vr-scroll-probe").scrollTop')
result['scrollAttempts'].append({'eventType':kind,'offset':offset})
if offset != result['scrollBefore']:
break
result['scrollAfter'] = result['scrollAttempts'][-1]['offset']
result['scrollEvents'] = panel.eval('__vrWheelEvents')
result['nativeScrollMoved'] = result['scrollAfter'] != result['scrollBefore']
panel.eval('document.getElementById("vr-scroll-probe").remove()')
panel.eval('document.getElementById("vr-controls").remove()')
# Scroll saved status into the visible iframe for compositor evidence.
panel.eval('document.querySelector("[role=status]").scrollIntoView({block:"center"})')
+3 -3
View File
@@ -15,7 +15,7 @@ dry=""
[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
tag="${1:?usage: $0 [--dry-run] vX.Y.Z}"
repo=saphid/frame-control
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-mac-x64.dmg Frame-Control-mac-x64.zip Frame-Control-Setup-x64.exe
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
page="https://github.com/$repo/releases/tag/$tag"
@@ -70,7 +70,7 @@ print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent
done
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
# The workflow uploads this only after all eight platform builds complete.
# The workflow uploads this only after all ten platform builds complete.
# Regenerate it from GitHub's installer digests and compare its own GitHub
# digest, so a stale or partial checksum file cannot pass the release gate.
python3 "$(dirname "$0")/release-checksums.py" < "$tmp/release.json" > "$tmp/SHA256SUMS"
@@ -79,7 +79,7 @@ checksum_digest=$(python3 -c 'import json,sys
d=json.load(open(sys.argv[1])); matches=[a.get("digest") for a in d["assets"] if a["name"]=="SHA256SUMS"]
print(matches[0] if len(matches)==1 else "absent")' "$tmp/release.json")
[ "$checksum_digest" = "sha256:$expected_sum" ] || {
echo "not publishing: SHA256SUMS is absent or does not match the eight installer digests" >&2; exit 1;
echo "not publishing: SHA256SUMS is absent or does not match the ten installer digests" >&2; exit 1;
}
echo "ok SHA256SUMS"
+2 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Print SHA256SUMS for the eight release installers from GitHub asset metadata.
"""Print SHA256SUMS for the ten release installers from GitHub asset metadata.
GitHub computes each asset digest from its uploaded bytes. The workflow runs
this only after all platform builds finish; publish-release.sh checks that the
@@ -13,6 +13,7 @@ import sys
INSTALLERS = (
"Frame-Control-mac-arm64.dmg",
"Frame-Control-mac-arm64.zip",
"Frame-Control-mac-x64.dmg", "Frame-Control-mac-x64.zip",
"Frame-Control-Setup-x64.exe",
"Frame-Control-win-x64.zip",
"Frame-Control-linux-x86_64.AppImage",
+7
View File
@@ -177,6 +177,13 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
if plugin['id'] == 135:
chunk = 'chunks/pluginApi-B8sV7970.js'
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
expected_chunk = bundle.read(plugin['folder']+'/dist/'+chunk)
self.assertEqual(
ssh(f'sha256sum {installed}/dist/{chunk}').split()[0],
hashlib.sha256(expected_chunk).hexdigest())
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':plugin['id'],'acknowledged':True}))
self.assertFalse(job.get('error'), job)
+120
View File
@@ -0,0 +1,120 @@
"""Optional full-catalog archive/transport audit; no official backend executes."""
import hashlib
import io
import json
import os
from pathlib import Path
import shlex
import subprocess
import threading
import unittest
import zipfile
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from harness import FrameTestCase, ROOT, Server, finished, ok, require, ssh
import frame_decky_store
require()
@unittest.skipUnless(os.environ.get('FRAMEY_AUDIT_CACHE'), 'optional full catalog audit cache not mounted')
class CatalogAudit(FrameTestCase):
def backend(self):
# Only the fake service/CEF stub executes. Official Python/native code
# copied by the installer is never imported or launched by this harness.
source = '''import json,pathlib
h=pathlib.Path.home();b=h/'.local/share/frame-control/decky/.frame-control';b.mkdir(parents=True,exist_ok=True)
(b/'receipt.json').write_text(json.dumps({'managedBy':'Frame Control','version':'v3.3.0'}))
(b.parent/'plugins').mkdir(exist_ok=True)
u=h/'.config/systemd/user/frame-control-decky.service';u.parent.mkdir(parents=True,exist_ok=True);u.write_text('[Service]')'''
ssh('python3 -c '+shlex.quote(source))
ssh('mkdir -p ~/.local/share/frame-control/headset/bundle/ui')
result = subprocess.run(['ssh', 'frame', 'cat > ~/.local/share/frame-control/headset/bundle/ui/frame_steam.py'],
input=(ROOT/'ui/frame_steam.py').read_bytes(), capture_output=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stderr)
@classmethod
def setUpClass(cls):
records = json.loads((ROOT/'ui/framey-compatibility.json').read_text())['plugins']
cache = Path(os.environ['FRAMEY_AUDIT_CACHE'])
class Fixture(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
if self.path == '/plugins':
body = json.dumps([{'id': p['id'], 'name': p['name'], 'tags': p['tags'],
'versions': [{'name': p['version'], 'hash': p['sha256']}]}
for p in records]).encode()
else:
path = cache / self.path.rsplit('/', 1)[-1]
if path.suffix != '.zip' or path.name not in {p['sha256']+'.zip' for p in records}:
self.send_error(404)
return
body = path.read_bytes()
self.send_response(200)
self.end_headers()
try:
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError):
pass # Installer stops reading at its archive size limit.
fixture = ThreadingHTTPServer(('127.0.0.1', 0), Fixture)
cls.addClassCleanup(fixture.server_close)
cls.addClassCleanup(fixture.shutdown)
threading.Thread(target=fixture.serve_forever, daemon=True).start()
old = {k: os.environ.get(k) for k in ('FRAMEY_STORE_URL', 'FRAMEY_CDN_URL')}
def restore():
Server.stop()
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
cls.addClassCleanup(restore)
os.environ['FRAMEY_STORE_URL'] = f'http://127.0.0.1:{fixture.server_port}/plugins'
os.environ['FRAMEY_CDN_URL'] = f'http://127.0.0.1:{fixture.server_port}/versions/'
Server.stop()
super().setUpClass()
def test_all_current_official_archives(self):
self.backend()
records = json.loads((ROOT/'ui/framey-compatibility.json').read_text())['plugins']
cache = Path(os.environ['FRAMEY_AUDIT_CACHE'])
catalog = {p['id']: p for p in ok('GET', '/api/decky/store')['plugins']}
self.assertEqual(set(catalog), {p['id'] for p in records})
results = []
for plugin in records:
with self.subTest(plugin=plugin['name']):
data = (cache/(plugin['sha256']+'.zip')).read_bytes()
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
validation = None
try:
folder = frame_decky_store.archive_info(data, plugin['sha256'], plugin['name'])
except ValueError as error:
validation = str(error)
blocked = catalog[plugin['id']]['root'] or plugin['blockInstall'] or validation
job = finished(ok('POST', '/api/decky/plugin', {'action': 'install', 'id': plugin['id'],
'sha256': plugin['sha256'], 'acknowledged': True}))
record = {'id': plugin['id'], 'sha256': plugin['sha256'],
'archiveValidation': validation or 'accepted', 'result': 'blocked' if blocked else 'installed-removed'}
if blocked:
self.assertTrue(job.get('error'), job)
record['reason'] = job['error']
self.assertFalse(any(p['storeId'] == plugin['id'] for p in ok('GET', '/api/decky')['plugins']))
else:
self.assertFalse(job.get('error'), job)
installed = f'~/.local/share/frame-control/decky/plugins/{folder}'
with zipfile.ZipFile(io.BytesIO(data)) as archive:
entry = folder+'/dist/index.js'
if entry in archive.namelist():
expected = hashlib.sha256(archive.read(entry)).hexdigest()
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
found = next(p for p in ok('GET', '/api/decky')['plugins'] if p['storeId'] == plugin['id'])
self.assertEqual((found['version'], found['sha256']), (plugin['version'], plugin['sha256']))
removed = finished(ok('POST', '/api/decky/plugin', {'action': 'remove', 'id': plugin['id'], 'acknowledged': True}))
self.assertFalse(removed.get('error'), removed)
self.assertFalse(any(p['storeId'] == plugin['id'] for p in ok('GET', '/api/decky')['plugins']))
results.append(record)
Path(os.environ.get('FRAMEY_AUDIT_OUTPUT', '/tmp/framey-catalog-result.json')).write_text(json.dumps(results, indent=2)+'\n')
Binary file not shown.
+10 -1
View File
@@ -7,20 +7,26 @@ from a local fixture so CI does not depend on the store being online.
| File | Official plugin | Version | Store SHA-256 |
| --- | --- | --- | --- |
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
| `18.zip` | Bluetooth | 3.1.1 | `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5` |
| `26.zip` | HLTB for Deck | 2.0.12 | `bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558` |
| `36.zip` | SteamGridDB | 1.8.1 | `e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0` |
| `135.zip` | Deck Shelves | 2.0.0 | `b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91` |
The matching source projects are
[CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader),
[MusicControl](https://github.com/nikurasu/MusicControl),
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
[Steam Status](https://github.com/bschelst/steamstatus-decky),
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner),
[Bluetooth](https://github.com/Outpox/Bluetooth), and
[HLTB for Deck](https://github.com/morwy/hltb-for-deck).
[HLTB for Deck](https://github.com/morwy/hltb-for-deck),
[SteamGridDB](https://github.com/SteamGridDB/decky-steamgriddb), and
[Deck Shelves](https://github.com/santojon/Deck-Shelves).
The test verifies the ZIP digest, archive policy, API v1/v2 manifest, installed
frontend bytes, managed store receipt, and removal. Separate local host tests
start Bluetooth's frontend and its initial panel without running effects or
@@ -29,3 +35,6 @@ module lookup. They do **not** run plugin Python backends, Bluetooth actions,
HLTB searches, or prove JavaScript panels render in the VR shell. The
Bluetooth result and limits are in
[the fixture evidence](../../../docs/evidence/framey-bluetooth-fixture-2026-10-25.md).
Deck Shelves' chunked frontend renders its settings in local Chromium with
backend/network calls denied. Its Steam Home shelves cannot be verified in the
isolated VR panel; no worn-headset test is claimed.
+2
View File
@@ -17,6 +17,8 @@ PLUGINS = (
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
{'id': 135, 'name': 'Deck Shelves', 'version': '2.0.0', 'api_version': 1,
'folder': 'Deck-Shelves', 'sha256': 'b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91'},
)
# This archive has a legacy IIFE frontend with no manifest API version.
+159 -6
View File
@@ -3,11 +3,12 @@ import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI;\n})();');
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI; window.__library=setLibrarySnapshot;\n})();');
assert.notEqual(testSource,source);
const states=[];
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
useState:value=>[value,next=>states.push(next)], useEffect(){},isValidElement:value=>!!value?.type};
useState:value=>[value,next=>states.push(next)], useRef:value=>({current:value}),
useEffect(){},isValidElement:value=>!!value?.type};
class Element {constructor(){this.children=[];this.textContent='';this.attributes={};}
append(...children){this.children.push(...children);}setAttribute(key,value){this.attributes[key]=value;}
remove(){this.removed=true;}}
@@ -20,7 +21,7 @@ const window={React,ReactDOM:{createRoot:node=>{
const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)};
const context=vm.createContext({window,document:{getElementById:id=>{
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()},
navigator:{language:'en'}, parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
navigator:{language:'en'}, parent,DOMException,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(testSource,context);
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
@@ -67,6 +68,24 @@ readyInput.props.onChange({target:{value:'New'}});assert.equal(entered,'New');
let speed=0;
const readySlider=nodes(window.DFL.SliderField({label:'Speed',value:2,onChange:v=>{speed=v;}}),'input')[0];
readySlider.props.onChange({target:{value:'4'}});assert.equal(speed,4);
// Official Volume Boost and Mixer request editableValue for precise volume entry.
const numericSlider=nodes(window.DFL.SliderField({label:'Volume',value:0.4,min:0,max:1,step:0.05,
editableValue:true,onChange:v=>{speed=v;}}),'input');
assert.equal(numericSlider[0].props.type,'range');
assert.equal(numericSlider[1].props.type,'number');
assert.equal(numericSlider[1].props['aria-label'],'Volume value');
assert.equal(numericSlider[1].props.step,0.05);
numericSlider[1].props.onChange({target:{value:'0.65',validity:{valid:true}}});
assert.equal(speed,0.65);
for(const value of ['', ' ', 'NaN', 'Infinity', '-0.05', '1.05'])
numericSlider[1].props.onChange({target:{value,validity:{valid:true}}});
numericSlider[1].props.onChange({target:{value:'0.63',validity:{valid:false}}});
assert.equal(speed,0.65,'Invalid drafts must not reach a backend');
const disabledNumber=nodes(window.DFL.SliderField({label:'Volume',value:40,editableValue:true,
disabled:true,onChange:()=>changed++}),'input')[1];
assert.equal(disabledNumber.props.disabled,true);
disabledNumber.props.onChange({target:{value:'50',validity:{valid:true}}});
assert.equal(changed,0);
let selected;
const readyDropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast',
onChange:option=>{selected=option;}}),'select')[0];
@@ -80,14 +99,33 @@ const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{typ
{label:'invalid'},...Array.from({length:9},(_,i)=>({label:`Action ${i}`,onClick:()=>{}}))]};
const tree=window.__surface({plugin});
const buttons=nodes(tree,'button');
assert.equal(buttons.length,9); // Panel, Settings, and seven valid actions inside the first eight entries.
assert.equal(buttons.length,10); // Panel, Settings, and eight valid actions after filtering.
assert.equal(nodes(tree,'nav')[0].props['aria-label'],'Plugin views');
assert.equal(nodes(tree,'section')[0].props['aria-label'],'Plugin quick actions');
assert.equal(nodes(tree,'header').length,1);
await buttons[2].props.onClick();
assert.equal(called,1);
assert.deepEqual(states.slice(-3),[0,'Refresh data…','Fresh',null].slice(-3));
assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav').length,1);
let actionCount=0, settleAction;
const guarded=window.__surface({plugin:{content:{type:'content'},frameyQuickActions:[{
label:'Save setting',onClick:()=>{actionCount++;return new Promise((resolve,reject)=>{
settleAction={resolve,reject};});}}]}});
const saveButton=nodes(guarded,'button')[0];
const firstSave=saveButton.props.onClick();
await saveButton.props.onClick(); // React has not rendered disabled state yet.
assert.equal(actionCount,1,'rapid second tap must not repeat a backend mutation');
settleAction.reject(Error('offline'));
await firstSave;
const retrySave=saveButton.props.onClick();
assert.equal(actionCount,2,'a failed action must release the synchronous lock');
settleAction.resolve('Saved');
await retrySave;
const settingsOnly=window.__surface({plugin:{settings:{type:'settings'}}});
assert.equal(nodes(settingsOnly,'nav').length,0);
assert.equal(nodes(settingsOnly,'settings').length,1);
const panelOnly=window.__surface({plugin:{content:{type:'content'}}});
assert.equal(nodes(panelOnly,'nav').length,0);
assert.equal(nodes(panelOnly,'content').length,1);
// API v2 backend calls remain request-scoped and errors return to the caller.
const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test');
@@ -99,6 +137,17 @@ listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:
assert.equal(await reply,'dark');
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
// These APIs use a separate, parameterless allowlisted read bridge.
for(const [namespace,method,result] of [['User','GetIPCountry','AU'],
['InstallFolder','GetInstallFolders',[{vecApps:[]}]],['System','GetSystemInfo',{sOSName:'Frame OS'}]]){
const pendingRead=window.SteamClient[namespace][method]();
const request=messages.at(-1);assert.equal(request.type,'framey:steam');assert.equal(request.method,method);
assert.equal(request.args.length,0);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:request.id,result}});
assert.equal(await pendingRead,result);
}
assert.equal(window.SteamClient.System.ShutdownPC,undefined);
const toast=api.toaster.toast({title:'Saved',body:'Mode updated',duration:4000});
const toastNode=elements.get('framey-toasts').children[0];
assert.equal(toastNode.attributes.role,'status');
@@ -136,7 +185,35 @@ const undo=api.routerHook.addRoute('/plugin/details',LocalRoute);
assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),true);
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
assert.equal(window.DFL.Navigation.Navigate('/decky/store'),false);
assert.equal(window.DFL.findModuleChild(()=>true),null);
// Bounded lookup uses DFL's module/export semantics, with only local exports.
const dfl=window.DFL;
const spinnerClasses=dfl.findClassModule(module=>!!module.SpinnerLoaderContainer);
assert.equal(spinnerClasses.ContainerBackground,'framey-loading');
assert.equal(dfl.findClass('framey/spinner','Medium'),'framey-medium');
assert.equal(dfl.findClassByName('LoadingStatus'),'framey-status');
assert.equal(dfl.unminifyClass('framey-loading'),'ContainerBackground');
assert.equal(dfl.findClassModule(module=>module.SteamOnlyClass),undefined);
assert.equal(dfl.findClass('steam/unknown','Medium'),undefined);
assert.equal(dfl.findClassByName('SteamOnlyClass'),undefined);
const details=dfl.findModuleDetailsByExport((value,name)=>name==='Spinner' && typeof value==='function');
assert.equal(details[0].Spinner,dfl.Spinner);
assert.equal(details[1],dfl.Spinner);
assert.equal(details[2],'Spinner');assert.equal(details[3],'framey/components');
assert.equal(dfl.findModule(module=>module.Spinner),details[0]);
assert.equal(dfl.findModuleByExport((value,name)=>name==='Spinner'),details[0]);
assert.equal(dfl.findModuleChild(module=>module.Spinner),dfl.Spinner);
assert.equal(dfl.findModuleExport((value,name)=>name==='Spinner',1000),undefined);
assert.equal(dfl.findModuleExport(value=>value.prototype.nonexistent),undefined);
assert.equal(dfl.findModuleChild(()=>false),undefined);
const mapping=dfl.createModuleMapping(module=>!!module.SpinnerLoaderContainer);
assert.equal(mapping.size,1);assert.equal(mapping.get('framey/spinner'),spinnerClasses);
mapping.clear();assert.equal(dfl.findAllModules(module=>!!module.SpinnerLoaderContainer).length,1);
assert.equal(dfl.createModuleMapping(module=>module.SteamOnlyClass).size,0);
const audio=dfl.findModuleExport(value=>value.GamepadUIAudio).GamepadUIAudio;
assert.equal(audio.available,false);
assert.throws(()=>audio.AudioPlaybackManager.PlayAudioURL('file:///sound.wav'),/not supported in Framey/);
assert.equal(dfl.findModuleExport(value=>value.m_GamepadUIAudioStore),undefined);
assert.equal(dfl.findModuleExport(value=>value.toString?.().includes('().LibraryContextMenu')),undefined);
window.DFL.Navigation.CloseSideMenus();
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0);
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
@@ -175,6 +252,50 @@ assert.equal(unsubscribe.unregister,unsubscribe);
unsubscribe.unregister();unsubscribe();assert.equal(eventCount,0);
assert.equal(window.SteamClient.System.ShutdownPC,undefined);
assert.equal(window.DFL.Router.MainRunningApp,undefined);
assert.equal(window.DFL.Router.RunningApps.length,0);
assert.equal(window.DFL.Router.RunningApps,window.SteamUIStore.RunningApps);
assert.ok(Object.isFrozen(window.SteamUIStore));
assert.ok(Object.isFrozen(window.SuspendResumeStore));
assert.equal(window.SuspendResumeStore.InitiateSleep,undefined);
for(const name of ['App','appStore','appInfoStore','collectionStore'])assert.equal(window[name],undefined);
const snapshot={apps:[[10,'Game Ten','ten',1,'10',180,60,1234],
[20,'Shortcut','shortcut',1073741824,'85899345920',0,0,0]],gameIds:[10]};
window.__library(snapshot);
assert.equal(window.appStore.allApps.length,2);
assert.equal(window.appStore.GetAppOverviewByAppID('10').display_name,'Game Ten');
assert.equal(window.appStore.GetAppOverviewByGameID('85899345920').appid,20);
assert.equal(window.appStore.GetAppOverviewByGameID('missing'),undefined);
assert.equal(window.collectionStore.allGamesCollection.allApps[0],window.appStore.m_mapApps.get(10));
window.appStore.m_mapApps.get(10).display_name='Plugin-local patch';
assert.equal(snapshot.apps[0][1],'Game Ten');
assert.equal(window.App,undefined); // No fabricated account identity.
window.__library({...snapshot,steamId:'76561198000000001'}); // Explicit test fixture, never a fallback.
assert.deepEqual(Object.keys(window.App.m_CurrentUser),['strSteamID']);
assert.equal(window.App.m_CurrentUser.strSteamID,'76561198000000001');
window.appStore.allApps[0].display_name='Plugin-local patch';
assert.equal(window.appInfoStore,undefined); // No fake MobX store.
for(const invalid of [null,{apps:[],gameIds:[10]},
{...snapshot,steamId:'invented'},
{...snapshot,apps:[snapshot.apps[0],snapshot.apps[0]]},
{...snapshot,apps:[[10,'Name','sort',1,'undefined',0,0,0]]},
{...snapshot,apps:[[10,'Name','sort',1,'10',-1,0,0]]},
{apps:Array(10001).fill(snapshot.apps[0]),gameIds:[]}]){
assert.throws(()=>window.__library(invalid),/Invalid Steam/);
assert.equal(window.appStore.allApps[0].display_name,'Plugin-local patch');
}
for(const register of [window.SteamClient.System.UI.RegisterForSystemKeyEvents,
window.SteamClient.Apps.RegisterForGameActionUserRequest]){
const dispose=register(()=>eventCount++);assert.equal(dispose.unregister,dispose);dispose();
}
assert.equal(eventCount,0);
const image=window.DFL.SuspensefulImage({src:'data:image/svg+xml,<svg/>',alt:'Setup',style:{width:50}});
assert.equal(image.type,'img');assert.equal(image.props.alt,'Setup');assert.equal(image.props.style.width,50);
await assert.rejects(window.SteamClient.Storage.GetJSON('missing'),/Not found/);
await window.SteamClient.Storage.SetObject('settings',{enabled:false,count:3});
assert.equal(await window.SteamClient.Storage.GetJSON('settings'),'{"enabled":false,"count":3}');
assert.equal(window.localStorage.getItem('steam-client:settings'),'{"enabled":false,"count":3}');
await assert.rejects(window.SteamClient.Storage.SetObject('large','x'.repeat(65537)),/Framey plugin cache is full/);
await assert.rejects(window.SteamClient.Storage.SetObject('undefined',undefined),/JSON value/);
assert.equal(window.indexedDB,undefined); // localForage must use bounded plugin storage.
const unpatch=window.DFL.beforePatch({},'method',()=>{});
assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch();
@@ -206,3 +327,35 @@ assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
removeGame();
assert.equal(window.DFL.useParams().appid,undefined);
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
// Event subscription is acknowledged before a method that immediately emits.
const onMessage=listeners.find(x=>x.type==='message').handler;
const eventValues=[];
const callback=(...args)=>eventValues.push(args);
assert.equal(api.addEventListener('timer:tick',callback),callback);
const subscribe=messages.at(-1);
assert.equal(subscribe.type,'framey:events');
assert.deepEqual(Array.from(subscribe.args[0]),['timer:tick']);
const before=messages.length;
const timerCall=api.call('load_remaining_seconds');
assert.equal(messages.length,before);
// Other windows cannot inject backend replies.
onMessage({source:{},data:{type:'framey:reply',id:subscribe.id,result:{events:[],cursor:0}}});
await Promise.resolve();assert.equal(messages.length,before);
onMessage({source:parent,data:{type:'framey:reply',id:subscribe.id,
result:{events:[{event:'timer:tick',args:[12,'seconds']}],cursor:1}}});
for(let i=0;i<12;i++)await Promise.resolve();
assert.deepEqual(eventValues,[[12,'seconds']]);
const timerRequest=messages.at(-1);
assert.equal(timerRequest.type,'framey:call');assert.equal(timerRequest.method,'load_remaining_seconds');
onMessage({source:parent,data:{type:'framey:reply',id:timerRequest.id,result:null}});await timerCall;
api.removeEventListener('timer:tick',callback);
const unsubscribeRequest=messages.at(-1);assert.equal(unsubscribeRequest.type,'framey:events');
assert.equal(unsubscribeRequest.args[0].length,0);
onMessage({source:parent,data:{type:'framey:reply',id:unsubscribeRequest.id,
result:{events:[{event:'timer:tick',args:[99]}],cursor:2}}});
for(let i=0;i<12;i++)await Promise.resolve();assert.equal(eventValues.length,1);
assert.throws(()=>api.addEventListener('bad event',callback),/Invalid Decky event/);
assert.throws(()=>api.addEventListener('tick',null),/Invalid Decky event/);
listeners.find(x=>x.type==='unload').handler();
assert.equal(messages.at(-1).type,'framey:events:close');
+7 -4
View File
@@ -38,7 +38,7 @@ function iframe(){
for(const message of queued)onParent({source:proxy,data:message});
live=true;
const send=message=>onParent({source:proxy,data:message});
return {storage:frameWindow.localStorage,send};
return {storage:frameWindow.localStorage,steamStorage:frameWindow.SteamClient.Storage,send};
}
let first=iframe();
assert.equal(first.storage.getItem('status-cache'),null);
@@ -46,6 +46,7 @@ first.storage.setItem('status-cache','{"online":true}');
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
first.storage.setItem('temporary','discard');
first.storage.removeItem('temporary');
await first.steamStorage.SetObject('playtime-settings',{count:3});
const oldSession=session;
vm.runInContext('openFrameyPanel(14,"Steam Status")',parent);
session=vm.runInContext('frameySession',parent);
@@ -57,13 +58,15 @@ assert.equal(reopenedURL.searchParams.get('session'),session);
assert.equal(reopenedURL.hash.slice(1),session);
let reopened=iframe();
assert.equal(reopened.storage.getItem('status-cache'),'{"online":true}');
assert.equal(reopened.storage.length,1);
assert.equal(reopened.storage.length,2);
assert.equal(reopened.storage.key(0),'status-cache');
assert.equal(await reopened.steamStorage.GetJSON('playtime-settings'),'{"count":3}');
assert.throws(()=>reopened.storage.setItem('huge','x'.repeat(65537)),/Framey plugin cache is full/);
// A malformed or over-limit message from a plugin must not poison saved data.
const savedCache=values.get('framey-plugin-cache-v1:14');
reopened.send({type:'framey:cache:save',session,entries:{huge:'x'.repeat(65537)}});
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
assert.equal(values.get('framey-plugin-cache-v1:14'),savedCache);
reopened.send({type:'framey:cache:save',session:oldSession,entries:{poison:'yes'}});
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
assert.equal(values.get('framey-plugin-cache-v1:14'),savedCache);
reopened.storage.clear();
assert.equal(values.get('framey-plugin-cache-v1:14'),'{}');
+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
const source=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
const elements=new Map(),listeners=[],requests=[],replies=[];
const el=id=>{if(!elements.has(id))elements.set(id,{removeAttribute(){},scrollIntoView(){}});return elements.get(id);};
const frame={postMessage:data=>replies.push(data)};
el('frameyFrame').contentWindow=frame;
const context=vm.createContext({document:{getElementById:el,createElement:()=>({})},
location:{hash:'',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem:()=>null,setItem(){}},localStorage:{getItem:()=>null,setItem(){}},
window:{addEventListener:(type,fn)=>listeners.push({type,fn})},
setTimeout(){},setInterval(){},console,
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
vm.runInContext(source,context);
vm.runInContext('deckyState={plugins:[{storeId:96}]};openFrameyPanel(96,"Timer")',context);
let session=vm.runInContext('frameySession',context);
const onMessage=listeners.find(x=>x.type==='message').fn;
const send=(data,origin=frame)=>onMessage({source:origin,data});
const subscription={type:'framey:events',id:1,session,args:[['tick'],0]};
await send({...subscription,session:'old'});
await send(subscription,{});
await send({...subscription,args:[['bad event'],0]});
assert.equal(requests.length,0);
const first=send(subscription);
assert.equal(requests.length,1);
assert.equal(requests[0].path,'/api/framey/events');
assert.equal(requests[0].body.id,96);
assert.equal(requests[0].body.session,session);
// WindowProxy survives navigation: stale same-plugin replies need a session check.
vm.runInContext('openFrameyPanel(96,"Timer")',context);
const secondSession=vm.runInContext('frameySession',context);
assert.notEqual(secondSession,session);
assert.equal(requests[1].body.events.length,0); // Closes only the original subscription.
assert.equal(requests[1].body.session,session);
requests[0].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[42]}],cursor:1})});
await first;assert.equal(replies.length,0);
await send({type:'framey:call',id:2,session,method:'cancel_timer',args:[]});
assert.equal(requests.length,2); // Old document cannot call a newly opened plugin.
session=secondSession;
const second=send({...subscription,id:3,session});
requests[2].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[7]}],cursor:2})});
await second;
assert.equal(replies.length,1);assert.equal(replies[0].result.events[0].args[0],7);
el('frameyClose').onclick();
assert.equal(requests[3].body.session,session);assert.equal(requests[3].body.events.length,0);
for(const request of requests.slice(1))request.resolve({ok:true,json:async()=>({events:[],cursor:0})});
// Only explicit zero-argument Steam reads reach the trusted parent endpoint.
vm.runInContext('openFrameyPanel(96,"Timer")',context);
session=vm.runInContext('frameySession',context);
const steamRead={type:'framey:steam',id:10,session,method:'GetIPCountry',args:[]};
const initialCount=requests.length;
for(const change of [{method:'ShutdownPC'},{method:'User.GetIPCountry'},{args:['injected']},{session:'stale'}])
await send({...steamRead,...change});
await send(steamRead,{});
assert.equal(requests.length,initialCount);
const read=send(steamRead);
assert.equal(requests.at(-1).path,'/api/framey/steam');assert.equal(requests.at(-1).body.id,96);
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
await read;assert.equal(replies.at(-1).result,'AU');
const staleRead=send({...steamRead,id:11});
vm.runInContext('openFrameyPanel(96,"Timer")',context);
const priorReplies=replies.length;
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
await staleRead;assert.equal(replies.length,priorReplies);
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const source=readFileSync(new URL('../../ui/framey-events.js',import.meta.url),'utf8');
const handlers=new Set(),timers=new Map();let now=1000,nextTimer=0;
const backend={addEventListener(name,listener){assert.equal(name,'loader/plugin_event');handlers.add(listener);},
removeEventListener(name,listener){assert.equal(name,'loader/plugin_event');handlers.delete(listener);}};
const window={DeckyBackend:backend};
const context=vm.createContext({window,Date:{now:()=>now},
setInterval:fn=>{timers.set(++nextTimer,fn);return nextTimer;},clearInterval:id=>timers.delete(id)});
const poll=(session='one',name='Simple Timer',events=['tick'],cursor=0)=>
JSON.parse(JSON.stringify(vm.runInContext(source,context)({session,name,events,cursor})));
const emit=(plugin,event,...args)=>{for(const handler of handlers)handler({plugin,event,args});};
assert.deepEqual(poll(),{events:[],cursor:0});
assert.equal(handlers.size,1);
emit('Other plugin','tick',10);emit('Simple Timer','other',99);emit('Simple Timer','tick',42,'seconds');
assert.deepEqual(poll().events,[{sequence:1,event:'tick',args:[42,'seconds']}]);
assert.equal(handlers.size,1); // Polling never duplicates the backend subscription.
assert.deepEqual(poll('one','Simple Timer',['tick'],1).events,[]);
assert.equal(poll().events.length,1); // Retry a lost HTTP response without losing its event.
assert.throws(()=>poll('one','Another plugin'),/belongs to another plugin/);
poll('two','Another plugin');emit('Another plugin','tick',123);
assert.deepEqual(poll('two','Another plugin').events[0].args,[123]);
assert.equal(poll().events.length,1);
emit('Simple Timer','tick','x'.repeat(8193));assert.equal(poll().events.length,1);
for(let index=0;index<100;index++)emit('Simple Timer','tick',index);
assert.equal(poll().events.length,64);
assert.equal(poll().events.at(-1).args[0],99);
for(let index=0;index<20;index++)emit('Simple Timer','tick','x'.repeat(8000));
assert.ok(JSON.stringify(poll()).length<50*1024);
poll('one','Simple Timer',[]);assert.equal(handlers.size,1);
now+=16000;for(const timer of [...timers.values()])timer();
assert.equal(handlers.size,0);assert.equal(timers.size,0);
poll();assert.equal(handlers.size,1);
// A Decky frontend reconnect must register against the replacement backend.
let replaced=0;
window.DeckyBackend={...backend,addEventListener(name,fn){replaced++;backend.addEventListener(name,fn);}};
poll();assert.equal(replaced,1);assert.equal(handlers.size,1);
poll('one','Simple Timer',[]);assert.equal(handlers.size,0);
+1 -1
View File
@@ -49,7 +49,7 @@ context.SP_JSX=window.SP_JSX;
vm.runInContext(official,context,{timeout:2000});
const plugin=window.__official();
assert.equal(typeof window.DFL.findModuleChild,'function');
assert.equal(window.DFL.findModuleChild(()=>{}),null); // Steam has no matching module in the VR panel.
assert.equal(window.DFL.findModuleChild(()=>{}),undefined); // Steam has no matching module in the VR panel.
assert.equal(plugin.content?.type?.name,'QuickAccessView');
const panel=plugin.content.type();
assert.ok(panel?.type); // Read-only settings render with empty local data.
+38 -3
View File
@@ -8,8 +8,22 @@ const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js'
assert.match(bundle,/SP_JSX\.jsx/);
const React={Fragment:Symbol('fragment'),Component:class {},
createElement:(type,props,...children)=>({type,props:props||{},children}),
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
useState:value=>[value,()=>{}],useRef:value=>({current:value}),
useEffect(){},isValidElement:value=>!!value?.type};
class Element {
constructor(){this.children=[];this.textContent='';this.attributes={};}
append(...nodes){this.children.push(...nodes);}
setAttribute(name,value){this.attributes[name]=value;}
remove(){this.removed=true;}
}
const elements=new Map(), roots=new Map();
const document={getElementById:id=>{
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);
},createElement:()=>new Element()};
const window={React,ReactDOM:{createRoot:node=>{
const root={render(value){this.value=value;},unmount(){this.unmounted=true;}};
roots.set(node,root);return root;
}},addEventListener(){}};
const listeners=[];
const calls=[];
const parent={postMessage(message){
@@ -18,7 +32,7 @@ const parent={postMessage(message){
listeners.find(x=>x.type==='message').handler({source:parent,
data:{type:'framey:reply',id:message.id,result:true}});
}};
const context=vm.createContext({window,document:{getElementById:()=>({})},
const context=vm.createContext({window,document,
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(host,context);
@@ -36,6 +50,27 @@ assert.equal(plugin.frameyQuickActions[0].label,'Read saved setting');
assert.equal(await plugin.frameyQuickActions[0].onClick(),'Saved setting is on');
assert.equal(calls[0].method,'get_setting');
assert.deepEqual(Array.from(calls[0].args),['enabled',false]);
// Exercise the built sample's save path through the real Framey toast host.
// Its JSX body must reach a React root, rather than stringify to an object.
const toggle=content.children[0][0].children[0];
toggle.props.onChange(true);
toggle.props.onChange(false); // Before React redraws disabled, do not race the first write.
await new Promise(resolve=>setImmediate(resolve));
assert.equal(calls.filter(call=>call.method==='set_setting').length,1);
assert.deepEqual(Array.from(calls.find(call=>call.method==='set_setting').args),['enabled',true]);
const toast=elements.get('framey-toasts').children[0];
assert.equal(toast.attributes.role,'status');
assert.equal(toast.children[0].textContent,'Framey sample');
const toastBody=roots.get(toast.children[1]).value;
assert.equal(toastBody.type,'span');
assert.match(JSON.stringify(toastBody),/Setting saved/);
assert.match(JSON.stringify(toastBody),/on/);
toast.children[2].onclick();
assert.equal(roots.get(toast.children[1]).unmounted,true);
toggle.props.onChange(false);
await new Promise(resolve=>setImmediate(resolve));
assert.equal(calls.filter(call=>call.method==='set_setting').length,2,
'a completed save must release the sample setting lock');
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),true);
assert.equal(window.DFL.useParams().topic,'saved-setting');
assert.equal(window.DFL.Navigation.Navigate('/library/home'),false);
+80 -1
View File
@@ -67,7 +67,7 @@ ctx.mockApi=async path => path==='/api/decky' ?
Promise.reject(Error(`unexpected ${path}`));
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('deckyInstalled').children[0].children[2].disabled,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
el('deckySearch').value='';el('deckyStoreFilter').value='all';
vm.runInContext('showDeckyStore()',ctx);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
@@ -82,6 +82,38 @@ await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"b".rep
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
assert.equal(session.has('frameyPluginJob'),false);
// Polling can lose the connection after the server accepted a plugin job.
// Keep both store and installed controls disabled until its saved ID resolves.
let changes=0;
ctx.mockApi=async (path,body) => {
if(path==='/api/decky/plugin'){changes++;return {job:'job-pending'};}
if(path.startsWith('/api/job'))throw Error('connection lost');
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(changes,1);
assert.equal(session.has('frameyPluginJob'),true);
assert.equal(el('deckyJobCheck').hidden,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,true);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
await vm.runInContext('deckyChange("remove",{id:2,name:"Plugin 2"})',ctx);
assert.equal(changes,1);
assert.match(el('deckyJobStatus').textContent,/Check result before starting another/);
ctx.mockApi=async path => {
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,false);
// A failed refresh must remove old install buttons. A late older response
// must not replace the newer catalog the wearer just requested.
ctx.mockApi=async path => {assert.equal(path,'/api/decky/store');throw Error('offline');};
@@ -117,3 +149,50 @@ await vm.runInContext('checkDeckyJob()',ctx);
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
// Known hardware blockers remain visible, cannot install, and keep their reason in the opened panel.
ctx.hardware={id:900,name:'Hardware only',version:'1.0',description:'Fan control',root:false,
compatibility:{status:'na',reason:'Not supported on Frame: Deck fan hardware.',verification:['static archive','local browser'],blockInstall:true}};
el('deckySearch').value='';el('deckyStoreFilter').value='all';
vm.runInContext('deckyStore=[hardware];deckyState={running:true,plugins:[{...hardware,storeId:900}]};showDeckyStore()',ctx);
const hardwareRow=el('deckyStoreList').children[0];
assert.equal(hardwareRow.children[1].disabled,true);
assert.match(hardwareRow.children[0].children[0].textContent,/N\/A: Not supported on Frame/);
el('deckyStoreFilter').value='compatible';vm.runInContext('showDeckyStore()',ctx);
assert.equal(el('deckyStoreList').children.length,0);
vm.runInContext('openFrameyPanel(900,"Hardware only")',ctx);
assert.match(el('frameyCompatibility').textContent,/Deck fan hardware/);
assert.match(el('frameyCompatibility').textContent,/local browser/);
// A failed installed-status read must not leave stale Remove or Install actions.
// Older responses must not replace a newer status, even if they arrive last.
vm.runInContext('deckyStore=[{id:2,name:"Plugin 2",version:"1.0",description:"A panel",root:false}];' +
'deckyState={running:true,plugins:[{storeId:2,name:"Plugin 2",version:"1.0"}]};showDeckyStore()',ctx);
let statusCalls=[];
ctx.mockApi=path=>{assert.equal(path,'/api/decky');
return new Promise((resolve,reject)=>statusCalls.push({resolve,reject}));};
vm.runInContext('api=mockApi',ctx);
const staleSuccess=vm.runInContext('refreshDecky()',ctx);
const latestFailure=vm.runInContext('refreshDecky()',ctx);
statusCalls[1].reject(Error('offline'));
await latestFailure;
assert.match(el('deckyState').textContent,/Installed plugins unavailable: offline/);
assert.equal(el('deckyInstalled').children.length,0);
assert.equal(el('deckyInstalled').textContent,'Refresh to check installed plugins.');
assert.equal(el('deckyStoreList').children[0].children[1].disabled,true);
statusCalls[0].resolve({running:true,plugins:[{storeId:2,name:'Stale',version:'1.0'}]});
await staleSuccess;
assert.match(el('deckyState').textContent,/offline/);
assert.equal(el('deckyInstalled').children.length,0);
statusCalls=[];
const staleFailure=vm.runInContext('refreshDecky()',ctx);
const latestSuccess=vm.runInContext('refreshDecky()',ctx);
statusCalls[1].resolve({managed:true,running:true,plugins:[{storeId:2,name:'Current',version:'1.0'}]});
await latestSuccess;
statusCalls[0].reject(Error('old offline error'));
await staleFailure;
assert.match(el('deckyInstalled').children[0].children[0].textContent,/Current/);
assert.doesNotMatch(el('deckyState').textContent,/offline/);
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
+96 -12
View File
@@ -4,6 +4,11 @@ Runs on your computer through the `frame` SSH alias (or FRAME_ALIAS), with
Frame Control's own modules, and records what happened with the headset's
BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
Before any status read or title change, it atomically owns /tmp/frame-test.lock
on the Frame and checks SteamVR standby, proximity and battery read-only. A
busy lock, awake headset or battery below 20% stops the run. Only this run's
lock is released, including after test failures.
- properties.json from Valve's devkit service names the login user;
- status (ui/frame_status.py) reads the build, battery and storage;
- three tiny titles (tests/smoke/tiny_programs.py: ARM64 and x86-64 static
@@ -28,6 +33,7 @@ import argparse
import json
import os
import platform
import secrets
import shlex
import subprocess
import sys
@@ -50,6 +56,60 @@ import stop_processes # noqa: E402
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
PREFIX = 'fc_smoke_'
LOCK = '/tmp/frame-test.lock'
def remote(command, *, input=None):
"""Use a bounded SSH call for shared-lock operations."""
return subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', ALIAS, command],
input=input, capture_output=True, text=True, timeout=20)
def acquire_lock(token):
"""Atomically reserve the Frame before reading its state or changing titles."""
command = (f'mkdir {LOCK} && {{ printf %s {shlex.quote(token)} > {LOCK}/owner || '
f'{{ rmdir {LOCK}; exit 1; }}; }}')
return remote(command).returncode == 0
def release_lock(token):
"""Never remove a lock if its owner changed."""
command = (f'test "$(cat {LOCK}/owner)" = {shlex.quote(token)} && '
f'rm {LOCK}/owner && rmdir {LOCK}')
return remote(command).returncode == 0
PREFLIGHT = r'''
import json, pathlib, subprocess
r = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd', '--stats'],
capture_output=True, text=True, timeout=8)
try: activity = json.loads(r.stdout)[0]['activity_level'] if r.returncode == 0 else None
except (ValueError, KeyError, IndexError, TypeError): activity = None
proximity = []
for path in pathlib.Path('/sys/bus/iio/devices').glob('iio:device*/in_proximity_raw'):
try: proximity.append(float(path.read_text().strip()))
except (OSError, ValueError): pass
battery = []
for path in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
try:
if (path.parent/'type').read_text().strip() == 'Battery':
battery.append(int(path.read_text().strip()))
except (OSError, ValueError): pass
print(json.dumps({'activity': activity, 'proximity': proximity, 'battery': battery}))
'''
def preflight():
result = remote('python3 -', input=PREFLIGHT)
if result.returncode:
raise RuntimeError('Locked SteamVR and battery preflight failed: ' + result.stderr.strip())
state = json.loads(result.stdout)
if state.get('activity') != 3:
raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched')
battery = state.get('battery')
if not battery or min(battery) < 20:
raise RuntimeError('Frame battery is unavailable or below the smoke threshold')
return state
def stop_titles(ids):
@@ -308,17 +368,8 @@ def pair(folder):
print(f' removed the smoke key from authorized_keys ({dropped} line(s))', flush=True)
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--pair', action='store_true', help='also pair through the devkit service (needs you in the headset)')
args = ap.parse_args()
r = subprocess.run(['ssh', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes', ALIAS, 'true'],
capture_output=True, text=True)
if r.returncode != 0:
print(f'{ALIAS} is not reachable over SSH: {r.stderr.strip()}', file=sys.stderr)
return 2
def run_smoke(args, before):
"""Run only after the shared lock and read-only preflight succeeded."""
smoke = Smoke()
started = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
release = os_release()
@@ -347,12 +398,45 @@ def main():
passed = sum(s['ok'] for s in smoke.steps)
RESULTS.mkdir(exist_ok=True)
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'os_release': release, 'paired': args.pair,
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'preflight': before,
'os_release': release, 'paired': args.pair,
'passed': passed, 'failed': len(smoke.steps) - passed,
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out}')
return 0 if passed == len(smoke.steps) else 1
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('--pair', action='store_true', help='also pair through the devkit service (needs you in the headset)')
args = ap.parse_args()
token = 'frame-smoke-' + secrets.token_hex(10)
try:
locked = acquire_lock(token)
except (OSError, subprocess.TimeoutExpired):
locked = False
if not locked:
print('Shared Frame lock busy or unreachable; no headset status read or smoke run.', file=sys.stderr)
return 2
result = 1
try:
try:
before = preflight()
print('Locked read-only preflight: ' + json.dumps(before), flush=True)
result = run_smoke(args, before)
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as failure:
print(str(failure), file=sys.stderr)
result = 2
finally:
try:
released = release_lock(token)
except (OSError, subprocess.TimeoutExpired):
released = False
if not released:
print('ERROR: could not release owned Frame lock.', file=sys.stderr)
result = 1
return result
if __name__ == '__main__':
sys.exit(main())
+81 -3
View File
@@ -98,14 +98,14 @@ class StoreTransaction(unittest.TestCase):
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
self.folder.parent.mkdir(parents=True)
def apply(self, action, data=None):
def apply(self, action, data=None, expected='-'):
token = secrets.token_hex(12)
source = Path('/tmp/frame-control-decky-plugin-' + token)
if data is not None:
source.write_bytes(data)
digest = hashlib.sha256(data or b'').hexdigest()
try:
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0'],
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0', expected],
input=frame_decky_store.APPLY, text=True, capture_output=True,
env=self.env, timeout=15)
finally:
@@ -181,6 +181,50 @@ class StoreTransaction(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.folder.exists())
def test_remote_apply_rechecks_plugin_name_under_transaction_lock(self):
data, _ = archive()
other = self.folder.parent / 'Other'
other.mkdir()
(other / 'plugin.json').write_text('{"name":"Example"}')
result = self.apply('install', data)
self.assertNotEqual(result.returncode, 0)
self.assertIn('different plugin with this name', result.stderr)
self.assertFalse(self.folder.exists())
self.assertEqual((other / 'plugin.json').read_text(), '{"name":"Example"}')
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
(other / 'plugin.json').write_text('{"name":"Distinct"}')
self.assertEqual(self.apply('install', data).returncode, 0)
(other / 'plugin.json').write_text('{"name":"Example"}')
result = self.apply('update', data)
self.assertNotEqual(result.returncode, 0)
self.assertIn('different plugin with this name', result.stderr)
self.assertTrue(self.folder.exists())
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
def test_update_and_remove_reject_stale_installed_receipt(self):
first, first_hash = archive()
second, _ = archive(extra=('Example/data.txt', 'new'))
self.assertEqual(self.apply('install', first).returncode, 0)
marker = self.folder / '.frame-control-store.json'
self.assertEqual(json.loads(marker.read_text())['sha256'], first_hash)
# A newer store operation wins before the caller reaches the lock.
current = json.loads(marker.read_text())
current['sha256'] = 'b' * 64
marker.write_text(json.dumps(current))
for action, data in [('update', second), ('remove', None)]:
with self.subTest(action=action):
result = self.apply(action, data, expected=first_hash)
self.assertNotEqual(result.returncode, 0)
self.assertIn('Installed plugin changed', result.stderr)
self.assertTrue(self.folder.exists())
self.assertEqual(json.loads(marker.read_text())['sha256'], 'b' * 64)
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
self.assertEqual(list(self.folder.parent.glob('.frame-control-old-*')), [])
self.assertEqual(self.apply('remove', expected='b' * 64).returncode, 0)
self.assertFalse(self.folder.exists())
class StoreHost(unittest.TestCase):
def test_reviewed_archive_changed_before_install_does_not_download(self):
@@ -197,7 +241,8 @@ class StoreHost(unittest.TestCase):
def test_stopped_managed_service_allows_removing_owned_plugin(self):
state = {'managed': True, 'running': False, 'plugins':
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0',
'sha256': 'a' * 64}]}
calls = []
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog') as network:
@@ -205,6 +250,7 @@ class StoreHost(unittest.TestCase):
'remove', 42)
self.assertEqual(result, {})
self.assertEqual(len(calls), 1)
self.assertTrue(calls[0].endswith(' ' + 'a' * 64))
network.assert_not_called()
def test_renamed_store_plugin_cannot_be_installed_twice(self):
@@ -219,6 +265,38 @@ class StoreHost(unittest.TestCase):
'install', 42)
network.assert_not_called()
def test_update_rejects_name_taken_by_another_plugin_before_download(self):
state = {'managed': True, 'running': True, 'plugins': [
{'storeId': 42, 'folder': 'Example', 'name': 'Old Name', 'version': '1.0'},
{'storeId': 43, 'folder': 'Other', 'name': 'Example', 'version': '1.0'}]}
catalog = [{'id': 42, 'name': 'Example', 'root': False,
'sha256': 'a' * 64, 'version': '2.0'}]
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
patch.object(frame_decky_store.urllib.request, 'urlopen',
side_effect=AssertionError('No download expected')):
with self.assertRaisesRegex(ValueError, 'different plugin with this name'):
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
'update', 42)
def test_update_allows_its_own_existing_name(self):
data, digest = archive()
state = {'managed': True, 'running': True, 'plugins': [
{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0',
'sha256': 'a' * 64}]}
catalog = [{'id': 42, 'name': 'Example', 'root': False,
'sha256': digest, 'version': '2.0'}]
calls = []
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(data)):
result = frame_decky_store.change(lambda command, **kwargs: calls.append(command) or '{}',
'update', 42)
self.assertEqual(result, {})
self.assertEqual(len(calls), 3) # Upload, apply, and temporary archive cleanup.
self.assertIn('update', calls[1])
self.assertTrue(calls[1].endswith(' ' + 'a' * 64))
def test_owned_plugin_can_be_removed_without_store_network(self):
state = {'managed': True, 'running': True, 'plugins':
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
+64
View File
@@ -0,0 +1,64 @@
"""A compatibility claim must never transfer to a different published archive."""
import sandbox # noqa: F401
import json
from pathlib import Path
import sys
import subprocess
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'ui'))
import framey_compatibility as compatibility
import frame_decky_store
import frame_decky
class Compatibility(unittest.TestCase):
def test_table_matches_store_data_and_complete_browser_and_fake_evidence(self):
root = Path(__file__).resolve().parents[1]
subprocess.run([sys.executable, str(root/'scripts/framey-compatibility-doc.py'), '--check'], check=True)
records = json.loads(compatibility.DATA.read_text())['plugins']
self.assertEqual(len({p['id'] for p in records}), len(records))
for record in records:
self.assertEqual(record['browser']['id'], record['id'])
self.assertEqual(record['fakeFrame']['sha256'], record['sha256'])
self.assertIn(record['status'], ('works', 'partial', 'broken', 'na'))
def test_claims_require_exact_id_version_and_digest(self):
records = json.loads(compatibility.DATA.read_text())['plugins']
for plugin in records:
with self.subTest(plugin=plugin['name']):
expected = compatibility.lookup(plugin['id'], plugin['sha256'], plugin['version'])
self.assertEqual(expected['status'], plugin['status'])
for plugin_id, digest, version in (
(plugin['id'], '0'*64, plugin['version']),
(plugin['id'], plugin['sha256'], plugin['version']+'-new'),
(99999, plugin['sha256'], plugin['version']),
):
result = compatibility.lookup(plugin_id, digest, version)
self.assertEqual(result['status'], 'unverified')
self.assertFalse(result['blockInstall'])
def test_installed_receipt_uses_its_digest_not_current_store_version(self):
record = json.loads(compatibility.DATA.read_text())['plugins'][0]
installed = {'plugins': [{'storeId': record['id'], 'version': record['version'],
'sha256': record['sha256']},
{'storeId': record['id'], 'version': record['version'], 'sha256': '0'*64}]}
result = frame_decky.status(lambda *a, **k: json.dumps(installed))
self.assertEqual(result['plugins'][0]['compatibility']['status'], record['status'])
self.assertEqual(result['plugins'][1]['compatibility']['status'], 'unverified')
def test_hardware_block_precedes_any_download_or_ssh_mutation(self):
with patch.object(frame_decky, 'status', return_value={'managed': True, 'running': True, 'plugins': []}), \
patch.object(frame_decky_store, 'catalog', return_value=[{'id': 42, 'name': 'Hardware only',
'sha256': 'a'*64, 'root': False, 'compatibility': {'blockInstall': True, 'reason': 'Not supported on Frame'}}]), \
patch.object(frame_decky_store.urllib.request, 'urlopen') as download:
def no_ssh(*a, **k):
self.fail('must not mutate headset for known hardware blocker')
with self.assertRaisesRegex(ValueError, 'Not supported on Frame'):
frame_decky_store.change(no_ssh, 'install', 42)
download.assert_not_called()
def test_missing_audit_data_is_unverified(self):
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
self.assertEqual(compatibility.lookup(12, 'a'*64, '1.0')['status'], 'unverified')
+6 -1
View File
@@ -74,9 +74,14 @@ class OfficialBundles(unittest.TestCase):
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
with patch.object(framey_panel, 'PLUGINS', plugins):
page = framey_panel.panel(plugin['id']).decode()
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
encoded = re.search(r'<script id="framey-bundle" type="application/json"[^>]*>([^<]+)</script>', page)
self.assertIsNotNone(encoded)
self.assertEqual(base64.b64decode(encoded.group(1)), (folder / 'dist/index.js').read_bytes())
if plugin['id'] == 135:
self.assertIn('data-module="/framey/module/135/index.js"', page)
with patch.object(framey_panel, 'PLUGINS', plugins):
self.assertEqual(framey_panel.local_module(135, 'chunks/pluginApi-B8sV7970.js'),
(folder/'dist/chunks/pluginApi-B8sV7970.js').read_bytes())
if __name__ == '__main__':
+159 -4
View File
@@ -20,6 +20,126 @@ import server
class Panel(unittest.TestCase):
def test_steam_reads_are_allowlisted_and_installed_only(self):
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck'}]}):
for method, expression in framey_panel.STEAM_READS.items():
def fake_ssh(command, stdin, **_):
self.assertEqual(command, 'python3 -')
compile(stdin, '<Steam read>', 'exec')
self.assertIn(expression.replace('\n', r'\n'), stdin)
self.assertNotIn('__FRAMEY_STEAM_READ__', stdin)
return '{"result":"actual read"}'
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method)['result'], 'actual read')
for plugin_id, method in [(True,'GetIPCountry'), (0,'GetIPCountry'),
(82,'GetIPCountry'), (81,'ShutdownPC'), (81,'User.GetIPCountry'),
(81,[]), (81,'GetIPCountry();alert(1)')]:
with self.subTest(plugin_id=plugin_id, method=method):
with self.assertRaises(framey_panel.PanelError):
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method)
def test_steam_read_response_limit_and_socket_cleanup(self):
from types import SimpleNamespace
for value, oversized in [('AU',False), ('x'*(256*1024),True)]:
closed=[]
page=SimpleNamespace(eval=lambda _:value,sock=SimpleNamespace(close=lambda:closed.append(True)))
fake_module=SimpleNamespace(Page=lambda:page)
script=framey_panel.STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__',repr('safe()')).replace(
'__FRAMEY_STEAM_LIMIT__',str(256*1024))
with patch.dict(sys.modules, {'frame_steam':fake_module}), patch('builtins.print') as output:
if oversized:
with self.assertRaisesRegex(ValueError,'too large'):exec(script,{})
else:
exec(script,{})
self.assertEqual(json.loads(output.call_args.args[0]),{'result':'AU'})
self.assertEqual(closed,[True])
def test_library_snapshot_copies_only_bounded_scalar_fields(self):
expression = framey_panel.STEAM_READS['GetLibrarySnapshot']
script = '''const assert=require('node:assert/strict');
const app={appid:10,display_name:'Game',sort_as:'game',app_type:1,gameid:'10',
minutes_playtime_forever:60,minutes_playtime_last_two_weeks:20,rt_last_time_played:123,
owner_account_id:'PRIVATE',per_client_data:{path:'PRIVATE'}};
global.window=global;global.appStore={allApps:[app]};
global.App={m_CurrentUser:{strSteamID:'76561198000000001',strAccountBalance:'PRIVATE'}};
global.collectionStore={allGamesCollection:{allApps:[app]}};
const value=eval(EXPRESSION);
assert.deepEqual(value,{apps:[[10,'Game','game',1,'10',60,20,123]],gameIds:[10],steamId:'76561198000000001'});
appStore.allApps=Array(10001).fill(app);
assert.throws(()=>eval(EXPRESSION),/snapshot limit/);
appStore=undefined;
assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
'''.replace('EXPRESSION',json.dumps(expression))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_library_sized_plugin_arguments_remain_bounded(self):
# Shotty sends appid/name pairs at startup; a normal library exceeds 8K.
args=[{'allapps':[[id,'Game '+str(id)] for id in range(7000)]}]
self.assertGreater(len(json.dumps(args)),128*1024)
def fake_ssh(command, stdin, **_):
import ast
self.assertEqual(command,'python3 -')
compile(stdin,'<large method>','exec')
assignment=next(n for n in ast.parse(stdin).body if isinstance(n,ast.Assign) and
any(isinstance(t,ast.Name) and t.id=='payload' for t in n.targets))
self.assertEqual(json.loads(ast.literal_eval(assignment.value.args[0]))['args'],args)
return '{"result":null}'
with patch.object(framey_panel.frame_decky,'status',return_value={
'plugins':[{'storeId':53,'name':'Shotty'}]}):
self.assertEqual(framey_panel.call_method(fake_ssh,
53,'set_id_map_fronend',args,True),{'result':None})
with self.assertRaisesRegex(framey_panel.PanelError,'too large'):
framey_panel.call_method(lambda *a,**k:self.fail('Unexpected SSH'),
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True)
def test_steam_reads_have_the_headset_service_boundary(self):
with patch.object(server,'HEADSET_RUNTIME',False), patch.dict(server.os.environ,{'FRAME_E2E_CONTAINER':'0'}):
with self.assertRaises(server.Failure):server.framey_steam_action({'id':81,'method':'GetIPCountry'})
with patch.object(server,'HEADSET_RUNTIME',True), patch.object(framey_panel,'read_steam',return_value={'result':'AU'}) as read:
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry'}),{'result':'AU'})
read.assert_called_once_with(server.ssh,81,'GetIPCountry')
def test_decky_event_queue_scope_bounds_expiry_and_reconnect(self):
for name in ('framey_events.mjs', 'framey_event_parent.mjs'):
with self.subTest(script=name):
result = subprocess.run(['node', str(Path(__file__).parent/'page'/name)],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_events_validate_before_ssh_and_resolve_name_from_store_receipt(self):
def fake_ssh(command, stdin=None, **_):
import shlex
payload = json.loads(shlex.split(command)[2])
self.assertEqual(payload, {'name':'Simple Timer', 'session':'test-123.45',
'events':['timer:tick'], 'cursor':5})
compile(stdin, '<event poll>', 'exec')
self.assertIn('loader/plugin_event', stdin)
return json.dumps({'events':[{'event':'timer:tick', 'args':[42]}], 'cursor':6})
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':96,'name':'Simple Timer'}]}):
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5)['cursor'], 6)
for plugin_id, session, events, cursor in [
(True,'test',['tick'],0), (96,'bad session',['tick'],0),
(96,'test',['a']*33,0), (96,'test',['bad event'],0),
(96,'test',[None],0), (96,'test',['tick'],-1),
(96,'test',['tick'],True), (96,'test',['tick'],2**53),
(19,'test',['tick'],0),
]:
with self.subTest(plugin_id=plugin_id, session=session, events=events, cursor=cursor):
with self.assertRaises(framey_panel.PanelError):
framey_panel.poll_events(lambda *a, **k:self.fail('Unexpected SSH'),
plugin_id, session, events, cursor)
def test_event_action_has_same_headset_boundary_as_methods(self):
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
with self.assertRaises(server.Failure):
server.framey_events_action({'id':96,'session':'test','events':['tick']})
with patch.object(server, 'HEADSET_RUNTIME', True), \
patch.object(framey_panel, 'poll_events', return_value={'events':[], 'cursor':0}) as poll:
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick']})['events'], [])
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0)
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
capture_output=True, text=True, timeout=10)
@@ -67,10 +187,34 @@ class Panel(unittest.TestCase):
with self.assertRaises(FileNotFoundError):
framey_panel.panel(14)
def test_chunked_frontend_serves_only_owned_javascript(self):
(self.folder/'dist/index.js').write_text(
"import './chunks/view.js'; export default () => ({content: 'OK'});")
chunks = self.folder/'dist/chunks'
chunks.mkdir()
(chunks/'view.js').write_text('export const title = "Settings";')
page = framey_panel.panel(14).decode()
self.assertIn('data-module="/framey/module/14/index.js"', page)
self.assertEqual(framey_panel.local_module(14, 'chunks/view.js'),
b'export const title = "Settings";')
for name in ('../plugin.json', 'chunks/../../plugin.json',
'chunks/view.js.map', 'other.js', 'chunks/%2e%2e/view.js'):
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
framey_panel.local_module(14, name)
(chunks/'linked.js').symlink_to(chunks/'view.js')
with self.assertRaises(FileNotFoundError):
framey_panel.local_module(14, 'chunks/linked.js')
with self.assertRaises(FileNotFoundError):
framey_panel.local_module(15, 'chunks/view.js')
def test_legacy_keyword_rpc_is_scoped_and_validated(self):
import shlex
def fake_ssh(command, stdin=None, **_):
payload = json.loads(shlex.split(command)[2])
import ast
self.assertEqual(command,'python3 -')
assignment=next(node for node in ast.parse(stdin).body if isinstance(node,ast.Assign) and
any(isinstance(target,ast.Name) and target.id=='payload' for target in node.targets))
payload=json.loads(ast.literal_eval(assignment.value.args[0]))
self.assertEqual(payload, {'name':'Legacy', 'method':'read_setting',
'args':[{'key':'mode'}], 'legacy':True})
self.assertIn('loader/call_legacy_plugin_method', stdin)
@@ -106,8 +250,9 @@ class Panel(unittest.TestCase):
def test_backend_method_is_scoped_to_store_receipt(self):
def fake_ssh(command, stdin=None, **_):
self.assertIn('python3 - ', command)
self.assertEqual(stdin, framey_panel.METHOD_SCRIPT)
self.assertEqual(command, 'python3 -')
compile(stdin, '<remote method>', 'exec')
self.assertNotIn('__FRAMEY_METHOD_PAYLOAD__',stdin)
return json.dumps({'result':{'theme':'dark'}})
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':14,'name':'ProtonDB Badges'}]}):
@@ -115,7 +260,7 @@ class Panel(unittest.TestCase):
{'result':{'theme':'dark'}})
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[])
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[])
compile(framey_panel.METHOD_SCRIPT, '<remote method>', 'exec')
compile(framey_panel.METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__',repr('{}')), '<remote method>', 'exec')
def test_headset_bundle_contains_runtime_and_licenses(self):
packed = frame_headset.bundle()
@@ -124,6 +269,7 @@ class Panel(unittest.TestCase):
with tarfile.open(fileobj=io.BytesIO(gzip.decompress(packed))) as archive:
names = set(archive.getnames())
self.assertTrue({'ui/framey_panel.py','ui/framey-host.js',
'ui/framey-events.js',
'ui/vendor/react-18.3.1.min.js','ui/vendor/react-dom-18.3.1.min.js',
'ui/vendor/REACT-LICENSE.txt','ui/vendor/REACT-DOM-LICENSE.txt'} <= names)
@@ -147,6 +293,15 @@ class Panel(unittest.TestCase):
self.assertIn(b'framey-bundle', body)
self.assertEqual(get('/framey/panel/15')[0], 404)
(self.folder/'dist/chunks').mkdir()
(self.folder/'dist/chunks/view.js').write_text('export const value = 1;')
status, headers, body = get('/framey/module/14/chunks/view.js')
self.assertEqual(status, 200)
self.assertEqual(headers['Access-Control-Allow-Origin'], 'null')
self.assertEqual(body, b'export const value = 1;')
self.assertEqual(get('/framey/module/14/chunks/../plugin.json')[0], 404)
self.assertEqual(get('/framey/module/15/chunks/view.js')[0], 404)
if __name__ == '__main__':
unittest.main()
+45 -1
View File
@@ -15,6 +15,24 @@ spec.loader.exec_module(check)
class Safety(unittest.TestCase):
def test_overlay_wait_is_bounded_and_stops_when_visible(self):
from unittest.mock import Mock
remote = Mock()
remote.python.side_effect = [{'state':{'visible':False}},
{'state':{'visible':False}},
{'state':{'visible':True}}]
with patch.object(check.time, 'sleep') as sleep:
self.assertTrue(check.wait_overlay(remote)['state']['visible'])
self.assertEqual(remote.python.call_count, 3)
self.assertEqual(sleep.call_count, 2)
remote.python.reset_mock(side_effect=True)
remote.python.return_value = {'state':{'visible':False}}
with patch.object(check.time, 'sleep') as sleep:
self.assertFalse(check.wait_overlay(remote, attempts=3)['state']['visible'])
self.assertEqual(remote.python.call_count, 3)
self.assertEqual(sleep.call_count, 2)
def test_foreign_lock_prevents_sensor_reads_and_cleanup(self):
with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory:
remote = factory.return_value
@@ -42,7 +60,8 @@ class Safety(unittest.TestCase):
def test_power_restore_after_partial_setter_failure(self):
state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true',
'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False}
'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False,
'stats':[{'activity_level':3}], 'proximity':[6.25]}
with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory:
remote = factory.return_value
remote.python.return_value = state
@@ -58,12 +77,37 @@ class Safety(unittest.TestCase):
self.assertTrue(any('--set-settings-float power.turnOffScreensTimeout 17.000000' in c for c in commands))
self.assertIn('rmdir /tmp/frame-test.lock', commands[-1])
def test_awake_or_unknown_activity_refuses_wake_and_install(self):
for stats in ([{'activity_level':1}], [{'activity_level':2}], [], None):
state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true',
'timeout':'power.turnOffScreensTimeout=17.000000',
'dashboardVisible':False, 'stats':stats, 'proximity':[0.0]}
for mode in ('--wake', '--exercise'):
with self.subTest(stats=stats, mode=mode), \
tempfile.TemporaryDirectory() as folder, \
patch.object(check, 'Remote') as factory, \
patch.object(check.frame_decky, 'change') as install:
remote = factory.return_value
remote.python.return_value = state
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(check.main([mode,'--output',folder]), 1)
install.assert_not_called()
commands = [c.args[0] for c in remote.call_args_list]
self.assertEqual(len(commands), 2)
self.assertIn('mkdir /tmp/frame-test.lock', commands[0])
self.assertIn('rmdir /tmp/frame-test.lock', commands[-1])
report = json.loads((Path(folder)/'result.json').read_text())
self.assertIn('active session untouched', report['error'])
def test_exact_linux_event_abi_and_debug_profile(self):
import ctypes
overlay_spec = importlib.util.spec_from_file_location('framey_openvr', PATH.with_name('framey_openvr.py'))
overlay = importlib.util.module_from_spec(overlay_spec); overlay_spec.loader.exec_module(overlay)
self.assertEqual(ctypes.sizeof(overlay.Event), 60)
self.assertEqual(overlay.Event.data.offset, 12)
self.assertEqual(ctypes.sizeof(overlay.Scroll), 16)
with self.assertRaises(ValueError):
object.__new__(overlay.Overlay).post_scroll(0, -3, 308)
original = check.frame_headset.bundle()
patched = check.debug_bundle(original, '/tmp/owned-profile')
import tarfile
+2 -1
View File
@@ -17,7 +17,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "publish-release.sh"
FAKEGH = ROOT / "tests" / "fakegh"
INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip", "Frame-Control-Setup-x64.exe",
INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip",
"Frame-Control-mac-x64.dmg", "Frame-Control-mac-x64.zip", "Frame-Control-Setup-x64.exe",
"Frame-Control-win-x64.zip", "Frame-Control-linux-x86_64.AppImage",
"Frame-Control-linux-arm64.AppImage", "Frame-Control-linux-amd64.deb",
"Frame-Control-linux-arm64.deb"]
+1 -1
View File
@@ -17,7 +17,7 @@ class Checksums(unittest.TestCase):
for i, name in enumerate(module.INSTALLERS):
assets.append({"name": name, "digest": "sha256:" + hashlib.sha256(str(i).encode()).hexdigest()})
actual = module.checksums({"assets": assets})
self.assertEqual(len(actual.splitlines()), 8)
self.assertEqual(len(actual.splitlines()), 10)
self.assertEqual(actual.splitlines()[0], hashlib.sha256(b"0").hexdigest() + " " + module.INSTALLERS[0])
def test_missing_duplicate_and_bad_digest_fail(self):
+20 -3
View File
@@ -426,10 +426,27 @@ class OneServer(unittest.TestCase):
# Stopped as the app stops it: by closing stdin (the only way on Windows,
# where terminate() is a hard kill) and, elsewhere, by SIGTERM too.
for stop in ["stdin"] + (["sigterm"] if os.name != "nt" else []):
for stop in ["stdin"] + (["sigterm", "stdin+late-sigterm"] if os.name != "nt" else []):
with self.subTest(stop=stop):
left_by_dead = [staged(tmp, "frame-vr-", dead.pid), staged(index_dir, ".download-", dead.pid)]
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
command = [sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"]
if stop == "stdin+late-sigterm":
# Deliver the app's second stop mechanism exactly as cleanup
# replaces the signal handler: the race observed on a real Mac.
wrapper = (
"import os, runpy, signal, sys; from pathlib import Path\n"
"original = signal.signal\n"
"def install(sig, handler):\n"
" if sig == signal.SIGTERM and handler == signal.SIG_IGN:\n"
" os.kill(os.getpid(), signal.SIGTERM)\n"
" return original(sig, handler)\n"
"signal.signal = install\n"
"sys.argv = sys.argv[1:]\n"
"sys.path.insert(0, str(Path(sys.argv[0]).parent))\n"
"runpy.run_path(sys.argv[0], run_name='__main__')\n"
)
command = [sys.executable, "-c", wrapper, *command[1:]]
proc = subprocess.Popen(command,
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
@@ -437,7 +454,7 @@ class OneServer(unittest.TestCase):
self.assertEqual([d for d in left_by_dead if d.exists()], [])
in_flight = [staged(tmp, "frame-vr-", proc.pid), staged(tmp, "frame-agent-", proc.pid),
staged(index_dir, ".download-", proc.pid)]
if stop == "stdin":
if stop.startswith("stdin"):
proc.stdin.close()
else:
proc.terminate()
+52 -1
View File
@@ -1,5 +1,6 @@
"""Smoke cleanup against real local Linux processes, never a headset or SSH."""
import sandbox # noqa: F401
import json
import os
from pathlib import Path
import shutil
@@ -139,11 +140,61 @@ class Safety(unittest.TestCase):
mock.patch.object(frame_smoke, 'cleanup', side_effect=RuntimeError('cannot stop')), \
mock.patch.object(frame_smoke, 'title_cycle') as cycle, \
mock.patch('builtins.print'):
self.assertEqual(frame_smoke.main(), 1)
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
{'activity':3,'proximity':[],'battery':[100]}), 1)
cycle.assert_not_called()
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
class SharedSmokeLock(unittest.TestCase):
def test_busy_lock_prevents_any_status_read_or_title_change(self):
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
mock.patch.object(frame_smoke, 'acquire_lock', return_value=False), \
mock.patch.object(frame_smoke, 'preflight') as preflight, \
mock.patch.object(frame_smoke, 'run_smoke') as run, \
mock.patch.object(frame_smoke, 'release_lock') as release:
self.assertEqual(frame_smoke.main(), 2)
preflight.assert_not_called()
run.assert_not_called()
release.assert_not_called()
def test_preflight_requires_standby_and_sufficient_battery(self):
for state in ({'activity':1,'proximity':[1],'battery':[100]},
{'activity':None,'proximity':[],'battery':[100]},
{'activity':3,'proximity':[],'battery':[19]},
{'activity':3,'proximity':[],'battery':[]}):
with self.subTest(state=state), mock.patch.object(frame_smoke, 'remote',
return_value=mock.Mock(returncode=0, stdout=json.dumps(state))):
with self.assertRaises(RuntimeError):
frame_smoke.preflight()
with mock.patch.object(frame_smoke, 'remote', return_value=mock.Mock(
returncode=0, stdout=json.dumps({'activity':3,'proximity':[0],'battery':[99]}))):
self.assertEqual(frame_smoke.preflight()['battery'], [99])
def test_owner_checked_on_release_and_preflight_failure_still_releases(self):
commands = []
def remote(command, **_):
commands.append(command)
return mock.Mock(returncode=0)
with mock.patch.object(frame_smoke, 'remote', side_effect=remote):
self.assertTrue(frame_smoke.acquire_lock('frame-smoke-test-token'))
self.assertTrue(frame_smoke.release_lock('frame-smoke-test-token'))
self.assertIn('mkdir /tmp/frame-test.lock', commands[0])
self.assertIn('> /tmp/frame-test.lock/owner', commands[0])
self.assertIn('test "$(cat /tmp/frame-test.lock/owner)" = frame-smoke-test-token', commands[1])
self.assertLess(commands[1].index('test '), commands[1].index('rm /tmp/frame-test.lock/owner'))
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
mock.patch.object(frame_smoke, 'acquire_lock', return_value=True), \
mock.patch.object(frame_smoke, 'preflight', side_effect=RuntimeError('awake')), \
mock.patch.object(frame_smoke, 'run_smoke') as run, \
mock.patch.object(frame_smoke, 'release_lock', return_value=True) as release:
self.assertEqual(frame_smoke.main(), 2)
run.assert_not_called()
release.assert_called_once()
self.assertTrue(release.call_args.args[0].startswith('frame-smoke-'))
@unittest.skipUnless(os.name == 'posix', 'local POSIX shell required')
class OrphanCleanup(unittest.TestCase):
"""Run the real cleanup shell commands in a throwaway home; mock Steam/SSH."""
+8 -2
View File
@@ -4,6 +4,7 @@ import json
import re
import secrets
import urllib.request
import framey_compatibility
REPO = "SteamDeckHomebrew/decky-loader"
@@ -38,7 +39,8 @@ def plugins(folder):
found.append({'folder': child.name, 'name': str(data.get('name', child.name))[:80],
'author': str(data.get('author', ''))[:80],
'storeId': owned.get('storeId') if isinstance(owned.get('storeId'), int) else None,
'version': str(owned.get('version', ''))[:40]})
'version': str(owned.get('version', ''))[:40],
'sha256': str(owned.get('sha256', ''))[:64]})
except (ValueError, OSError, UnicodeError): pass
return found
os_release = {}
@@ -154,7 +156,11 @@ finally:
def status(ssh):
return json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
result = json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
for plugin in result.get('plugins', []):
plugin['compatibility'] = framey_compatibility.lookup(
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'))
return result
def release_binary():
+34 -6
View File
@@ -13,6 +13,7 @@ import urllib.request
import zipfile
import frame_decky
import framey_compatibility
STORE_URL = 'https://plugins.deckbrew.xyz/plugins'
@@ -65,7 +66,10 @@ def catalog():
counts = {}
for item in result:
counts[item['id']] = counts.get(item['id'], 0) + 1
return [item for item in result if counts[item['id']] == 1]
unique = [item for item in result if counts[item['id']] == 1]
for item in unique:
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
return unique
def archive_info(data, expected_hash, expected_name):
@@ -119,7 +123,7 @@ def archive_info(data, expected_hash, expected_name):
APPLY = r'''
import fcntl,hashlib,json,os,pathlib,shutil,subprocess,sys,zipfile
action,token,plugin_id,digest,folder,version = sys.argv[1:]
action,token,plugin_id,digest,folder,version,expected_installed = sys.argv[1:]
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
unit=pathlib.Path.home()/'.config/systemd/user/frame-control-decky.service'
target=base/folder
@@ -147,6 +151,10 @@ try:
if action not in ('install','update','remove'): raise RuntimeError('Invalid plugin action')
if action=='install' and (target.exists() or target.is_symlink()): raise RuntimeError('Plugin folder already exists')
if action in ('update','remove') and not owned(): raise RuntimeError('Only a Frame Control store plugin can be changed')
if action in ('update','remove') and expected_installed!='-':
current=json.loads((target/marker).read_text()).get('sha256')
if current!=expected_installed:
raise RuntimeError('Installed plugin changed; refresh the installed list and try again')
running=subprocess.run(['systemctl','--user','is-active','--quiet','frame-control-decky.service']).returncode==0
if action!='remove':
data=archive.read_bytes()
@@ -171,6 +179,19 @@ try:
z.extractall(stage)
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
# Recheck under the transaction lock. Another store operation may
# have installed a colliding name while this archive downloaded.
proposed_name=json.loads((unpacked/'plugin.json').read_text()).get('name')
for peer in base.iterdir():
if peer==target or peer.is_symlink() or not peer.is_dir(): continue
manifest=peer/'plugin.json'
if manifest.is_symlink() or not manifest.is_file(): continue
try:
if manifest.stat().st_size>16384: continue
peer_data=json.loads(manifest.read_text())
if isinstance(peer_data,dict) and peer_data.get('name')==proposed_name:
raise RuntimeError('A different plugin with this name is already installed')
except (OSError,ValueError,UnicodeError): continue
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
# Stop even a failed unit before removal to cancel any pending auto-restart.
if running or action=='remove': service('stop','frame-control-decky.service')
@@ -222,9 +243,14 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
raise ValueError('The official store version changed; refresh the store and review it again')
if plugin['root']:
raise ValueError('Root plugins are not supported by the Frame user service')
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']]
if action == 'install' and existing_name:
raise ValueError('A plugin with this name is already installed')
if plugin.get('compatibility', {}).get('blockInstall'):
raise ValueError(plugin['compatibility']['reason'])
# Decky routes backend calls by plugin name. A store update may rename
# its plugin, so check names for updates as well as fresh installs.
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']
and (action == 'install' or p.get('storeId') != plugin_id)]
if existing_name:
raise ValueError('A different plugin with this name is already installed')
if action == 'update' and len(installed) != 1:
raise ValueError('Only an installed Frame Control store plugin can be updated')
data = None
@@ -239,6 +265,8 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
raise ValueError('The store changed its plugin folder; remove the old version before installing')
if not SAFE_FOLDER.fullmatch(folder):
raise ValueError('Unsafe installed plugin folder')
previous = installed[0].get('sha256') if action != 'install' else None
expected_installed = previous if isinstance(previous, str) and SHA.fullmatch(previous) else '-'
token = secrets.token_hex(12)
remote = '/tmp/frame-control-decky-plugin-' + token
try:
@@ -246,7 +274,7 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
report('Sending archive to Frame', 65)
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
report({'install':'Installing', 'update':'Updating', 'remove':'Removing'}[action] + ' plugin on Frame', 80)
return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]}',
return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]} {expected_installed}',
stdin=APPLY, timeout=90))
finally:
if data is not None:
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
/* Executed in Steam CEF, using Decky 3.3.0's loader/plugin_event envelope. */
(function(payload) {
const backend = window.DeckyBackend;
if (!backend?.addEventListener || !backend?.removeEventListener)
throw Error('Decky backend events are unavailable');
let bridge = window.__frameyEventBridgeV1;
if (bridge && bridge.backend !== backend) {
for (const key of [...bridge.sessions.keys()]) bridge.remove(key);
bridge = null;
}
if (!bridge) {
const sessions = new Map();
const remove = key => {
const item = sessions.get(key);
if (item) backend.removeEventListener('loader/plugin_event', item.listener);
sessions.delete(key);
if (!sessions.size) {clearInterval(bridge.timer); bridge.timer = null;}
};
bridge = window.__frameyEventBridgeV1 = {backend, sessions, remove, timer:null};
}
const key = payload.session;
let item = bridge.sessions.get(key);
if (item && item.name !== payload.name) throw Error('Framey event session belongs to another plugin');
if (!payload.events.length) {
bridge.remove(key);
return {events:[], cursor:0};
}
if (!item) {
if (bridge.sessions.size >= 4) throw Error('Too many Framey event sessions');
item = {name:payload.name, names:new Set(), queue:[], sequence:0, touched:Date.now()};
item.listener = data => {
if (data?.plugin !== item.name || !item.names.has(data.event) || !Array.isArray(data.args)) return;
try {
const encoded = JSON.stringify(data.args);
if (encoded.length > 8192) return;
item.queue.push({sequence:++item.sequence, event:data.event, args:JSON.parse(encoded)});
// Bound both event count and the CDP response, even for a noisy plugin.
while (item.queue.length > 64 || JSON.stringify(item.queue).length > 48*1024) item.queue.shift();
} catch (_) {} // Non-JSON event data cannot cross the iframe bridge.
};
bridge.sessions.set(key, item);
backend.addEventListener('loader/plugin_event', item.listener);
if (!bridge.timer) bridge.timer = setInterval(() => {
for (const [session, state] of bridge.sessions)
if (Date.now() - state.touched > 15000) bridge.remove(session);
}, 5000);
}
item.names = new Set(payload.events);
item.touched = Date.now();
return {events:item.queue.filter(event => event.sequence > payload.cursor && item.names.has(event.event)),
cursor:item.sequence};
})
+220 -23
View File
@@ -11,6 +11,11 @@
: R.createElement(type, elementProps);
};
window.SP_JSX = {jsx, jsxs:jsx, Fragment:R.Fragment};
// Let plugins use optional chaining for Steam-only globals without inventing
// user identity or suspend state in this isolated document.
for (const name of ['App','appStore','appInfoStore','collectionStore']) window[name] = undefined;
window.SuspendResumeStore = Object.freeze({});
window.SteamUIStore = Object.freeze({RunningApps:Object.freeze([]), MainRunningApp:undefined});
// Steam normally fills these token maps. Official plugins can safely use
// their own fallback strings when the isolated panel has no Steam tokens.
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en'],
@@ -29,6 +34,9 @@
let currentParams = {};
let nextId = 0;
let plugin;
const eventListeners = new Map();
let eventCursor = 0, eventRevision = 0, subscribedRevision = -1;
let eventRequest = null, eventTimer = null, eventClosed = false;
// A sandboxed iframe has an opaque origin, so its native localStorage getter
// throws SecurityError. Keep the familiar synchronous API inside the iframe;
// the parent stores only this plugin's bounded snapshot under its own origin.
@@ -63,15 +71,66 @@
parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*');
setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable.
function ask(method, args, legacy=false) {
function request(method, args, legacy=false, type='framey:call') {
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
const id = ++nextId;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { pending.delete(id); reject(Error('Decky backend timed out')); }, 30000);
pending.set(id, {resolve, reject, timer});
parent.postMessage({type:'framey:call', id, method, args, legacy}, '*');
parent.postMessage({type, id, method, args, legacy, session:cacheSession}, '*');
});
}
function syncEvents() {
if (eventClosed) return Promise.resolve();
if (eventRequest) return eventRequest.then(() =>
subscribedRevision !== eventRevision ? syncEvents() : undefined);
const revision = eventRevision;
eventRequest = request('events', [[...eventListeners.keys()], eventCursor], false, 'framey:events')
.then(data => {
subscribedRevision = revision;
if (!data || !Array.isArray(data.events) || !Number.isSafeInteger(data.cursor))
throw Error('Invalid Decky event response');
eventCursor = data.cursor;
for (const event of data.events) {
if (!Array.isArray(event.args)) continue;
for (const listener of eventListeners.get(event.event) || [])
Promise.resolve().then(() => listener(...event.args)).catch(failure =>
console.warn('Decky event listener failed', failure));
}
}).finally(() => {eventRequest = null;});
return eventRequest.then(() => subscribedRevision !== eventRevision ? syncEvents() : undefined);
}
function refreshEvents() {
clearTimeout(eventTimer);
syncEvents().catch(failure => console.warn('Decky event bridge unavailable', failure.message))
.finally(() => {
clearTimeout(eventTimer);
if (!eventClosed && eventListeners.size)
eventTimer = setTimeout(refreshEvents, 1000);
});
}
function addPluginEvent(event, listener) {
if (typeof event !== 'string' || !/^[A-Za-z0-9_:/.-]{1,128}$/.test(event) || typeof listener !== 'function')
throw Error('Invalid Decky event listener');
if (!eventListeners.has(event)) {
if (eventListeners.size >= 32) throw Error('Too many Decky event names');
eventListeners.set(event, new Set());
}
eventListeners.get(event).add(listener);
eventRevision++; refreshEvents();
return listener; // Decky returns the registered callback, not a disposer.
}
function removePluginEvent(event, listener) {
const listeners = eventListeners.get(event);
if (!listeners?.delete(listener)) return;
if (!listeners.size) eventListeners.delete(event);
eventRevision++; refreshEvents();
}
async function ask(method, args, legacy=false) {
// Subscribe before a backend call can emit its immediate response events.
if (eventListeners.size && (eventRequest || subscribedRevision !== eventRevision)) await syncEvents();
return request(method, args, legacy);
}
addEventListener('message', event => {
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
event.data.session === cacheSession && acceptSeed) {
@@ -90,7 +149,12 @@
pending.delete(event.data.id); clearTimeout(item.timer);
if (event.data.error) item.reject(Error(event.data.error)); else item.resolve(event.data.result);
});
addEventListener('unload', () => { try { plugin?.onDismount?.(); } catch (_) {} });
addEventListener('unload', () => {
eventClosed = true; clearTimeout(eventTimer);
try { plugin?.onDismount?.(); } catch (_) {}
// Parent tears down the remote session on close; CEF also expires it after 15s.
parent.postMessage({type:'framey:events:close', session:cacheSession}, '*');
});
addEventListener('pointerup', event => {
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
}, {passive:true});
@@ -128,12 +192,19 @@
mustBeNumeric, mustBeURL, mustBeEmail, onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:bIsPassword?'password':mustBeNumeric?'number':mustBeURL?'url':mustBeEmail?'email':'text',
value, placeholder, disabled:!!disabled, onChange:e=>{if(!disabled)onChange?.(e);}}));
const SliderField = ({label, description, value, min=0, max=100, step=1, disabled, showValue,
const SliderField = ({label, description, value, min=0, max=100, step=1, disabled, showValue, editableValue,
valueSuffix='', onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:'range', value, min, max, step, disabled:!!disabled,
'aria-label':typeof label==='string'?label:undefined,
onChange:e=>{if(!disabled)onChange?.(Number(e.target.value));}}),
showValue && R.createElement('span', null, String(value) + valueSuffix));
editableValue ? R.createElement('span', null,
R.createElement('input', {type:'number', value, min, max, step, disabled:!!disabled,
'aria-label':typeof label==='string'?`${label} value`:'Slider value',
style:{width:'7ch'}, onChange:e=>{
const raw=e.target.value, next=Number(raw);
if(!disabled && raw.trim()!=='' && e.target.validity?.valid!==false &&
Number.isFinite(next) && next>=Number(min) && next<=Number(max)) onChange?.(next);
}}), valueSuffix) : showValue && R.createElement('span', null, String(value) + valueSuffix));
const PanelSection = ({title, children}) => R.createElement('section', null,
title && R.createElement('h2', null, title), children);
const Spinner = ({children}) => R.createElement('p', {role:'status'}, children || 'Loading…');
@@ -217,6 +288,50 @@
R.createElement('button', {type:'button',onClick:closeModal}, 'Close'), element));
return {Close:closeModal};
};
// These are Framey's own styles and components, never Steam's webpack cache.
// Keep the registry finite: an unknown Steam implementation stays unavailable.
const classModules = new Map(Object.entries({
'framey/spinner': {SpinnerLoaderContainer:'framey-spinner', ContainerBackground:'framey-loading',
LoadingStatus:'framey-status', Medium:'framey-medium'},
'framey/footer': {QuickAccessFooter:'framey-footer', BasicFooter:'framey-footer'},
'framey/app-details': {AppDetailSectionList:'framey-sections', AppActionButton:'framey-action'},
'framey/play-button': {PlayButtonContainer:'framey-actions', ButtonChild:'framey-button-child',
Green:'framey-green', PlayButton:'framey-action'},
'framey/library-image': {LongTitles:'framey-long-title', GreyBackground:'framey-muted', Image:'framey-image'},
'framey/library': {GamepadLibrary:'framey-library'},
'framey/focus': {FocusRingRoot:'framey-focus', FocusRing:'framey-focus'},
}).map(([id, classes]) => [id, Object.freeze(classes)]));
const localModules = new Map(classModules);
const moduleVariants = module => [module?.default, module].filter(value => value != null);
function findModule(filter) {
for (const module of localModules.values())
for (const value of moduleVariants(module)) if (filter(value)) return value;
}
function findModuleDetailsByExport(filter, minExports) {
for (const [id, module] of localModules)
for (const value of moduleVariants(module)) {
if (typeof value !== 'object' || (minExports && Object.keys(value).length < minExports)) continue;
for (const name in value) {
if (!value[name]) continue;
// DFL tolerates filters that dereference an incompatible export.
try { if (filter(value[name], name)) return [value, value[name], name, id]; } catch (_) {}
}
}
return [undefined, undefined, undefined, undefined];
}
function findModuleChild(filter) {
for (const module of localModules.values())
for (const value of moduleVariants(module)) {
const result = filter(value);
if (result) return result;
}
}
function createModuleMapping(filter) {
const mapping = new Map();
for (const [id, module] of localModules)
for (const value of moduleVariants(module)) if (filter(value)) mapping.set(id, value);
return mapping;
}
const DFL = window.DFL = {
definePlugin: init => init,
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem,
@@ -225,10 +340,12 @@
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
SuspensefulImage:({alt='', ...props})=>R.createElement('img', {...props, alt}),
Marquee:passthrough('span'), DialogBody:passthrough('div'), DialogBodyText:passthrough('p'),
DialogHeader:passthrough('h2'), DialogSubHeader:passthrough('h3'), DialogFooter:passthrough('footer'),
DialogLabel:passthrough('label'), DialogControlsSection:passthrough('section'),
DialogControlsSectionHeader:passthrough('h3'), ModalRoot:passthrough('section'),
ModalPosition:passthrough('div'),
Menu:passthrough('div'), MenuGroup:passthrough('section'), MenuItem,
ProgressBar, ProgressBarWithInfo:ProgressBar,
gamepadDialogClasses:{}, quickAccessMenuClasses:{}, gamepadTabbedPageClasses:{},
@@ -242,12 +359,30 @@
showModal, showContextMenu:showModal,
// Steam app, library, and store patches have no target inside this panel.
afterPatch:noPatch, beforePatch:noPatch, replacePatch:noPatch, createReactTreePatcher:noPatch,
findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null,
findModule:()=>null, findModuleByExport:()=>null, findClassModule:()=>null,
findClass:()=>null, findSP:()=>null, fakeRenderComponent:()=>null,
findInReactTree: () => null, findModule, findModuleChild, findModuleDetailsByExport,
findModuleByExport:(filter, minExports)=>findModuleDetailsByExport(filter,minExports)[0],
findModuleExport:(filter, minExports)=>findModuleDetailsByExport(filter,minExports)[1],
createModuleMapping, findAllModules:filter=>[...createModuleMapping(filter).values()],
findClassModule:filter=>[...classModules.values()].find(filter),
findClass:(id,name)=>classModules.get(id)?.[name],
findClassByName:name=>[...classModules.values()].find(classes=>classes[name])?.[name],
unminifyClass:token=>{
for(const classes of classModules.values())
for(const name of Object.keys(classes)) if(classes[name]===token)return name;
},
findSP:()=>null, fakeRenderComponent:()=>null,
appDetailsClasses: {}, appDetailsHeaderClasses: {},
useParams: () => currentParams,
};
localModules.set('framey/components', Object.freeze(Object.fromEntries(
Object.entries(DFL).filter(([name,value])=>typeof value==='function' && /^[A-Z]/.test(name)))));
// Some panels eagerly look up the optional Steam UI sound player. Allow the
// lookup, but never claim playback or expose Steam's mutable audio store.
localModules.set('framey/ui-audio', Object.freeze({UIAudio:Object.freeze({
GamepadUIAudio:Object.freeze({available:false, AudioPlaybackManager:Object.freeze({
PlayAudioURL:()=>{throw Error('Steam UI sound playback is not supported in Framey');},
})}),
})}));
// No Steam game is attached to an isolated panel. Subscriptions have a
// disposable handle but emit no fabricated game, power, or input events.
const subscription = () => {const dispose=()=>{};dispose.unregister=dispose;return dispose;};
@@ -267,17 +402,33 @@
Settings:{GetCurrentLanguage:async()=>steamLanguage(),
...register(['RegisterForSettingsChanges'])},
Apps:register(['RegisterForAppDetails','RegisterForGameActionTaskChange',
'RegisterForGameActionStart','RegisterForGameActionEnd']),
'RegisterForGameActionStart','RegisterForGameActionEnd','RegisterForGameActionUserRequest']),
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
UI:register(['RegisterForFocusChangeEvents'])},
GetSystemInfo:()=>request('GetSystemInfo',[],false,'framey:steam'),
UI:register(['RegisterForFocusChangeEvents','RegisterForSystemKeyEvents'])},
Input:register(['RegisterForControllerInputMessages','RegisterForControllerStateChanges']),
User:register(['RegisterForLoginStateChange','RegisterForPrepareForSystemSuspendProgress',
User:{...register(['RegisterForLoginStateChange','RegisterForPrepareForSystemSuspendProgress',
'RegisterForResumeSuspendedGamesProgress','RegisterForCurrentUserChanges']),
GetIPCountry:()=>request('GetIPCountry',[],false,'framey:steam')},
InstallFolder:{GetInstallFolders:()=>request('GetInstallFolders',[],false,'framey:steam')},
Overlay:register(['RegisterForOverlayActivated']),
UI:register(['RegisterForUIModeChanged']),
// This is private plugin cache, not Steam's account/global JSON store.
Storage:{
GetJSON:async key=>{
const value=storage.getItem('steam-client:'+String(key));
if(value===null)throw Error('Not found');
return value;
},
SetObject:async(key,value)=>{
const encoded=JSON.stringify(value);
if(encoded===undefined)throw Error('SteamClient.Storage requires a JSON value');
storage.setItem('steam-client:'+String(key),encoded);
},
},
};
DFL.Router = {...DFL.Navigation, MainRunningApp:undefined};
DFL.Router = {...DFL.Navigation, ...window.SteamUIStore};
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
const toaster = {toast: ({title, body, subtext, icon, logo, duration, showToast=true, onClick}={}) => {
const data = {title, body, subtext, icon, logo};
@@ -331,23 +482,27 @@
const [route, setRoute] = R.useState(currentRoute);
R.useEffect(() => {routeListeners.add(setRoute); return () => routeListeners.delete(setRoute);}, []);
const [running, setRunning] = R.useState(null);
const runningRef = R.useRef(false);
const [message, setMessage] = R.useState('');
const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions.slice(0, 8)
const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions
.filter(action => action && typeof action.label === 'string' && action.label.length <= 80 &&
typeof action.onClick === 'function') : [];
typeof action.onClick === 'function').slice(0, 8) : [];
async function runAction(action, index) {
if (running !== null) return;
// React state may not render between two controller clicks. Lock before
// awaiting a backend action so one tap cannot start a second mutation.
if (runningRef.current) return;
runningRef.current = true;
setRunning(index); setMessage(`${action.label}…`);
try {
const result = await action.onClick();
setMessage(typeof result === 'string' && result.length < 200 ? result : `${action.label} done`);
} catch (failure) { setMessage(`${action.label} failed: ${failure.message || failure}`); }
finally { setRunning(null); }
finally { runningRef.current = false; setRunning(null); }
}
return R.createElement(R.Fragment, null,
plugin.titleView && R.createElement('header', null, plugin.titleView),
route && R.createElement('button', {type:'button', onClick:()=>navigate(null)}, 'Back to plugin'),
plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'},
plugin.content && plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'},
R.createElement('button', {type:'button', 'aria-current':view === 'panel' ? 'page' : undefined,
onClick:()=>setView('panel')}, 'Panel'),
R.createElement('button', {type:'button', 'aria-current':view === 'settings' ? 'page' : undefined,
@@ -366,7 +521,7 @@
_version: Math.min(version, 2),
call: (method, ...args) => ask(method, args),
callable: method => (...args) => ask(method, args),
addEventListener: () => {}, removeEventListener: () => {},
addEventListener:addPluginEvent, removeEventListener:removePluginEvent,
routerHook, toaster,
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
executeInTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
@@ -386,17 +541,59 @@
},
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
};
function setLibrarySnapshot(snapshot) {
// These are detached plugin-local values. Patching an overview or its Map
// must never mutate Steam's library. Live MobX observations are unsupported.
if (!snapshot || !Array.isArray(snapshot.apps) || snapshot.apps.length > 10000 ||
!Array.isArray(snapshot.gameIds) || snapshot.gameIds.length > 10000 ||
(snapshot.steamId != null && (typeof snapshot.steamId !== 'string' || !/^\d{17}$/.test(snapshot.steamId))))
throw Error('Invalid Steam library snapshot');
const apps = snapshot.apps.map(row => {
if (!Array.isArray(row) || row.length !== 8 || !Number.isSafeInteger(row[0]) || row[0] <= 0 ||
typeof row[1] !== 'string' || row[1].length > 2048 ||
(row[2] != null && typeof row[2] !== 'string') || !Number.isSafeInteger(row[3]) ||
typeof row[4] !== 'string' || !/^\d{1,20}$/.test(row[4]) ||
row.slice(5).some(value=>value != null && (!Number.isFinite(value) || value < 0)))
throw Error('Invalid Steam app overview');
const [appid,display_name,sort_as,app_type,gameid,
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played] = row;
return {appid,display_name,sort_as,app_type,gameid,
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played};
});
const map = new Map(apps.map(app=>[app.appid,app]));
if (map.size !== apps.length || snapshot.gameIds.some(id=>!Number.isSafeInteger(id) || !map.has(id)))
throw Error('Invalid Steam game collection');
window.appStore = {allApps:apps, m_mapApps:map,
GetAppOverviewByAppID:id=>map.get(Number(id)),
GetAppOverviewByGameID:id=>apps.find(app=>app.gameid === String(id)),
};
window.collectionStore = {allGamesCollection:{allApps:snapshot.gameIds.map(id=>map.get(id))}};
window.App = snapshot.steamId ? {m_CurrentUser:{strSteamID:snapshot.steamId}} : undefined;
}
async function start() {
try {
await seeded;
const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0));
const bundleNode = document.getElementById('framey-bundle');
const bytes = Uint8Array.from(atob(bundleNode.textContent), c=>c.charCodeAt(0));
const bundleText = new TextDecoder().decode(bytes);
// Only panels referencing library globals need the extra read. A failed
// read leaves them unavailable rather than fabricating an empty library.
if (/\b(?:appStore|collectionStore|m_CurrentUser)\b/.test(bundleText)) {
try {setLibrarySnapshot(await request('GetLibrarySnapshot',[],false,'framey:steam'));}
catch (failure) {console.warn('Framey Steam library unavailable', failure.message);}
}
// Old Decky builds are expressions returning the initializer, rather than
// ES modules. A module wrapper preserves the sandbox and avoids eval.
const legacy = /^\s*\(function\s*\(/.test(new TextDecoder().decode(bytes));
const url = URL.createObjectURL(new Blob(legacy ?
['const __frameyLegacy =\n', bytes, '\nexport default __frameyLegacy;'] : [bytes], {type:'text/javascript'}));
const legacy = /^\s*\(function\s*\(/.test(bundleText);
let module;
try { module = await import(url); } finally { URL.revokeObjectURL(url); }
if (bundleNode.dataset.module && !legacy) {
// A hierarchical URL lets official Vite chunks resolve relative imports.
module = await import(new URL(bundleNode.dataset.module, location.href).href);
} else {
const url = URL.createObjectURL(new Blob(legacy ?
['const __frameyLegacy =\n', bytes, '\nexport default __frameyLegacy;'] : [bytes], {type:'text/javascript'}));
try { module = await import(url); } finally { URL.revokeObjectURL(url); }
}
if (typeof module.default !== 'function') throw Error('The Decky frontend has no plugin entry point');
plugin = await module.default(legacyServerAPI);
if (legacy && plugin?.title && !plugin.titleView) plugin.titleView = plugin.title;
+28
View File
@@ -0,0 +1,28 @@
"""Digest-scoped observations, never a trust or execution guarantee."""
import json
from functools import lru_cache
from pathlib import Path
DATA = Path(__file__).with_name('framey-compatibility.json')
@lru_cache(maxsize=2)
def _records(path):
return json.loads(path.read_text())['plugins']
def lookup(plugin_id, digest, version):
unknown = {'status': 'unverified', 'reason': 'This exact store version has not been audited on Frame.',
'verification': [], 'blockInstall': False}
if type(plugin_id) is not int or not isinstance(digest, str) or not isinstance(version, str):
return unknown
try:
records = _records(DATA)
matches = [p for p in records if p['id'] == plugin_id and p['sha256'] == digest and p['version'] == version]
if len(matches) != 1:
return unknown
record = matches[0]
return {key: record[key] for key in ('status', 'reason', 'verification', 'blockInstall')}
except (OSError, ValueError, KeyError, TypeError):
return unknown
+146 -12
View File
@@ -9,6 +9,9 @@ import frame_decky
METHOD = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,63}\Z')
EVENT = re.compile(r'[A-Za-z0-9_:/.-]{1,128}\Z')
SESSION = re.compile(r'[A-Za-z0-9_.-]{1,100}\Z')
MODULE_PATH = re.compile(r'[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*/[A-Za-z0-9_.-]+\.js\Z|index\.js\Z')
PLUGINS = Path.home()/'.local/share/frame-control/decky/plugins'
@@ -16,8 +19,8 @@ class PanelError(ValueError):
pass
def local_bundle(plugin_id):
"""Read a modern Decky bundle with a store receipt; reject linked paths."""
def _owned_dist(plugin_id):
"""Locate a compatible store-owned frontend without following linked paths."""
if type(plugin_id) is not int or plugin_id <= 0 or not PLUGINS.is_dir() or PLUGINS.is_symlink():
raise FileNotFoundError('Framey plugin unavailable')
for folder in PLUGINS.iterdir():
@@ -53,24 +56,71 @@ def local_bundle(plugin_id):
legacy = re.match(rb'\s*\(function\s*\(', bundle) is not None
if version is None and not legacy:
raise FileNotFoundError('This legacy frontend is not a supported Decky IIFE bundle')
if version == 1 and not legacy and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle:
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
return bundle
if version == 1 and not legacy:
marker = b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit'
supported = marker in bundle
if not supported and b'./chunks/' in bundle:
chunks = dist/'chunks'
if chunks.is_symlink() or not chunks.is_dir():
raise FileNotFoundError('Framey plugin chunks unavailable')
files = list(chunks.iterdir())
if len(files) > 128:
raise FileNotFoundError('Framey plugin has too many chunks')
for chunk in files:
if chunk.suffix != '.js':
continue
if chunk.is_symlink() or not chunk.is_file() or chunk.stat().st_size > 8*1024*1024:
raise FileNotFoundError('Framey plugin chunk unavailable')
if marker in chunk.read_bytes():
supported = True
if not supported:
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
return dist, bundle
except (OSError, ValueError):
raise FileNotFoundError('Framey plugin files are invalid')
raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store')
def local_bundle(plugin_id):
"""Read a modern Decky bundle with a store receipt; reject linked paths."""
return _owned_dist(plugin_id)[1]
def local_module(plugin_id, name):
"""Serve only JavaScript within a store-owned dist directory for module imports."""
if not isinstance(name, str) or not MODULE_PATH.fullmatch(name):
raise FileNotFoundError('Framey module path unavailable')
dist, bundle = _owned_dist(plugin_id)
if name == 'index.js':
return bundle
target = dist
for part in name.split('/'):
target = target/part
if target.is_symlink():
raise FileNotFoundError('Framey module path unavailable')
if not target.is_file() or target.stat().st_size > 8*1024*1024:
raise FileNotFoundError('Framey module unavailable')
return target.read_bytes()
def panel(plugin_id):
"""The plugin receives no Frame UI key and is sandboxed to an opaque origin."""
encoded = base64.b64encode(local_bundle(plugin_id)).decode('ascii')
bundle = local_bundle(plugin_id)
encoded = base64.b64encode(bundle).decode('ascii')
module = f' data-module="/framey/module/{plugin_id}/index.js"' if b'./chunks/' in bundle else ''
return ('''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Framey plugin</title><style>body{font:21px/1.4 system-ui;background:#0d1925;color:#f3f7fa;margin:0;padding:20px}
button,select,input{font:inherit;min-height:52px;background:#23618c;color:#fff;border:1px solid #72baf0;border-radius:9px;padding:8px}
button:focus-visible,select:focus-visible,input:focus-visible{outline:4px solid #f8d370}button{margin:4px 8px 4px 0}button:disabled{opacity:.55}section{padding:12px;margin:12px 0;background:#1b3042;border-radius:10px}
label{display:grid;gap:6px;margin:10px 0}small{color:#b9c9d6}</style>
label{display:grid;gap:6px;margin:10px 0}small{color:#b9c9d6}
.framey-spinner,.framey-loading{display:grid;place-items:center;min-height:80px}.framey-loading{background:#1b3042}
.framey-status{color:#b9c9d6}.framey-medium{font-size:1em}.framey-footer{padding:12px 0}
.framey-sections,.framey-library{display:flex;flex-direction:column;gap:12px}.framey-actions{display:flex;flex-wrap:wrap;gap:8px}
.framey-action{min-height:52px}.framey-button-child{display:inline-flex;align-items:center;gap:8px}.framey-green{background:#246b42}
.framey-long-title{overflow-wrap:anywhere}.framey-muted{background:#1b3042}.framey-image{max-width:100%;object-fit:contain}
.framey-focus:focus-visible{outline:4px solid #f8d370}</style>
<p id="framey-error" role="status"></p><div id="framey-toasts" aria-live="polite"></div><div id="framey-root"></div><div id="framey-modal"></div>
<script id="framey-bundle" type="application/json">''' + encoded + '''</script>
<script id="framey-bundle" type="application/json"''' + module + '''>''' + encoded + '''</script>
<script src="/framey-runtime/react.js"></script><script src="/framey-runtime/react-dom.js"></script>
<script src="/framey-runtime/host.js"></script></html>''').encode()
@@ -79,7 +129,7 @@ METHOD_SCRIPT = r'''
import json, pathlib, sys
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
from frame_steam import Page
payload = json.loads(sys.argv[1])
payload = json.loads(__FRAMEY_METHOD_PAYLOAD__)
route = 'loader/call_legacy_plugin_method' if payload.get('legacy') else 'loader/call_plugin_method'
arguments = [payload['name'], payload['method'], *payload['args']]
expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call(" + ','.join(json.dumps(x) for x in [route, *arguments]) + ')})()'
@@ -94,7 +144,7 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
raise PanelError('Choose an installed Decky store plugin')
if not isinstance(method, str) or not METHOD.fullmatch(method):
raise PanelError('Invalid plugin method')
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args)) > 8192:
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args).encode('utf-8')) > 256*1024:
raise PanelError('Plugin method arguments are too large')
if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))):
raise PanelError('Legacy plugin methods need one keyword argument object')
@@ -102,5 +152,89 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
if len(found) != 1:
raise PanelError('The plugin is not managed by the Frame Control Decky store')
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)),
stdin=METHOD_SCRIPT, timeout=25))
# Library-sized arguments can exceed the OS limit for a single SSH command
# argument. Send them as a quoted Python literal on stdin instead.
script = METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__', repr(json.dumps(payload)))
return json.loads(ssh('python3 -', stdin=script, timeout=25))
# Only parameterless read APIs. Never resolve a plugin-supplied property path
# against SteamClient or evaluate a plugin-supplied expression in trusted CEF.
STEAM_READS = {
'GetIPCountry': 'SteamClient.User.GetIPCountry()',
'GetInstallFolders': 'SteamClient.InstallFolder.GetInstallFolders()',
'GetSystemInfo': 'SteamClient.System.GetSystemInfo()',
# Copy selected scalar fields, never Steam objects, methods, credentials or
# paths. Only the current public Steam ID is needed for per-user plugin data.
# Compact rows keep even a large library inside the response budget.
'GetLibrarySnapshot': r'''(()=>{
if (!window.appStore?.allApps || !window.collectionStore?.allGamesCollection?.allApps)
throw Error('Steam library is unavailable');
const apps = Array.from(appStore.allApps);
if (apps.length > 10000) throw Error('Steam library exceeds the Framey snapshot limit');
return {apps:apps.map(a=>[a.appid,a.display_name,a.sort_as,a.app_type,String(a.gameid),
a.minutes_playtime_forever,a.minutes_playtime_last_two_weeks,a.rt_last_time_played]),
gameIds:Array.from(collectionStore.allGamesCollection.allApps,a=>a.appid),
steamId:window.App?.m_CurrentUser?.strSteamID || null};
})()''',
}
STEAM_READ_SCRIPT = r'''
import json, pathlib, sys
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
from frame_steam import Page
page = Page()
try:
encoded = json.dumps({'result':page.eval(__FRAMEY_STEAM_READ__)})
if len(encoded.encode('utf-8')) > __FRAMEY_STEAM_LIMIT__:
raise ValueError('Steam read response is too large')
print(encoded)
finally: page.sock.close()
'''
def read_steam(ssh, plugin_id, method):
"""Read one explicitly supported Steam API for an installed store panel."""
if type(plugin_id) is not int or plugin_id <= 0:
raise PanelError('Choose an installed Decky store plugin')
if not isinstance(method, str) or method not in STEAM_READS:
raise PanelError('This Steam API is unavailable in Framey')
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
if len(found) != 1:
raise PanelError('The plugin is not managed by the Frame Control Decky store')
expression = '(async()=>await ' + STEAM_READS[method] + ')()'
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
'__FRAMEY_STEAM_LIMIT__', str(2*1024*1024 if method == 'GetLibrarySnapshot' else 256*1024))
return json.loads(ssh('python3 -', stdin=script, timeout=25))
EVENT_SCRIPT = r'''
import json, pathlib, sys
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
from frame_steam import Page
payload = json.loads(sys.argv[1])
source = __FRAMEY_EVENTS_SOURCE__
page = Page()
try: print(json.dumps(page.eval('(' + source + ')(' + json.dumps(payload) + ')')))
finally: page.sock.close()
'''
def poll_events(ssh, plugin_id, session, events, cursor=0):
"""Read only this store-owned plugin's subscribed events; never trust its claimed name."""
if type(plugin_id) is not int or plugin_id <= 0:
raise PanelError('Choose an installed Decky store plugin')
if not isinstance(session, str) or not SESSION.fullmatch(session):
raise PanelError('Invalid Framey event session')
if (not isinstance(events, list) or len(events) > 32 or
any(not isinstance(event, str) or not EVENT.fullmatch(event) for event in events)):
raise PanelError('Invalid Decky event names')
if type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
raise PanelError('Invalid Decky event cursor')
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
if len(found) != 1:
raise PanelError('The plugin is not managed by the Frame Control Decky store')
payload = {'name':found[0]['name'], 'session':session, 'events':events, 'cursor':cursor}
# Embed only our bundled source as a Python string; plugin data stays in JSON.
source = Path(__file__).with_name('framey-events.js').read_text(encoding='utf-8')
script = EVENT_SCRIPT.replace('__FRAMEY_EVENTS_SOURCE__', repr(source))
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)), stdin=script, timeout=25))
+62 -16
View File
@@ -50,10 +50,11 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
<p id="deckyJobStatus" role="status" aria-live="polite"></p><progress id="deckyJobProgress" max="100"></progress>
<div class="row"><button id="deckyJobCheck" class="secondary" hidden>Check result</button></div></div>
<div id="deckyStorePanel" hidden><div class="row" style="margin:16px 0"><label>Find a plugin <input id="deckySearch" type="search" placeholder="Name, author or description"></label>
<label>Show <select id="deckyStoreFilter"><option value="all">All plugins</option><option value="compatible">Without root requirement</option><option value="installed">Installed plugins</option></select></label></div>
<label>Show <select id="deckyStoreFilter"><option value="all">All plugins</option><option value="compatible">No known install blocker</option><option value="installed">Installed plugins</option></select></label></div>
<p id="deckyStoreSummary" role="status" aria-live="polite"></p><div id="deckyStoreList" class="stack"></div>
<div class="row" style="margin-top:14px"><button id="deckyStorePrevious" class="secondary">Previous</button><span id="deckyStorePage"></span><button id="deckyStoreNext" class="secondary">Next</button></div></div>
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button></div>
<p id="frameyCompatibility"></p>
<p id="frameyPanelStatus" role="status">Loading plugin panel…</p>
<iframe id="frameyFrame" title="Framey Decky plugin panel" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer"></iframe></div>
<h2>Extensions</h2><p>Local additions run only when opened in the isolated panel below. They can read status but cannot change headset settings through the extension API.</p>
@@ -103,21 +104,32 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}};
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
}catch(error){list.textContent=error.message;}}
function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
function deckyJobPending(){return !!sessionStorage.getItem('frameyPluginJob');}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
deckyStatusRequest=0,
activeFrameyId=null, frameySession=null, deckyBusy=false;
async function refreshDecky(){try{deckyState=await api('/api/decky');
async function refreshDecky(){const request=++deckyStatusRequest;try{const state=await api('/api/decky');
if(request!==deckyStatusRequest)return;
deckyState=state;
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
'Decky is not running. Install it from Frame Control on your computer.';
const list=byId('deckyInstalled');list.replaceChildren();
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
const note=document.createElement('p');note.textContent=compatibilityText(plugin);row.append(note);
if(plugin.storeId){const open=document.createElement('button');open.className='secondary';open.textContent='Open panel';
open.onclick=()=>openFrameyPanel(plugin.storeId,plugin.name);row.append(open);
const button=document.createElement('button');button.className='danger';button.textContent='Remove';
button.disabled=deckyBusy;button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
button.disabled=deckyBusy||deckyJobPending();button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.';
showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}}
showDeckyStore();}catch(error){if(request!==deckyStatusRequest)return;
deckyState=null;
byId('deckyState').textContent=`Installed plugins unavailable: ${error.message}`;
byId('deckyInstalled').replaceChildren();
byId('deckyInstalled').textContent='Refresh to check installed plugins.';
showDeckyStore();}}
async function loadDeckyStore(){const request=++deckyStoreRequest;byId('deckyStorePanel').hidden=false;
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
byId('deckyStoreSummary').textContent='Loading official Decky store…';
@@ -130,7 +142,7 @@ function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren
const q=byId('deckySearch').value.trim().toLowerCase();
const filter=byId('deckyStoreFilter').value;
const found=deckyStore.filter(p=>(!q||`${p.name} ${p.author||''} ${p.description}`.toLowerCase().includes(q))&&
(filter==='all'||(filter==='compatible'&&!p.root)||(filter==='installed'&&(deckyState?.plugins||[]).some(x=>x.storeId===p.id))));
(filter==='all'||(filter==='compatible'&&!p.root&&!p.compatibility?.blockInstall)||(filter==='installed'&&(deckyState?.plugins||[]).some(x=>x.storeId===p.id))));
const size=20, pages=Math.max(1,Math.ceil(found.length/size));
deckyStoreIndex=Math.min(deckyStoreIndex,pages-1);
byId('deckyStoreSummary').textContent=`${found.length} plugin${found.length===1?'':'s'} found`;
@@ -140,12 +152,13 @@ function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren
if(!found.length)list.textContent='No plugins match this search.';
for(const plugin of found.slice(deckyStoreIndex*size,(deckyStoreIndex+1)*size)){
const row=document.createElement('div');row.className='block game';
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version}${plugin.author?' · '+plugin.author:''} · ${plugin.description}`;
const label=document.createElement('div');label.textContent=`${plugin.name} ${plugin.version}${plugin.author?' · '+plugin.author:''} · ${plugin.description}`;
const button=document.createElement('button');button.textContent='Install';
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===plugin.id);
if(installed)button.textContent=installed.version===plugin.version?'Reinstall':'Update';
button.disabled=deckyBusy||!deckyState?.running||plugin.root;
if(plugin.root)label.textContent+=' · Needs root; not supported on Frame';
button.disabled=deckyBusy||deckyJobPending()||!deckyState?.running||plugin.root||!!plugin.compatibility?.blockInstall;
const note=document.createElement('p');note.textContent=compatibilityText(plugin);label.append(note);
if(plugin.root && !plugin.compatibility?.blockInstall)label.append(' · Needs root; not supported on Frame');
button.onclick=()=>deckyChange(installed?'update':'install',plugin);row.append(label,button);list.append(row);}}
function deckyJobView(title,message,percent,failed=false){
byId('deckyJobPanel').hidden=false;byId('deckyJobTitle').textContent=title;
@@ -171,7 +184,12 @@ async function followDeckyJob(job,action,name){
}
throw Error('Still working. Use Check result to reconnect to this job.');
}
async function deckyChange(action,plugin){if(deckyBusy||!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
async function deckyChange(action,plugin){if(deckyBusy)return;
if(deckyJobPending()){
deckyJobView('Previous plugin change','Check result before starting another plugin change.',null,true);
return;
}
if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
deckyBusy=true;showDeckyStore();
deckyJobView(`${action} ${plugin.name}`,'Starting…',0);
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
@@ -184,14 +202,17 @@ async function deckyChange(action,plugin){if(deckyBusy||!confirm(`${action} ${pl
finally{deckyBusy=false;await refreshDecky();}
}
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
if(!saved||typeof saved.job!=='string'){sessionStorage.removeItem('frameyPluginJob');return;}
if(deckyBusy)return;
if(!saved||typeof saved.job!=='string'){sessionStorage.removeItem('frameyPluginJob');showDeckyStore();return;}
deckyBusy=true;byId('deckyJobCheck').hidden=true;
try{await followDeckyJob(saved.job,saved.action,saved.name);sessionStorage.removeItem('frameyPluginJob');}
catch(error){deckyJobView(`${saved.action} ${saved.name}`,`Error: ${error.message}`,null,true);}
finally{deckyBusy=false;await refreshDecky();}}
byId('deckyJobCheck').onclick=checkDeckyJob;
function openFrameyPanel(id,name){
if(!(deckyState?.plugins||[]).some(p=>p.storeId===id))return;
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
byId('frameyCompatibility').textContent=compatibilityText(installed);
closeFrameyEvents();
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
byId('frameyPanelStatus').textContent='Loading plugin panel…';
byId('frameyFrame').src=`/framey/panel/${id}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
@@ -199,7 +220,13 @@ function openFrameyPanel(id,name){
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
byId('frameyPanelStatus').textContent='No compatible panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
}
byId('frameyClose').onclick=()=>{activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
let frameyEventsActive=false;
function closeFrameyEvents(){
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
frameyEventsActive=false;
}
byId('frameyClose').onclick=()=>{closeFrameyEvents();activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
function frameyCacheRead(id){
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
@@ -232,11 +259,30 @@ window.addEventListener('message',async event=>{
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
if(req.type!=='framey:call'||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
const pluginId=activeFrameyId,reply={type:'framey:reply',id:req.id};
try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
if(req.type==='framey:events:close'){
if(req.session===frameySession)closeFrameyEvents();return;
}
if(req.type==='framey:events'){
if(req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||!Array.isArray(req.args)||
req.args.length!==2||!Array.isArray(req.args[0])||req.args[0].length>32||
req.args[0].some(name=>typeof name!=='string'||!/^[A-Za-z0-9_:/.-]{1,128}$/.test(name))||
!Number.isSafeInteger(req.args[1])||req.args[1]<0)return;
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
frameyEventsActive=req.args[0].length>0;
try{reply.result=await api('/api/framey/events',{id:pluginId,session,events:req.args[0],cursor:req.args[1]});}
catch(error){reply.error=error.message;}
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
event.source.postMessage(reply,'*');
return;
}
if(!['framey:call','framey:steam'].includes(req.type)||req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
const steamRead=req.type==='framey:steam';
if(steamRead&&(req.args.length!==0||!['GetIPCountry','GetInstallFolders','GetSystemInfo','GetLibrarySnapshot'].includes(req.method)))return;
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
try{const data=await api(steamRead?'/api/framey/steam':'/api/framey/method',
{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
catch(error){reply.error=error.message;}
if(pluginId===activeFrameyId&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
});
window.addEventListener('pointerup',event=>{if(event.isTrusted)
api('/api/framey/check',{kind:'input'}).catch(()=>{});},{passive:true});
+4 -2
View File
@@ -2189,6 +2189,7 @@ $('extensionClose').onclick = () => {
$('extensionFrame').removeAttribute('src');
$('extensionPanel').hidden = true;
};
function deckyCompatibilityText(plugin) {const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
let deckyInfo = null;
let deckyCatalog = [];
async function loadDecky() {
@@ -2200,7 +2201,7 @@ async function loadDecky() {
const managedList=$('deckyManagedList');managedList.replaceChildren();
for(const plugin of d.plugins||[]){if(!plugin.storeId)continue;
const row=document.createElement('div');row.className='hint';
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''}`;
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''} · ${deckyCompatibilityText(plugin)}`;
const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove plugin';
remove.disabled=!d.managed||!$('deckyAck').checked;
remove.onclick=()=>changeDeckyPlugin('remove',{id:plugin.storeId,name:plugin.name});
@@ -2226,8 +2227,9 @@ function showDeckyStore() {
const description = document.createElement('span'); description.textContent = ` ${plugin.description}`;
const entry = installed.find(p => p.storeId === plugin.id);
const unmanaged = installed.some(p => p.name === plugin.name && p.storeId !== plugin.id);
const reason = plugin.root ? 'Needs root; not supported by the Frame user service.' : unmanaged ? 'Existing plugin is not managed by Frame Control.' : '';
const reason = plugin.root ? 'Needs root; not supported by the Frame user service.' : plugin.compatibility?.blockInstall ? plugin.compatibility.reason : unmanaged ? 'Existing plugin is not managed by Frame Control.' : '';
row.append(title, description);
const compatibility=document.createElement('p');compatibility.textContent=deckyCompatibilityText(plugin);row.append(compatibility);
if (reason) { const note=document.createElement('small'); note.textContent=` ${reason}`; row.append(note); }
else {
const button=document.createElement('button'); button.className='small';
+38 -4
View File
@@ -586,6 +586,25 @@ def framey_method_action(body):
raise Failure(str(exc), 400) from exc
def framey_steam_action(body):
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
raise Failure('Decky plugin panels run in the headset service', 400)
try:
return framey_panel.read_steam(ssh, body.get('id'), body.get('method'))
except framey_panel.PanelError as exc:
raise Failure(str(exc), 400) from exc
def framey_events_action(body):
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
raise Failure('Decky plugin panels run in the headset service', 400)
try:
return framey_panel.poll_events(ssh, body.get('id'), body.get('session'),
body.get('events'), body.get('cursor', 0))
except framey_panel.PanelError as exc:
raise Failure(str(exc), 400) from exc
def framey_check_action(body):
"""Record only a live, explicitly started worn-headset check."""
if not HEADSET_RUNTIME or body.get('kind') not in ('input', 'panel'):
@@ -3452,6 +3471,8 @@ POST = {
"/api/decky": decky,
"/api/decky/plugin": decky_plugin,
"/api/framey/method": framey_method_action,
"/api/framey/steam": framey_steam_action,
"/api/framey/events": framey_events_action,
"/api/framey/check": framey_check_action,
"/api/headset": headset_action,
"/api/headset/quick": headset_quick,
@@ -3670,6 +3691,16 @@ class Handler(BaseHTTPRequestHandler):
self.send_bytes(framey_panel.panel(plugin_id), 'text/html; charset=utf-8', framey=True)
except (ValueError, FileNotFoundError, OSError):
self.send_json({'error':'Framey panel unavailable'}, 404)
elif path.startswith('/framey/module/'):
try:
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
module = framey_panel.local_module(int(plugin_id), name)
# The plugin iframe has an opaque origin; only its module
# imports need cross-origin access to these public JS files.
self.send_bytes(module, 'text/javascript; charset=utf-8', framey=True,
headers=[('Access-Control-Allow-Origin', 'null')])
except (ValueError, FileNotFoundError, OSError):
self.send_json({'error':'Framey module unavailable'}, 404)
elif path.startswith('/framey-runtime/'):
files = {'react.js':'vendor/react-18.3.1.min.js',
'react-dom.js':'vendor/react-dom-18.3.1.min.js',
@@ -4151,8 +4182,12 @@ def main():
LINK.start()
if not PRIVATE: # one worker per user, in the app's server
threading.Thread(target=autosync.run, args=(_stop_sync,), daemon=True).start()
def request_shutdown(*_):
# shutdown() must run outside the serve_forever thread. Do not raise
# from SIGTERM: the app can send it after stdin EOF has begun cleanup.
threading.Thread(target=httpd.shutdown, daemon=True).start()
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
signal.signal(signal.SIGTERM, request_shutdown)
if args.exit_on_eof:
def watch_stdin():
# os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock,
@@ -4160,7 +4195,7 @@ def main():
# when it can't take that lock back from this thread.
while os.read(0, 4096):
pass
threading.Thread(target=httpd.shutdown, daemon=True).start()
request_shutdown()
threading.Thread(target=watch_stdin, daemon=True).start()
try:
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
@@ -4169,8 +4204,7 @@ def main():
except KeyboardInterrupt:
pass
finally:
# The app both closes stdin and sends SIGTERM on quit; a second signal
# mid-cleanup would abort it and leave the SSH master running.
# Cleanup is already underway; later stop signals need no more workers.
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
_power.shutdown()