Compare commits

..
Author SHA1 Message Date
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 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 587a7fc8c3 Render React content in Framey panel toasts 2026-10-26 00:06:00 +00:00
SAPHID dd62de4f6d Merge remote-tracking branch 'origin/main' into fix/pr266-windows-doc 2026-10-26 00:01:16 +00:00
Alex Southwell 081d133084 Merge pull request #257 from saphid/test/framey-css-sgdb
Framey: pin CSS Loader and SteamGridDB compatibility checks
2026-10-26 00:00:41 +00:00
Alex Southwell 94e050d5a0 Merge pull request #263 from saphid/feat/framey-safe-ui-shims
Expand isolated Framey controls and cache fallbacks
2026-10-25 23:50:11 +00:00
Alex Southwell cd6ecf073f Merge pull request #265 from saphid/fleet/n781xzqt5q
Verify Framey in unworn VR and fix live settings and reopen bugs
2026-10-25 23:48:49 +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
SAPHID 3d6bd463ce Merge remote-tracking branch 'origin/test/framey-css-sgdb' into test/framey-css-sgdb 2026-10-25 23:39:17 +00:00
SAPHID 981173a1f2 Merge remote-tracking branch 'origin/main' into test/framey-css-sgdb
# Conflicts:
#	tests/e2e/test_framey.py
2026-10-25 23:38:57 +00:00
SAPHID 9146234758 Merge remote-tracking branch 'origin/main' into fix/pr263-main-sync 2026-10-25 23:35:28 +00:00
SAPHID c6e54041e0 Merge remote-tracking branch 'origin/main' into fix/pr257-main-sync
# Conflicts:
#	tests/e2e/test_framey.py
2026-10-25 23:33:05 +00:00
SAPHID e67a90b397 Record real Framey VR evidence and restored device state 2026-10-25 23:31:50 +00:00
Alex Southwell 91f60c26e3 Merge pull request #261 from saphid/fleet/kp0z857q58
Framey: support legacy Decky panels and keyword RPC
2026-10-25 23:31:28 +00:00
SAPHID cb9ad1b6cb Drain stale mirror frames and record native scroll delivery 2026-10-25 23:27:34 +00:00
SAPHID 285871bc39 Add locked unattended Framey VR capture and native input checks 2026-10-25 23:25:07 +00:00
SAPHID 779f9c5a53 Preserve coordinator sync of legacy bridge 2026-10-25 23:24:32 +00:00
SAPHID 43112f5143 Merge current main and preserve parameterized routes 2026-10-25 23:24:01 +00:00
SAPHID 0e6c00525d Expand isolated Framey controls and cache fallbacks 2026-10-25 23:23:47 +00:00
SAPHID 416c574240 Merge remote-tracking branch 'origin/main' into fleet/n781xzqt5q 2026-10-25 23:22:33 +00:00
Alex Southwell d77e2bd056 Merge pull request #260 from saphid/docs/framey-route-sample
Framey: demonstrate local routes in author sample
2026-10-25 23:21:51 +00:00
Alex Southwell 2a6736a02c Merge pull request #258 from saphid/docs/v0436-smoke-evidence
Record v0.4.36 real Frame smoke evidence
2026-10-25 23:20:31 +00:00
Alex Southwell c8ef81ad93 Merge pull request #259 from saphid/fix/framey-store-stale-list
Framey: clear stale store choices during refresh
2026-10-25 23:17:09 +00:00
SAPHID e529d60554 Merge remote-tracking branch 'origin/main' into fix/pr261-main-sync
# Conflicts:
#	tests/page/framey_actions.mjs
2026-10-25 23:16:34 +00:00
SAPHID e9273beaa1 Keep CSS Loader fixture neutral about legacy panel support 2026-10-25 23:16:00 +00:00
SAPHID 48b404bc3b Merge remote-tracking branch 'origin/main' into fleet/n781xzqt5q 2026-10-25 23:14:50 +00:00
SAPHID aeb991bc1c Fix Framey sample settings path and same-plugin reopen 2026-10-25 23:14:50 +00:00
Alex Southwell 63820d1909 Merge pull request #256 from saphid/feat/framey-steamgriddb
Framey: support local plugin routes with parameters
2026-10-25 23:12:51 +00:00
SAPHID 1aaa3c8c06 Support legacy Decky panels and keyword backend calls 2026-10-25 23:12:28 +00:00
SAPHID be18ed1a8b Show local route navigation in Framey author sample 2026-10-25 23:11:01 +00:00
SAPHID 3f4aaf539c Clear stale Framey store listings during refresh 2026-10-25 23:09:14 +00:00
SAPHID 3acc2d91e6 Record v0.4.36 real Frame release smoke 2026-10-25 23:08:07 +00:00
SAPHID 0867debebd Pin CSS Loader and SteamGridDB store compatibility cases 2026-10-25 23:07:09 +00:00
SAPHID e400cb1dcf Support local Framey routes with named parameters 2026-10-25 23:04:02 +00:00
Alex Southwell 1701e622dc Merge pull request #255 from saphid/release/v0.4.36
Prepare Frame Control v0.4.36
2026-10-25 22:58:26 +00:00
SAPHID 31edf6b737 Prepare Frame Control v0.4.36 2026-10-25 22:49:34 +00:00
Alex Southwell 005ad9b71d Merge pull request #253 from saphid/fix/framey-store-version-race
Reject store versions changed after review
2026-10-25 22:48:38 +00:00
Alex Southwell aaf2668882 Merge pull request #252 from saphid/test/framey-bluetooth-store
Provide Steam token fallbacks for Bluetooth panel
2026-10-25 22:39:14 +00:00
Alex Southwell cfb0310e9b Merge pull request #254 from saphid/fix/appimage-smoke-isolation
Isolate AppImage release smoke state
2026-10-25 22:32:18 +00:00
SAPHID 493ab35635 Keep stale version check within existing store flow 2026-10-25 22:29:45 +00:00
SAPHID 313d1ec9c8 Merge shipped HLTB support into Bluetooth compatibility 2026-10-25 22:25:48 +00:00
SAPHID 6ee7bacc47 Merge remote-tracking branch 'origin/main' into fix/appimage-smoke-isolation 2026-10-25 22:23:50 +00:00
SAPHID 027bbfa701 Isolate AppImage smoke network and extraction state 2026-10-25 22:23:49 +00:00
SAPHID 0f8d69a263 Use readable Bluetooth label fallbacks 2026-10-25 22:22:44 +00:00
Alex Southwell 2a9d4f886e Merge pull request #251 from saphid/feat/framey-hltb-compat
Extend Framey shim for HLTB settings panel
2026-10-25 22:22:27 +00:00
SAPHID 34cd35056b Reject store versions changed after review 2026-10-25 22:20:44 +00:00
Alex Southwell 734cd4f36c Merge pull request #250 from saphid/test/appimage-launch-ci
Smoke test the Linux AppImage in release CI
2026-10-25 22:20:18 +00:00
SAPHID 44ca81f527 Provide Steam token fallbacks for Bluetooth panel 2026-10-25 22:17:12 +00:00
SAPHID 3e5349ec39 Extend Framey shim for HLTB settings panel 2026-10-25 22:14:11 +00:00
SAPHID 3c184f5741 Smoke test packaged Linux AppImage in release CI 2026-10-25 22:10:57 +00:00
Alex Southwell 93bffc21d7 Merge pull request #249 from saphid/release/v0.4.35
Prepare Frame Control v0.4.35
2026-10-25 21:50:02 +00:00
SAPHID 8423a309ff Document API v1 Framey limits for v0.4.35 2026-10-25 21:42:29 +00:00
SAPHID 698a3b11d9 Merge remote-tracking branch 'origin/main' into release/v0.4.35 2026-10-25 21:42:13 +00:00
Alex Southwell 5952bf8e9d Merge pull request #247 from saphid/feat/framey-api-v1-panels
Host modern Decky API v1 plugin panels in Framey
2026-10-25 21:41:58 +00:00
SAPHID d186dfbc60 Merge remote-tracking branch 'origin/main' into feat/framey-api-v1-panels
# Conflicts:
#	docs/frame-plugin-author.md
#	docs/framey-loader.md
2026-10-25 21:33:37 +00:00
SAPHID c7e76fdd64 Document v0.4.35 Framey coverage limits 2026-10-25 21:33:29 +00:00
SAPHID 559eec96b3 Merge remote-tracking branch 'origin/main' into release/v0.4.35 2026-10-25 21:32:22 +00:00
Alex Southwell 020f93a187 Merge pull request #248 from saphid/docs/framey-sample-panels
Show Framey settings and quick action in author sample
2026-10-25 21:32:00 +00:00
Alex Southwell 69a912cc7d Merge pull request #245 from saphid/feat/framey-plugin-cache
Persist bounded Framey plugin cache across panel reopen
2026-10-25 21:31:53 +00:00
SAPHID aa6078c11a Keep fake Frame store test scoped to remote installs 2026-10-25 21:31:01 +00:00
SAPHID 01d1fba915 Merge remote-tracking branch 'origin/main' into release/v0.4.35 2026-10-25 21:28:14 +00:00
Alex Southwell e89ff39cff Merge pull request #246 from saphid/fix/framey-store-catalog-edges
Handle root tags and ambiguous Decky store IDs
2026-10-25 21:27:24 +00:00
SAPHID 4cfbd5883f Merge remote-tracking branch 'origin/main' into feat/framey-api-v1-panels
# Conflicts:
#	docs/framey-loader.md
2026-10-25 21:25:09 +00:00
SAPHID 33fdd9d296 Show Framey settings and quick action in author sample 2026-10-25 21:23:09 +00:00
SAPHID 45bd789cb7 Reconcile Framey cache bridge with session shim 2026-10-25 21:21:13 +00:00
SAPHID 1db65d6c82 Merge remote-tracking branch 'origin/main' into feat/framey-plugin-cache 2026-10-25 21:20:22 +00:00
SAPHID 6b6ec64381 Host modern Decky API v1 plugin panels in Framey 2026-10-25 21:20:14 +00:00
SAPHID 1e9aa42282 Prepare Frame Control v0.4.35 2026-10-25 21:19:04 +00:00
Alex Southwell 1e69ca4194 Merge pull request #244 from saphid/fix/framey-session-cache
Give isolated Framey panels a session cache
2026-10-25 21:17:49 +00:00
SAPHID 8296657a45 Handle root tags and ambiguous Decky store IDs 2026-10-25 21:13:56 +00:00
SAPHID 2b2d72ac9a Persist bounded Framey plugin cache across panel reopen 2026-10-25 21:12:30 +00:00
SAPHID fe4e09032d Provide session cache to isolated Framey panels 2026-10-25 21:09:45 +00:00
Alex Southwell 1356ea4df1 Merge pull request #243 from saphid/docs/framey-steam-status-browser
Record local Steam Status Framey frontend result
2026-10-25 11:04:33 +00:00
SAPHID c79ba7571f Record local Steam Status Framey frontend check 2026-10-25 10:55:46 +00:00
Alex Southwell 76dcd0c426 Merge pull request #242 from saphid/release/v0.4.34
Prepare Frame Control v0.4.34
2026-10-25 10:43:24 +00:00
SAPHID f668a10982 Prepare Frame Control v0.4.34 release 2026-10-25 10:35:56 +00:00
Alex Southwell 29f2f57bc0 Merge pull request #236 from saphid/feat/framey-ui-controls
Support more Decky settings controls in Framey
2026-10-25 10:35:17 +00:00
Alex Southwell a257ec0bf9 Merge pull request #241 from saphid/docs/framey-official-api-audit
Document official Decky frontend compatibility by manifest
2026-10-25 10:31:17 +00:00
Alex Southwell 99504554e0 Merge pull request #240 from saphid/feat/framey-sample-recovery
Recover Framey sample settings from damaged data
2026-10-25 10:28:41 +00:00
Alex Southwell b53293eddd Merge pull request #239 from saphid/feat/framey-zip-collisions
Reject colliding Decky plugin ZIP paths
2026-10-25 10:23:20 +00:00
Alex Southwell 229f17696e Merge pull request #238 from saphid/feat/framey-official-fixtures
Test official Decky plugin archives in fake Frame
2026-10-25 10:22:58 +00:00
SAPHID f6a88f1a51 Merge remote-tracking branch 'origin/main' into feat/framey-ui-controls
# Conflicts:
#	docs/frame-plugin-author.md
#	tests/page/framey_actions.mjs
#	ui/framey-host.js
2026-10-25 10:19:20 +00:00
Alex Southwell b7caa7d7d3 Merge pull request #237 from saphid/fix/framey-disabled-controls
Honor disabled state in Framey plugin controls
2026-10-25 10:18:09 +00:00
SAPHID a61dfdd3ab Recover sample Decky settings from damaged data 2026-10-25 10:17:29 +00:00
SAPHID 840f53f032 Reject colliding Decky plugin ZIP paths 2026-10-25 10:16:03 +00:00
SAPHID 5932b6a8a4 Document official Decky frontend API compatibility audit 2026-10-25 10:15:48 +00:00
SAPHID 3f2e32db87 Test pinned official Decky plugins in fake Frame 2026-10-25 10:14:14 +00:00
SAPHID a6a1e0b258 Honor disabled state in Framey plugin controls 2026-10-25 10:09:57 +00:00
SAPHID db74e3be96 Support more Decky panel controls in Framey 2026-10-25 10:09:56 +00:00
Alex SouthwellandSAPHID faf7bc6224 Prepare Frame Control 0.4.33 (#235)
* Prepare Frame Control 0.4.33

* Record Framey sample and Decky recovery limits

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:51:10 +00:00
75 changed files with 17223 additions and 159 deletions

No files matched your search

+4 -1
View File
@@ -10,7 +10,7 @@ on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
@@ -110,6 +110,9 @@ jobs:
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Launch the built Linux AppImage
if: matrix.os == 'ubuntu-latest'
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
# The installer, not just the unpacked build: its 7-Zip payload once dropped
# the OpenXR layer's arm64 library without any error. Install it silently
# and compare every installed file with the unpacked build.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.33",
"version": "0.4.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.33",
"version": "0.4.37",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.33",
"version": "0.4.37",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -55,7 +55,8 @@
"*.html",
"*.js",
"*.css",
"telemetry.json"
"telemetry.json",
"framey-compatibility.json"
]
},
{
+5 -5
View File
@@ -1,10 +1,10 @@
# What we have tested
Current for Frame Control **v0.4.33**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
Current for Frame Control **v0.4.37**, 26 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.31 candidate source `1255ef2`, Frame OS 0.5.6 build `20261022.6153909`: [sanitized results](evidence/round2-smoke-2026-10-25.json) |
| 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) |
| 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) |
@@ -22,8 +22,8 @@ Current for Frame Control **v0.4.33**, 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. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.33 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [compatibility table](decky-frame.md) |
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Fake Frame tests cover setting read/write through update and reinstall; page tests cover the official JSX runtime, stale job recovery and an author sample bundle. The sample is not published in the store. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.33: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
| 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 | 25 Oct, v0.4.29–0.4.37: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.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 later returned read-only ProtonDB and Steam Status setting RPCs through Steam CEF; it was removed under the shared lock | No | The loader remains opt-in. The exact-version official catalog covers 116 plugins: 0 works, 43 partial, 62 broken and 11 not applicable. Fake Frame transport and local Chromium do not prove official plugin backends or VR controls. Physical controller use and wearer-visible official plugin panels remain unverified | 25–26 Oct, v0.4.31–0.4.37: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend evidence](evidence/framey-official-backend-2026-10-25.json), [compatibility table](framey-compatibility.md) |
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 run showed the Framey sample panel in stereo compositor and mirror captures. 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 are 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. React toast content is tested in the host and fake Frame only; it is a panel notice, not a Steam system notification. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; 96 local Chromium initializers were ready and 20 failed. No plugin is marked fully working | 25–26 Oct, v0.4.37: [unattended evidence and cleanup](evidence/framey-vr-2026-10-25/README.md), [CSS/SteamGridDB PR #257](https://github.com/saphid/frame-control/pull/257), [legacy bridge PR #261](https://github.com/saphid/frame-control/pull/261), [UI shims PR #263](https://github.com/saphid/frame-control/pull/263), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [toast PR #267](https://github.com/saphid/frame-control/pull/267), [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.
+37 -3
View File
@@ -42,6 +42,39 @@ files or behavior. The official store's digest checks the archive in transit;
it does not certify plugin code. Plugins run with the headset user's access.
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
### Which of these plugins can open a Framey panel?
The separate Framey panel accepts a Frame Control store install with a supported
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
A backend load does not bypass this frontend check. We downloaded the 11 versions above
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
catalogue. All 11 had a frontend bundle, but their manifests differed:
| Manifest in the checked archive | Plugins | Framey panel result |
| --- | --- | --- |
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
has each store ID, exact version, SHA-256, manifest field, and bundle check.
The archive audit itself is static PC inspection. Later local host and fake
Frame tests cover the Storage Cleaner, HLTB, CSS Loader and SteamGridDB cases noted above; none shows
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
remain the only device evidence for these versions. A new store version needs a
new check because its manifest and binaries can change.
Pinned CSS Loader 2.1.3 and SteamGridDB 1.8.1 archives now pass the fake Frame
managed install/remove flow. CSS Loader's `plugin.json` has no `api_version` and
its older IIFE bundle passes the Framey packaging check added in
[#261](https://github.com/saphid/frame-control/pull/261). The fixture does not
render its theme controls. SteamGridDB declares API v1 and its bundle passes Framey's packaging
check, but it looks up Steam library modules while loading and patches game
context menus. Those modules and menus do not exist in Framey's isolated
iframe, so its artwork picker cannot be called a usable VR panel. Framey does
not apply its Steam library changes. These checks did not run CSS themes or
modify game artwork on the real Frame.
The new store transaction was also exercised on the same real Frame:
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
store update → store remove → managed loader remove**. Status reported the
@@ -57,11 +90,12 @@ change Frame Control owned plugin folders under
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
back an update if the service restart fails. The current VR shell does not
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
shows installed plugins and store actions. Its separate Framey iframe now hosts
compatible API v2 plugin pages, settings, local routes, and panel toasts.
shows installed plugins and store actions. Its separate Framey iframe hosts
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
with settings, local routes, and panel toasts where supported.
Steam route patches and system notifications remain unavailable. The panel
still needs a worn-headset visual and controller check. See the
[current compatibility table](frame-plugin-author.md#framey-api-v2-compatibility).
[current compatibility table](frame-plugin-author.md#framey-decky-api-compatibility).
## What was checked on the real headset
@@ -0,0 +1,20 @@
# API v1 panel check, 25 October 2026
The unmodified official Storage Cleaner 1.4.0 archive (store ID 50, SHA-256
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`)
declares `api_version: 1`, but its bundled frontend connects to Decky's modern
frontend API. Framey admitted that bundle from a local store receipt and
rendered it in Chromium inside an iframe with `sandbox="allow-scripts
allow-popups"`.
A test parent returned empty lists and `0B` for the plugin's two list and two
size backend calls. Chromium reported `framey:ready`, displayed Shader Cache
and Compatibility Data sections, and showed all four cleanup buttons disabled.
No cleanup action was invoked. The backend was a stub; the test did not run the
plugin's Python code or connect to a headset.
This confirms a panel shell for this exact archive with empty data. Storage
Cleaner may remove compatibility data and local game saves when used with a
real backend. Its cleanup actions, game names, SteamClient app details, VR
visibility, and controller input remain unverified on Frame. Do not infer that
its deletion controls are safe or complete in VR from this browser check.
@@ -0,0 +1,16 @@
# Bluetooth 3.1.1 compatibility fixture, 25 October 2026
The unmodified official Decky store archive for Bluetooth 3.1.1 (store ID 18,
SHA-256 `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5`)
passes Frame Control's archive rules. The fake Frame e2e harness installed it
through the store API, verified the managed receipt and frontend bytes, then
removed it. An earlier real Frame check recorded the backend's `Bluetooth
plugin loaded` line while SteamVR was in standby.
In a local Node host test, its official frontend starts and builds an initial
panel tree. Framey supplies empty Steam token maps and readable English
fallbacks for the three Bluetooth labels that lack plugin-owned fallbacks.
The test leaves React effects and click
handlers inert and sends no backend calls. It does not inspect paired devices,
change Bluetooth state, exercise a controller, or prove the panel is visible in
VR. No worn-headset check has passed for this plugin.
@@ -0,0 +1,19 @@
# Framey cache check, 25 October 2026
The official Steam Status 1.2.3 ZIP pinned in
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
allow-popups"`, so its native storage remained unavailable. The new Framey
storage facade accepted a set/get probe without a `SecurityError`. Steam
Status rendered and issued four `get_settings` calls to a stub parent backend.
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
snapshot and the plugin wrote the same cache key again. The browser log had
zero `SecurityError` or `localStorage` errors.
The parent was a local test page with in-memory Decky backend replies. One
public status fetch also failed in the test browser, so this check does not
establish offline correctness or the freshness of Steam Status data. The
per-plugin save/reopen protocol, stale panel session rejection, and cache
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
live Decky settings, VR visibility, and controller input remain unverified.
+25
View File
@@ -0,0 +1,25 @@
# Legacy Decky frontend bridge
The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures)
has no `api_version` field and returns its initializer from a Decky IIFE.
Framey now wraps that expression in an ES module inside the existing
opaque-origin iframe. It passes a legacy `serverAPI` to the initializer.
Local Chromium ran the unchanged official frontend, including React effects,
with all backend calls rejected and network access blocked. It emitted
`framey:ready` without a JavaScript startup error during the observation window.
This proves initialization, not playback, MPRIS access, or a useful populated UI.
The fake Frame installed, packed, checked the frontend bytes and removed this
exact archive. Its synthetic Sample Panel also read a legacy keyword argument,
wrote a setting, and read that setting through the modern API. This exercised
the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was
checked against Decky's `handle_plugin_method_call_legacy`: it calls with a
keyword object and returns `{success, result}`. Invalid argument shapes and
unmanaged plugin IDs are rejected.
Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'`
(21 passed), `node tests/page/framey_actions.mjs`, and
`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or
headset state was changed by these checks. Real Frame backend behavior,
controller input, playback and worn VR use remain unverified.
@@ -0,0 +1,116 @@
{
"checkedAtUtc": "2026-10-25",
"source": "https://plugins.deckbrew.xyz/plugins",
"method": "Downloaded exact current official store versions; local SHA-256 matched each store hash; inspected ZIP plugin.json and dist/index.js without running plugin code.",
"limit": "Static inspection on PC. Prior backend load results are documented in docs/decky-frame.md; VR visibility and controller input remain unverified.",
"plugins": [
{
"storeId": 7,
"name": "CSS Loader",
"storeVersion": "2.1.3",
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
"manifestApiVersion": null,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 14,
"name": "ProtonDB Badges",
"storeVersion": "1.3.4",
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
"manifestApiVersion": 2,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 16,
"name": "Audio Loader",
"storeVersion": "1.6.1",
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
"manifestApiVersion": null,
"manifestFlags": [
"_root"
],
"frontendBundle": true
},
{
"storeId": 18,
"name": "Bluetooth",
"storeVersion": "3.1.1",
"sha256": "f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 21,
"name": "PowerTools",
"storeVersion": "2.0.3",
"sha256": "47614f53b8c538c4caa15f89a01e4ab106fa328e89f78545bacb3166d104d964",
"manifestApiVersion": null,
"manifestFlags": [
"root",
"global-dfl"
],
"frontendBundle": true
},
{
"storeId": 23,
"name": "Animation Changer",
"storeVersion": "1.3.2",
"sha256": "f2c62b90ca60d8a80b6d0f75d8027552b1509c7a05842c6f4a24a9072846d133",
"manifestApiVersion": null,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 26,
"name": "HLTB for Deck",
"storeVersion": "2.0.12",
"sha256": "bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 36,
"name": "SteamGridDB",
"storeVersion": "1.8.1",
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
"manifestApiVersion": 1,
"manifestFlags": [
"debug"
],
"frontendBundle": true
},
{
"storeId": 50,
"name": "Storage Cleaner",
"storeVersion": "1.4.0",
"sha256": "1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 68,
"name": "Wine Cellar",
"storeVersion": "0.2.1",
"sha256": "7e077b933c234414d172b5c6d1c804b026bf39fa4e18e6663495a282637031e8",
"manifestApiVersion": 1,
"manifestFlags": [
"debug"
],
"frontendBundle": true
},
{
"storeId": 82,
"name": "MagicPods",
"storeVersion": "2.0.21",
"sha256": "abe2eef5788cb3ff6e73fcb9783e30a54b127e28c761a9bf99e64ab5fc4be9ab",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
}
]
}
@@ -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,26 @@
# Framey session cache browser check, 25 October 2026
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
used a per-panel memory substitute. It did **not** change the iframe's sandbox
flags or give plugin code Frame Control's UI key.
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
from `tests/fixtures/framey-official/123.zip` (SHA-256
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
four `get_settings` calls. That reply was a stub, not the Decky backend.
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
The parent could not read the iframe document (`contentDocument` was null).
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
Managers and Players Online. Its public status request returned HTTP 200.
No cache `SecurityError` appeared in the captured console, while the
same archive had logged that error with the v0.4.34 host.
Node host tests also checked item updates, removal, clear, key order, string
conversion, quota rejection, and preservation of an existing item after a
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
values **in memory**. Closing or reloading the panel loses this cache. This
check did not test offline behavior, persistent plugin settings, a live Decky
backend, SteamVR visibility, controller input, or headset performance.
@@ -0,0 +1,28 @@
# Steam Status in the local Framey browser, 25 October 2026
This check used the unmodified official Steam Status 1.2.3 archive already
pinned in `tests/fixtures/framey-official/123.zip`. Its SHA-256 is
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`.
The archive declares Decky API v2. No headset was contacted for this check.
On a Linux PC, we extracted the archive to a disposable plugin folder with a
Frame Control store receipt. A local HTTP server served the page from
`framey_panel.panel(123)`, Frame Control's React 18.3.1 runtime and its
`framey-host.js`. A parent page put the panel in an iframe with
`sandbox="allow-scripts"` and returned `{}` for the plugin's `get_settings`
backend calls. This was a **stub**, not the Decky service.
Chromium reported `framey:ready` and four `get_settings` calls. The page showed
Steam Status, Store, Community, WebAPI, Connection Managers, Players Online,
and other controls. Its own public `https://steamstatus.schelstraete.org/api/v1/status`
request returned HTTP 200 and populated the status view. A click on **Refresh**
sent Framey's trusted pointer event to the parent; this test did not prove a
fresh status fetch or settings write. The [browser screenshot](framey-steam-status-browser-2026-10-25.png)
contains only the local test page and public service data.
The plugin logged `SecurityError` while reading and writing `localStorage` in
the opaque-origin iframe. It caught the errors and rendered anyway. Its cache
and offline behavior in Framey are **not verified**. The real Decky backend,
live settings, SteamVR visibility, controller input and headset performance
were not tested. This result supports only local frontend rendering for this
exact archive and Frame Control v0.4.34 host code.
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

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

After

Width:  |  Height:  |  Size: 150 KiB

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

After

Width:  |  Height:  |  Size: 107 KiB

@@ -0,0 +1,24 @@
# v0.4.36 release smoke on a real Frame
On 25 October 2026 at 22:59 UTC, the release branch ran
`scripts/frame-smoke.sh` from a physical Linux host against a real Steam Frame.
The script and its Python implementation were byte-identical to the
`v0.4.36` tag `1701e622dc1f9888560412a058fe816b19ca7e79`.
The headset reported Frame OS 0.5.6, build `20261022.6153909`.
The test acquired `/tmp/frame-test.lock` with a unique owner marker before
reading device status or changing test titles. Its first read showed SteamVR
activity level 3 (standby), proximity raw 6.3125, and battery 99%. The test
ran only while that lock was owned. It released only its own marker and lock
after the final cleanup.
All **13 of 13** smoke steps passed: cleanup of reserved leftovers, devkit
service read, status read, install/launch/remove for reserved ARM64, x86_64
and Windows test titles, and final cleanup. The test did not pair a key. The
test output was saved locally under `/tmp/frame-v0436-smoke-results/`; this
summary omits the headset identifier and private device status fields.
The headset was in standby. This test does not show wearer-visible game or
Framey pixels, controller input, live Decky settings, or plugin actions.
See [release v0.4.36](https://github.com/saphid/frame-control/releases/tag/v0.4.36)
for the tagged installer and checksum checks.
+41 -5
View File
@@ -29,19 +29,26 @@ before describing it as working in VR. Until a QAM bridge is verified, a Frame
Control extension is the simplest route for a self-contained read-only VR panel.
Frame Control now has an experimental [Framey panel host](framey-loader.md)
for API v2 Decky frontend bundles. It renders common settings controls in the
for API v2 Decky frontend bundles and API v1 bundles that use Decky's modern
frontend API initializer. It renders common settings controls in the
separate SteamVR dashboard panel, but Steam library and store page patches
remain unavailable. Wearer-visible use is not yet verified.
The [settings sample](../examples/framey-settings/README.md) shows an official
Decky JSX build, backend persistence, a toggle and a local toast. Framey offers
only official-store-owned plugins in its VR panel.
For persistent backend settings, use Decky's `decky.DECKY_PLUGIN_SETTINGS_DIR` rather
than assuming an environment variable or Steam Deck home path. Validate stored
types when reading, write through a temporary file before replacing the old
file, and give the panel a safe default if settings are missing or damaged.
Removing a managed plugin leaves its Decky settings behind; document how users
can reset data your plugin owns.
To test safely, use a separate Frame Control managed Decky install, verify the
ZIP digest from the official store, install one plugin at a time, and inspect
`journalctl --user -u frame-control-decky.service`. Remove only your own
plugin folder when you finish. See the current [compatibility table](decky-frame.md).
## Framey API v2 compatibility
## Framey Decky API compatibility
Framey hosts a plugin's frontend in an isolated iframe in the Frame Control
SteamVR panel. The table describes the current implementation and its tests;
@@ -50,17 +57,46 @@ none of these frontend controls has passed a worn-headset check yet.
| Decky API or UI feature | Framey behavior | Verified in |
| --- | --- | --- |
| `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test |
| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; recognized legacy IIFE bundles also use the legacy bridge | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
| Legacy Decky IIFE frontend, with no API manifest field | Wraps the returned initializer as an ES module inside the same opaque iframe; passes `serverAPI` and preserves the legacy title | MusicControl 1.1.6 exact official archive, local Chromium startup with backend methods blocked; fake Frame installation/pack/removal |
| Legacy `serverAPI.callPluginMethod(method, kwargs)` | Uses Decky's `loader/call_legacy_plugin_method` RPC, preserving keyword arguments and `{success, result}` replies | Fake Frame HTTP/CDP read/write with a synthetic backend; no official plugin backend action or worn VR result |
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls | Local browser and host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
| `SteamClient` subscriptions and `DFL.Router.MainRunningApp` | Explicit inert registration methods return disposable handles; no events or attached running game. Mutating Steam/power methods are unavailable. | Host contract tests; these are safe fallbacks, not live Steam functionality |
| `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check |
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
| `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack` | Local routes inside this plugin panel only | Host unit test |
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
| `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey |
Keep routes local to the panel and give every operation a visible result. A
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
Frame Control store; only paths registered by that same plugin can navigate.
Optional route segments must be at the end. Framey accepts local path segments
only; query strings, external URLs, Steam library routes and duplicate parameter
names are rejected. Removing an active route returns to the plugin panel.
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
An API v1 manifest does not guarantee a working Framey panel. Test the exact
store archive and do not assume SteamClient app details, library patches, or
destructive actions work in VR. The
[Storage Cleaner browser check](evidence/framey-api-v1-browser-2026-10-25.md)
used empty stub data and did not invoke its cleanup actions.
Use the Decky Python backend for settings that must survive browser cache loss.
The Framey `localStorage` bridge is for disposable frontend caches, including
the cache read attempted by Steam Status 1.2.3. It does not expose another
plugin's cache or the Frame Control UI key. A real Steam Status panel reopen
and a worn-headset settings check are still needed.
HLTB for Deck 2.0.12 demonstrates the limit of the panel shim: its official
bundle can start and produce a settings view with Framey's Steam module lookup
stub, but its game page and context menu patches have no Steam target in the
isolated panel. The tests do not call its HLTB network search or Python backend.
Do not claim game length badges work in VR from this result.
+311
View File
@@ -0,0 +1,311 @@
# 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: 0 · Partial: 43 · Broken: 62 · N/A: 11.**
- **Works** requires verified useful plugin behavior with its real backend. None qualifies from this audit.
- **Partial** means a limited frontend/settings surface initialized or rendered. Backend-dependent functionality remains unverified; it is not an end-to-end success claim.
- **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. All other 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. 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 | broken | Frontend audit failed: SteamClient.System.UI.RegisterForSystemKeyEvents is not a function. | 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. Frontend audit failed: Cannot read properties of null (reading 'm_GamepadUIAudioStore'). | 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 | partial | Notebook initializes; real note load/save remains unverified. | static archive, local browser, fake-Frame |
| 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 | Frontend audit failed: DFL.createModuleMapping is not a function. | static archive, local browser, fake-Frame |
| 37: Discord Status 1.5.0 | 1 / 2 | broken | RunningApps and appStore are absent; Discord activity integration fails after initialization. | static archive, local browser, fake-Frame |
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | partial | Initializer succeeds; user PulseAudio/PipeWire mixer backend and volume changes unverified. | static archive, local browser, fake-Frame |
| 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 | broken | Initializer succeeds but React rejects a missing component (#130); no usable control claim. | 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 | broken | Unsafe plugin archive member. Frontend audit failed: App is not defined. | static archive, local browser, fake-Frame |
| 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 | Frontend audit failed: Cannot read properties of undefined (reading 'allApps'). | static archive, local browser, fake-Frame |
| 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 | Initializer succeeds; PulseAudio/PipeWire volume backend is unverified. | static archive, local browser, fake-Frame |
| 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 | broken | Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio'). | 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 | broken | Frontend audit failed: Cannot read properties of undefined (reading 'GetJSON'). | static archive, local browser, fake-Frame |
| 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 | broken | Country lookup calls missing SteamClient.User.GetIPCountry; deal loading fails after initialization. | static archive, local browser, fake-Frame |
| 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 | Route registration succeeds; rendering fails on a missing Steam ContainerBackground component. | 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 | broken | Initializer succeeds; game selection fails because SteamClient.InstallFolder.GetInstallFolders is unavailable. | static archive, local browser, fake-Frame |
| 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 | Timer panel initializes; live timer persistence, notifications and audio are unverified. | static archive, local browser, fake-Frame |
| 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 | Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio'). | 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 | Plugin contains a non-ARM64 Linux binary: Reroll/bin/memscan.so. Frontend audit failed: window.SteamClient.Apps.RegisterForGameActionUserRequest is not a function. | 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 / unknown | broken | Frontend audit failed: Failed to resolve module specifier "./chunks/pluginApi-B8sV7970.js". Invalid relative url or base scheme isn't hierarchical.. | static archive, local browser, fake-Frame |
| 136: Decky-Clipper 1.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: Decky-Clipper/bin/gstreamer-1.0/libgstproxy.so. | static archive, local browser, fake-Frame |
| 137: Decky Proton Launch 0.9.0 | 1 / 2 | partial | Initializer succeeds; Proton wrapper installation and launch changes were not run. | static archive, local browser, fake-Frame |
| 138: Docky 1.4.13 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 139: Output Manager 1.4.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 140: IPv6 Enabler Plugin 0.0.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 141: DeckyClocker 1.0.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 142: MuteOnSleep 1.0.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 143: Desktop Apps 0.1.0 | 1 / 2 | partial | Initializer succeeds; Flatpak app discovery and Steam shortcut changes unverified. | static archive, local browser, fake-Frame |
| 144: Mixer 0.1.0 | 1 / 2 | partial | Initializer succeeds; actual audio mixer backend and volume changes unverified. | static archive, local browser, fake-Frame |
| 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 | sudo |
| 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 | pactl |
| 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 | flatpak |
| 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.
| 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 and missing Steam hook remain. |
| Decky Localsend | bin/localsend-core | LocalSend-Go commit 66edc509a28b9f877e7374504892dcbc653a4da4 cross-compiled successfully with CGO_ENABLED=0 GOOS=linux GOARCH=arm64. Static aarch64 ELF SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Not executed; source tip is not proven to match the shipped helper/protocol. Official ZIP is still x86_64. |
| 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.
+64 -7
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
@@ -19,11 +21,17 @@ panel or Decky backend keeps plugin folders until each is removed separately.
Frame Control's existing official-store manager downloads a published ZIP from
Decky's content-addressed CDN, verifies its store SHA-256 hash and manifest,
rejects unsafe ZIP members and root plugins, and swaps one managed plugin
rejects unsafe ZIP members, path collisions, special files and root plugins,
and swaps one managed plugin
folder with rollback on failure. It rejects non-ARM64 ELF and Windows binaries.
The Decky user service restarts after a file change. Framey's panel host accepts
only API v2 bundles with `dist/index.js`; other installed plugins remain
manageable but have no Framey panel. The store manager does not currently
API v2 bundles and API v1 bundles using Decky's modern frontend initializer,
with `dist/index.js`. Legacy `(function (...) {...})(DFL, SP_REACT)` bundles are
also accepted: their returned initializer receives the legacy `serverAPI`, and
keyword backend calls use Decky's legacy RPC. The wrapper runs as a blob ES
module inside the same sandbox; it does not require `eval`. Other installed plugins remain manageable but have no
Framey panel. API v1 admission is a compatibility attempt, not a guarantee
that the plugin's controls work in VR. The store manager does not currently
screen a plugin's runtime network downloads.
## What the compatibility host supplies
@@ -36,8 +44,15 @@ through Decky's *existing* websocket connection. Framey never opens a second
Decky websocket, because Decky allows only one frontend connection. The iframe
does not receive Frame Control's UI key. React and ReactDOM are bundled for
offline use under their MIT licenses in `ui/vendor`.
Framey gives each plugin a bounded `localStorage` substitute because browser
storage is denied to an opaque-origin iframe. The parent panel keeps a separate
cache for each plugin: up to 64 keys, 64 KiB per value and 128 KiB total. It
restores the cache when a panel reopens if the parent's browser storage is
available. If storage is denied or cleared, the cache lasts only for the current
panel. Use the plugin backend for settings that must survive cache loss.
The host currently supplies sections, rows, buttons, toggles, dropdowns,
The host currently supplies sections, rows, buttons, toggles, text fields,
sliders, grouped dropdowns,
scroll containers, a modal, locale information, local panel routes and toasts,
and backend calls for the plugin that owns the panel. Steam library/store page
patches, global Steam UI components, file pickers, and privileged root methods
@@ -53,6 +68,31 @@ live Decky settings persistence, or badges on Steam game pages. The last item
has no supported injection target in the Frame Control panel.
The [browser and fake Frame evidence](evidence/framey-browser-2026-10-25.md)
records exactly what was exercised.
The pinned official Steam Status 1.2.3 frontend also
[rendered in local Chromium](evidence/framey-steam-status-browser-2026-10-25.md)
with a stubbed settings reply. Its public status request worked, but its cache
logged `localStorage` errors in Framey's v0.4.34 opaque iframe. A later
[local browser check](evidence/framey-session-cache-2026-10-25.md) confirmed
that a panel-lifetime substitute avoids those errors while preserving the
opaque origin. A [second browser check](evidence/framey-cache-browser-2026-10-25.md)
tested the per-plugin cache after reopening a panel. These checks used a stub
backend and no worn headset.
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4,
Steam Status 1.2.3, and Storage Cleaner 1.4.0 archives, checks their API v1/v2
manifests and frontend bytes, and verifies they can be packed as Framey panels.
It does not execute their actual backend code or prove their frontend controls
work in VR. Storage Cleaner rendered an
[empty-data panel in local Chromium](evidence/framey-api-v1-browser-2026-10-25.md).
Its cleanup actions were not tested; they may remove local game saves.
Framey also supplies local tabs/sidebar pages, spinners, dialog/menu wrappers
and progress bars. Its explicit SteamClient subscription fallbacks return
disposable handles without emitting fabricated events. There is no attached
running game in `DFL.Router`, and power, app launch and hardware mutation methods
are not supplied. Plugins that need live Steam stores still require integration
work. IndexedDB is hidden because the browser exposes it but denies access in
an opaque iframe; localForage can then select Framey's bounded localStorage.
Direct IndexedDB users remain unsupported.
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,
@@ -64,7 +104,10 @@ The in-headset **Open official store** view loads the full official catalogue.
Search matches names, authors, and descriptions. Filters show non-root plugins
or installed plugins, and Previous/Next buttons browse 20 results per page.
The root label is a compatibility warning; other APIs are checked only when a
plugin is opened. The store still needs the managed Decky user service running
plugin is opened. Both `root` and `_root`
store tags mark a plugin as needing root, and duplicate store IDs are omitted
because an ID alone could select a different archive than the visible row.
The store still needs the managed Decky user service running
before its Install or Update buttons are enabled. If a plugin prevents Decky
from starting, Remove remains available for Frame Control-owned plugins while
the managed service is stopped; it does not restart the service.
@@ -74,7 +117,10 @@ The store verifies each downloaded archive and the headset applies it with a
rollback if Decky cannot start. The panel remembers the current job ID for this
browser session, so **Check result** can resume polling after a navigation or
temporary connection error. Refresh the installed list before starting another
operation after an error. If Frame Control restarts, it cannot recover the old
operation after an error. The headset panel and desktop app send the hash of
the version you saw; if the official store changes that version before the job
starts, Frame Control stops before downloading or changing the headset. Refresh
the store and review the new version before retrying. If Frame Control restarts, it cannot recover the old
job result. The panel clears that job ID and asks you to refresh the installed
list before making another change.
@@ -127,7 +173,7 @@ The Decky and headset service manual removal commands are in
## Fast worn-headset check
With an already installed Framey plugin and someone wearing the headset, run
`python3 scripts/framey-worn-check.py` from a paired computer. The script
`python3 scripts/framey-worn-check.py --worn-confirmed` from a paired computer. The script
atomically acquires `/tmp/frame-test.lock` before reading battery or SteamVR
activity. If another job owns the lock, it makes no sensor reads. It releases
its own lock without starting the interaction when the headset is in standby.
@@ -140,3 +186,14 @@ color count, then removes the private temporary image from both devices.
Pixel variety alone cannot prove the UI text was legible; a person must still
confirm the view in the headset. The script does not install or remove a plugin,
alter Steam settings, or wake the headset.
## Unattended VR checks
The [unattended check](framey-vr-check.md) can wake, inspect and capture the
real compositor, send OpenVR mouse input, and exercise a temporary sample
against live Decky with restoration and shared-lock protection. Its
[device evidence](evidence/framey-vr-2026-10-25/README.md) distinguishes these
checks from physical controller and wearer validation. A trusted pointer event
and active-headset telemetry are both reproducible without wearing the Frame;
they must not be labelled as physical controller proof.
+115
View File
@@ -0,0 +1,115 @@
# Checking Framey without wearing the Frame
Use the existing paired SSH alias, directly or through lxso2. Start with the
read-only check, then a temporary wake. A full input exercise is opt in:
```sh
python3 scripts/framey-vr-check.py --via lxso2 --output /tmp/framey-inspect
python3 scripts/framey-vr-check.py --via lxso2 --wake --output /tmp/framey-wake
python3 scripts/framey-vr-check.py --via lxso2 --exercise --output /tmp/framey-exercise
```
Omit `--via` when the computer can already `ssh frame`; use `--host` for another
paired alias. No SSH trust or credentials are changed. The output folder is
private by default. Stereo screenshots and the OBS mirror can contain room
pixels. Review/redact them before committing or sharing. JSON reports have
screenshot hashes and no UI access keys.
The runner acquires `/tmp/frame-test.lock` by atomic `mkdir`, writes a random
owner, then reads battery and SteamVR state. A foreign lock prevents sensor
reads, wake, installation and input. Unknown battery or below 20% aborts.
Battery is read again before release. The default check never wakes or
installs anything; it returns nonzero when the panel is absent/hidden.
`--wake` saves the exact current `power.pauseCompositorOnStandby` and
`power.turnOffScreensTimeout`, sets them to `0` and `120`, calls the supported
`vrcmd --handlewakeup` and requests the Frame Control dashboard panel. It reads
OpenVR state, captures a stereo compositor screenshot through the production
`ui/frame_vrshot.py` API, and captures `/dev/video99`, the same SteamVR mirror
used by OBS. The mirror reader drains 60 frames before selecting its image,
because the first loopback buffer can contain a previous session. It restores the saved power values and reads them back. It hides
the test dashboard if the dashboard was originally closed. It does not change
the proximity sensor, tracking driver, display hardware, root filesystem,
room setup, or spatial panel placement. An unreachable suspended Frame still
needs a physical wake; an SSH command cannot reach it.
`--exercise` requires the managed headset panel and Decky services to be absent;
it refuses to replace an existing installation. It creates a random test
plugin folder, checks that its settings folder does not exist, and installs
the repository's actual sample frontend/backend, with only the plugin name
and receipt changed to distinguish this test. It installs the verified official
ARM64 Decky release through Frame Control's existing installer, followed by the
normal headset app bundle. Only the test bundle's browser startup is modified:
loopback-only `--remote-debugging-port=0` and a disposable `/tmp` browser profile.
Production browser startup is unchanged. Decky's installer can reload Steam's
web helper; this is not a Steam/SteamVR restart, but its current UI can change.
The test should run while the headset is unattended.
CDP opens the actual plugin through `openFrameyPanel`, observes the opaque
iframe through its own debugging target, and reads control geometry. OpenVR's
`IVROverlayView_003.PostOverlayEvent` supplies mouse move/down/up to the
actual gamescope overlay. The check verifies delivery to the intended element
and a trusted pointer event, toggles the sample, reads its real Decky settings
file, reopens a fresh iframe, and checks the saved value. It also clicks the
Settings/Panel tabs and quick action. A smooth-scroll event is attempted and its measured scroll offset is reported.
Temporary controls exercise the shipped
`DFL.SliderField` and `DFL.TextField`; their values remain UI-only. Text uses
CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input,
not proof of a physical controller or the SteamVR virtual keyboard.
SteamVR can leave a recreated panel hidden with a stale focus request. The
exercise 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
visibility, actual panel visibility, and pixel capture are separate checks.
A visible docked panel can report `IsDashboardVisible=false` and
`IsActiveDashboardOverlay=false`. A zero-sized main texture or failed image
read must not be described as a texture-refresh measurement. The compositor
before/after images provide independent pixel evidence.
Normal cleanup removes only the test-created managed services and random
plugin/profile paths, restores power, cancels its power watchdog, checks battery
and settings, then releases its own lock. A four-minute independent systemd
user timer restores the saved power values even if the controlling process is
killed or disconnected. It deliberately leaves the lock and test artifacts
for recovery. `recovery.json` records the host, owner, timer, remote restore
script and original values as soon as the timer starts. The final report is
`result.json`. Cleanup errors force a nonzero exit and retain the lock; inspect
that report before manually releasing it. Never remove another job's lock.
A power watchdog is not a promise to complete service cleanup after a hard
kill. The next owner must finish that recorded cleanup first.
Exit 0 means the requested machine checks and cleanup passed. It never means
a wearer has approved readability, comfort, controller rays, haptics or text
entry on the VR keyboard. The [real-device evidence](evidence/framey-vr-2026-10-25/README.md)
records the exact tested version and limits. No public CI runner should contact
the shared headset: run this command on a paired, serialized self-hosted worker.
Ordinary CI runs `tests/test_framey_vr_check.py`, the sample backend tests, and
the same-plugin navigation regression without a headset.
## Three-minute worn checklist
1. **0:00–0:30:** Coordinate the shared lock. With an installed managed panel,
Decky backend and harmless sample, wear the headset and open SteamVR →
Frame Control. Check the panel is in front of you, both eyes agree, text is
legible, and the size is comfortable. Do not use a save-cleaning plugin.
2. **0:30–1:15:** Run
`python3 scripts/framey-worn-check.py --via lxso2 --worn-confirmed`. Within its 35-second
interaction window, use the physical controller to open the sample and
toggle **Example setting**. Confirm the laser aims where expected and the
script records a fresh input/panel event and visible stereo output. The flag
is the operator’s statement; sensor activity and trusted pointers can also
be produced synthetically and cannot identify controller hardware.
3. **1:15–2:15:** Use the controller to open Settings, return to Panel, invoke
**Read saved setting**, close/reopen the plugin, and confirm the value
remains. Try controller scroll, a slider and a harmless text field in an
installed compatible plugin; check SteamVR's actual keyboard and Back.
4. **2:15–3:00:** Restore the original sample setting. Switch away and back,
close/reopen the Frame Control panel, and confirm controller input still
works. Record comfort/legibility, controller model, any haptic behavior,
keyboard behavior and the command's JSON separately. Confirm battery and
release only your lock. Do not infer these human checks from synthetic input.
The watchdog's creation and cancellation are exercised on the real Frame.
A forced host kill/network loss followed by timer recovery has not been fault
injected. Its saved recovery script remains available on a cleanup failure.
+6
View File
@@ -20,6 +20,12 @@ count. So a build reaches people only when you publish it, after testing it.
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
draft by its exact version title, restores the tag association and retries
the affected upload; it refuses ambiguous matches.
The Linux build runs `scripts/check-appimage-launch.py` before upload. It
starts the built x86_64 AppImage on an isolated Xvfb display, checks that
its bundled server answers, and checks that the server stops on exit. The
check disables telemetry and update requests, and keeps extraction in its
temporary profile. This CI test does not test a physical display, FUSE mount
or a headset.
2. Download the draft's installers and test them. An installed copy of the
previous version won't offer the draft, so install it directly.
+21 -5
View File
@@ -2,9 +2,24 @@
This is a small Decky API v2 plugin for authors. Its panel uses the official
`@decky/api`, `@decky/ui`, and `@decky/rollup` packages. A toggle calls a Python
backend method, stores one boolean under `decky.decky_SETTINGS_DIR`,
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.
values. The frontend also shows a local toast after a successful save. 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
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 **About this setting** button opens a local detail page at
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
`Navigation.Navigate`, `useParams`, `NavigateBack`, and removal of the route in
`onDismount`. The `:topic?` segment is optional. Framey keeps this navigation
inside the plugin panel; it does not open a Steam library page. Put optional
segments at the end of the path and do not use query strings. This detail page
has not been checked with headset controllers.
## Build
@@ -27,11 +42,12 @@ plugin directory does not enable its VR panel.
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
repository root. It checks backend persistence and evaluates the built bundle
against Framey's actual host script. The fake Frame store flow is exercised by
against Framey's actual host script, including the quick action's scoped
backend call and local route registration/navigation. The fake Frame store flow is exercised by
the repository's e2e suite. No worn-headset visibility or controller-input test
has passed for this sample yet.
The plugin needs no root access or system changes. Its settings remain after
the managed plugin folder is removed, following Decky's settings behavior.
For a production plugin, add migration and recovery handling for damaged
settings files, and test every UI path in a worn headset.
For a production plugin, add explicit migrations for every supported schema,
explain how users can reset settings, and test every UI path in a worn headset.
+26 -6
View File
@@ -16,6 +16,7 @@ if (api._version != API_VERSION) {
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);
}
const callable = api.callable;
const routerHook = api.routerHook;
const toaster = api.toaster;
const definePlugin = (fn) => {
return (...args) => {
@@ -59,13 +60,32 @@ function Content() {
setBusy(false);
}
}
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
}
var index = definePlugin(() => ({
name: 'Framey Settings Sample',
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
content: SP_JSX.jsx(Content, {}),
}));
function Details() {
const { topic } = DFL.useParams();
return SP_JSX.jsxs(DFL.PanelSection, { title: "Sample detail page", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsxs("p", { children: ["Topic: ", topic || 'overview'] }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { children: "The toggle is stored by this plugin's Decky backend in the user's settings directory." }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.NavigateBack(), children: "Back to setting" }) })] });
}
var index = definePlugin(() => {
routerHook.addRoute('/framey-sample/details/:topic?', Details);
return Object.assign({
name: 'Framey Settings Sample',
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
content: SP_JSX.jsx(Content, {}),
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
}, {
// Framey additions are ignored by Decky's own menu. Keep the main content
// usable there too, while showing authors the extra VR settings view.
settings: SP_JSX.jsx(Content, {}),
frameyQuickActions: [{
label: 'Read saved setting',
onClick: async () => {
const enabled = await getSetting('enabled', false);
return `Saved setting is ${enabled ? 'on' : 'off'}`;
},
}],
});
});
export { index as default };
//# sourceMappingURL=index.js.map
+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, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => ({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WAAA,EAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,8CAAA,EAAA,GAAA,CAAA,QAAA,CAAA,6BAAA,CAAA,CAAA;AACA;AAEA,MAAA,QAAA,GAAA,GAAA,CAAA,QAAA;AAIA,MAAA,OAAA,GAAA,GAAA,CAAA,OAAA;AAQA,MAAA,YAAA,GAAA,CAAA,EAAA,KAAA;AACA,IAAA,OAAA,CAAA,GAAA,IAAA,KAAA;AACA,QAAA,OAAA,EAAA,CAAA,GAAA,IAAA,CAAA;AACA,IAAA,CAAA;AACA,CAAA;;ACnCA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AACA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AAEA,SAAA,OAAA,Line truncated
{"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
+5 -4
View File
@@ -8,7 +8,7 @@ import decky
def settings_file():
# Decky provides this per-plugin path through its official Python module.
return Path(decky.decky_SETTINGS_DIR) / 'settings.json'
return Path(decky.DECKY_PLUGIN_SETTINGS_DIR) / 'settings.json'
class Plugin:
@@ -16,10 +16,11 @@ class Plugin:
if key != 'enabled':
raise ValueError('Unknown setting')
try:
stored = json.loads(settings_file().read_text())
except FileNotFoundError:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return default
return stored.get(key, default)
value = stored.get(key) if isinstance(stored, dict) else None
return value if type(value) is bool else default
async def set_setting(self, key: str, value: bool):
if key != 'enabled' or type(value) is not bool:
+29 -4
View File
@@ -1,5 +1,5 @@
import {callable, definePlugin, toaster} from '@decky/api';
import {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
import {useEffect, useState} from 'react';
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
@@ -45,12 +45,37 @@ function Content() {
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
</PanelSectionRow>
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
</PanelSection>;
}
export default definePlugin(() => ({
function Details() {
const {topic} = useParams<{topic?: string}>();
return <PanelSection title="Sample detail page">
<PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>
<PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>
<PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>
</PanelSection>;
}
export default definePlugin(() => {
routerHook.addRoute('/framey-sample/details/:topic?', Details);
return Object.assign({
name: 'Framey Settings Sample',
icon: <span aria-hidden="true">⚙</span>,
content: <Content />,
}));
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
}, {
// Framey additions are ignored by Decky's own menu. Keep the main content
// usable there too, while showing authors the extra VR settings view.
settings: <Content />,
frameyQuickActions: [{
label: 'Read saved setting',
onClick: async () => {
const enabled = await getSetting('enabled', false);
return `Saved setting is ${enabled ? 'on' : 'off'}`;
},
}],
});
});
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Launch a built Linux AppImage on Xvfb and check its bundled server.
This checks the packaged app without a headset or a desktop login session.
The profile and display are temporary, and every process started by this check
is stopped before it exits.
"""
import hashlib
import os
from pathlib import Path
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
def server_port(profile):
for log in (profile / "config").rglob("server.log"):
ports = re.findall(r"--port (\d+)", log.read_text(errors="replace"))
if ports:
return int(ports[-1])
return None
def port_closed(port):
try:
with socket.create_connection(("127.0.0.1", port), timeout=1):
return False
except OSError:
return True
def check(appimage):
if not appimage.is_file() or not os.access(appimage, os.X_OK):
raise RuntimeError(f"AppImage is missing or cannot run: {appimage}")
if not shutil.which("xvfb-run"):
raise RuntimeError("xvfb-run is required for the AppImage smoke check")
digest = hashlib.sha256(appimage.read_bytes()).hexdigest()
with tempfile.TemporaryDirectory(prefix="frame-appimage-smoke-") as directory:
profile = Path(directory)
(profile / "tmp").mkdir()
env = dict(os.environ)
env.update(
APPIMAGE_EXTRACT_AND_RUN="1", ELECTRON_DISABLE_SANDBOX="1",
TMPDIR=str(profile / "tmp"),
XDG_CONFIG_HOME=str(profile / "config"),
XDG_CACHE_HOME=str(profile / "cache"),
XDG_DATA_HOME=str(profile / "data"),
FRAME_ALIAS="frame-control-appimage-smoke-no-headset",
FRAME_CONTROL_TELEMETRY="0", FRAME_CONTROL_NO_UPDATE_CHECK="1",
DO_NOT_TRACK="1",
)
output = profile / "app.log"
port = None
passed = False
with output.open("w") as log:
proc = subprocess.Popen(
["xvfb-run", "-a", "-s", "-screen 0 1280x800x24 -nolisten tcp",
str(appimage), "--no-sandbox"],
cwd=profile, env=env, stdout=log, stderr=subprocess.STDOUT,
start_new_session=True,
)
try:
deadline = time.monotonic() + 60
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"AppImage exited {proc.returncode} before its server answered")
port = server_port(profile)
if port is not None:
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) as response:
if response.status == 200 and response.headers.get("Server", "").startswith("FrameControl/1"):
passed = True
break
except (OSError, urllib.error.URLError):
pass
time.sleep(0.5)
if not passed:
raise RuntimeError("AppImage server did not answer within 60 seconds")
except BaseException:
log.flush()
print(output.read_text(errors="replace")[-1500:], file=sys.stderr)
raise
finally:
try:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait(timeout=10)
if port is not None:
for _ in range(20):
if port_closed(port):
break
time.sleep(0.5)
else:
raise RuntimeError(f"AppImage server stayed on port {port} after shutdown")
print(f"AppImage SHA-256 {digest}: HTTP 200, FrameControl/1, server stopped")
if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: check-appimage-launch.py path/to/AppImage")
check(Path(sys.argv[1]).resolve())
+122
View File
@@ -0,0 +1,122 @@
#!/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;
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: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:[]};
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)
args = parser.parse_args()
plugins = json.loads(args.catalog.read_text())['plugins']
records = {p['id']: p for p in plugins}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
path = self.path.split('?')[0]
content_type = 'text/html; charset=utf-8'
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).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
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)
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()
+107
View File
@@ -0,0 +1,107 @@
#!/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. None qualifies from this audit.',
'- **Partial** means a limited frontend/settings surface initialized or rendered. Backend-dependent functionality remains unverified; it is not an end-to-end success claim.',
'- **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. All other 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. '
'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.', '',
'| 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()
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Bounded real-Frame VR diagnostics. No device writes without the shared lock.
Default: inspect only. --wake: reversible wake + stereo capture. --exercise:
temporary clean user-service install, live sample settings and OpenVR input.
Exit 0 means requested machine checks passed, never a worn/controller verdict.
"""
import argparse
import base64
import datetime
import hashlib
import io
import json
from pathlib import Path
import secrets
import shlex
import subprocess
import sys
import tarfile
import time
import signal
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_decky
import frame_headset
LOCK = '/tmp/frame-test.lock'
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
KEY = 'valve.steam.desktopgame.2001947813'
class Remote:
def __init__(self, host, via=None):
self.argv = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
'-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2', host]
self.via = via
def __call__(self, command, stdin=None, text=True, timeout=30):
argv = self.argv + [command]
if self.via:
argv = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', self.via,
shlex.join(argv)]
result = subprocess.run(argv, input=stdin, capture_output=True, text=text, timeout=timeout)
if result.returncode:
raise RuntimeError((result.stderr or result.stdout).strip())
return result.stdout
def python(self, source, timeout=30):
return json.loads(self('python3 -', stdin=source, timeout=timeout))
PROBE = '''
import json,pathlib,subprocess,ctypes as C
lib=C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
lib.VR_InitInternal2.argtypes=[C.POINTER(C.c_int),C.c_int,C.c_char_p]
lib.VR_GetGenericInterface.argtypes=[C.c_char_p,C.POINTER(C.c_int)]
lib.VR_GetGenericInterface.restype=C.c_void_p
error=C.c_int();lib.VR_InitInternal2(C.byref(error),3,None)
if error.value: raise RuntimeError('OpenVR unavailable')
try:
table=lib.VR_GetGenericInterface(b'FnTable:IVROverlay_027',C.byref(error))
if error.value or not table: raise RuntimeError('Overlay ABI unavailable')
dashboard=bool(C.CFUNCTYPE(C.c_bool)(C.cast(table,C.POINTER(C.c_void_p))[66])())
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()))
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')),
'overlays':cmd(vr,'--overlays'),
'pause':cmd(vr,'--settings-bool','power.pauseCompositorOnStandby'),
'timeout':cmd(vr,'--settings-float','power.turnOffScreensTimeout')}))
'''
def settings(state):
pause = state['pause'].split('=', 1)[1]
timeout = state['timeout'].split('=', 1)[1]
if pause not in ('true', 'false') or not 0 <= float(timeout) <= 86400:
raise RuntimeError('Cannot safely save original SteamVR power values')
return pause, timeout
def diagnostic_source():
return (ROOT / 'scripts/framey_openvr.py').read_text() + '''
import json
vr=Overlay()
try: print(json.dumps({'state':vr.snapshot(),'imageRead':vr.image_hash()}))
finally: vr.close()
'''
def debug_bundle(original, profile):
"""Only the test-owned browser gets CDP and a disposable profile."""
out = io.BytesIO()
with tarfile.open(fileobj=io.BytesIO(original), mode='r:gz') as src, tarfile.open(fileobj=out, mode='w:gz') as dst:
for item in src.getmembers():
data = src.extractfile(item).read()
if item.name == 'ui/frame_headset_runtime.py':
code = data.decode()
needle = "'--noerrdialogs', '--window-size=1200,800',"
if needle not in code:
raise RuntimeError('Runtime changed; review test browser setup')
code = code.replace(needle, "'--noerrdialogs', '--remote-debugging-port=0', '--window-size=1200,800',")
code = code.replace("str(BASE / 'profile')", repr(profile))
data = code.encode()
item.size = len(data)
dst.addfile(item, io.BytesIO(data))
return out.getvalue()
def exercise_source(profile, plugin_id, folder):
# Runs entirely on the headset. The normal iframe, HTTP handler, CEF bridge
# and Decky Python backend are used. CDP observes DOM/coordinates; OpenVR
# delivers clicks. No JS .click() or backend stubs.
return (ROOT/'scripts/framey_openvr.py').read_text() + '\n' + (ROOT/'scripts/framey_vr_input.py').read_text() + '\n' + (
'print(json.dumps(exercise(%r,%r,%r)))\n' % (profile, plugin_id, folder))
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host', default='frame')
parser.add_argument('--via', help='Existing paired SSH host, e.g. lxso2')
parser.add_argument('--wake', action='store_true')
parser.add_argument('--exercise', action='store_true', help='Temporary user installs; requires no managed panel/Decky install')
parser.add_argument('--output', type=Path, required=True, help='Private evidence folder (screenshots may contain room pixels)')
args = parser.parse_args(argv)
args.output.mkdir(parents=True, exist_ok=True, mode=0o700)
report = {'schema': 1, 'time': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'controllerHardwareVerified': False, 'wornVisualComfortVerified': False}
git = subprocess.run(['git','rev-parse','HEAD'],cwd=ROOT,capture_output=True,text=True)
report['sourceCommit'] = git.stdout.strip() if git.returncode == 0 else None
remote = Remote(args.host, args.via)
token = 'framey-vr-' + secrets.token_hex(8)
folder = token
profile = '/tmp/' + token + '-profile'
locked = power_changed = plugin_created = decky_created = panel_created = watchdog = False
original_bundle = frame_headset.bundle
before = None
previous_term = signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(RuntimeError('Terminated; cleaning up')))
try:
remote('mkdir ' + LOCK + ' && { printf %s ' + shlex.quote(token) + ' > ' + LOCK + '/owner || { rmdir ' + LOCK + '; exit 1; }; }')
locked = True
before = remote.python(PROBE)
report['before'] = before
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:
# Independent systemd timer restores power even if the controller is
# killed or SSH disappears. It retains the test lock for recovery.
recovery = '/tmp/' + token + '-restore.py'
restore_source = ("import pathlib,subprocess\n"
"p=pathlib.Path('/tmp/frame-test.lock/owner')\n"
"if p.exists() and p.read_text()==" + repr(token) + ":\n"
" subprocess.run([" + repr(VRCMD) + ", '--set-settings-bool','power.pauseCompositorOnStandby'," + repr('1' if pause == 'true' else '0') + "],check=True)\n"
" subprocess.run([" + repr(VRCMD) + ", '--set-settings-float','power.turnOffScreensTimeout'," + repr(timeout) + "],check=True)\n")
remote('umask 077; cat > ' + shlex.quote(recovery), stdin=restore_source)
remote('systemd-run --user --quiet --unit=' + token + '-restore --on-active=240s /usr/bin/python3 ' + shlex.quote(recovery))
watchdog = True
report['powerRecovery'] = {'timer':token+'-restore.timer','script':recovery,
'originalPause':pause,'originalTimeout':timeout}
(args.output/'recovery.json').write_text(json.dumps({'host':args.host,'via':args.via,'lockOwner':token,**report['powerRecovery']},indent=2)+'\n')
if args.exercise:
d, h = frame_decky.status(remote), frame_headset.status(remote)
if d['managed'] or h['managed'] or d['upstreamInstall']:
raise RuntimeError('Exercise needs absent managed services; existing installs left intact')
# Refuse every collision, including retained plugin settings.
files = {name: base64.b64encode((ROOT/'examples/framey-settings'/name).read_bytes()).decode()
for name in ('plugin.json', 'main.py', 'dist/index.js')}
source = '''
import base64,json,pathlib
base=pathlib.Path.home()/'.local/share/frame-control/decky'
folder=%r
p=base/'plugins'/folder
if p.exists() or (base/'settings'/folder).exists(): raise RuntimeError('Test path collision')
p.mkdir(parents=True); (p/'dist').mkdir()
for name,data in %r.items(): (p/name).write_bytes(base64.b64decode(data))
manifest=json.loads((p/'plugin.json').read_text());manifest['name']='Framey VR Check '+folder
(p/'plugin.json').write_text(json.dumps(manifest))
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check'}))
print(json.dumps({'created':True}))
''' % (folder, files)
remote.python(source)
plugin_created = True
decky_created = True
report['deckyInstall'] = frame_decky.change(remote, 'install')
# Decky reloads its Steam frontend; install the panel afterwards.
time.sleep(3)
frame_headset.bundle = lambda: debug_bundle(original_bundle(), profile)
panel_created = True
report['panelInstall'] = frame_headset.change(remote, 'install')
if args.wake or args.exercise:
power_changed = True # before first setter, so partial failure restores
remote(VRCMD + ' --set-settings-bool power.pauseCompositorOnStandby 0')
remote(VRCMD + ' --set-settings-float power.turnOffScreensTimeout 120')
remote(VRCMD + ' --handlewakeup')
remote(VRCMD + ' --showdashboard ' + KEY)
time.sleep(2)
report['overlay'] = remote.python(diagnostic_source())
if args.exercise and not report['overlay']['state']['visible']:
report['retaggedTestWindow'] = True
remote.python('''
import json,pathlib,subprocess,time,os
p=pathlib.Path.home()/'.local/share/frame-control/headset/state.json'
w=json.loads(p.read_text())['window'];env={**os.environ,'DISPLAY':':0'}
subprocess.run(['xprop','-id',w,'-remove','STEAM_GAME'],env=env,check=True)
time.sleep(1)
subprocess.run(['xprop','-id',w,'-f','STEAM_GAME','32c','-set','STEAM_GAME','2001947813'],env=env,check=True)
time.sleep(1)
subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--showdashboard','valve.steam.desktopgame.2001947813'],capture_output=True,check=True)
time.sleep(2)
print('{}')
''')
report['overlay'] = remote.python(diagnostic_source())
if args.wake or args.exercise:
shot = remote.python((ROOT/'ui/frame_vrshot.py').read_text(), timeout=20)['path']
try:
image = remote('cat ' + shlex.quote(shot), text=False, timeout=20)
(args.output/'before-stereo-private.png').write_bytes(image)
report['beforeStereoSha256'] = hashlib.sha256(image).hexdigest()
finally:
remote('rm -f ' + shlex.quote(shot))
if args.exercise:
report['input'] = remote.python(exercise_source(profile, 2000000001, folder), timeout=100)
if args.wake or args.exercise:
shot = remote.python((ROOT/'ui/frame_vrshot.py').read_text(), timeout=20)['path']
try:
image = remote('cat ' + shlex.quote(shot), text=False, timeout=20)
(args.output/'stereo-private.png').write_bytes(image)
report['stereoSha256'] = hashlib.sha256(image).hexdigest()
report['compositorChanged'] = report['stereoSha256'] != report.get('beforeStereoSha256')
finally:
remote('rm -f ' + shlex.quote(shot))
# Same SteamVR mirror used by OBS; FFmpeg is already on the Frame.
mirror = remote("ffmpeg -hide_banner -loglevel error -f v4l2 -i /dev/video99 -vf 'select=gte(n\\,60)' -frames:v 1 -f image2pipe -vcodec png -", text=False, timeout=20)
(args.output/'mirror-private.png').write_bytes(mirror)
report['mirrorSha256'] = hashlib.sha256(mirror).hexdigest()
report['passed'] = bool(report['overlay']['state']['visible'])
if args.exercise:
report['passed'] = report['passed'] and report['input']['passed'] and report.get('compositorChanged',False)
except Exception as exc:
report['error'] = str(exc)
report['passed'] = False
finally:
frame_headset.bundle = original_bundle
cleanup = []
def clean(name, action):
try: action()
except Exception as exc: cleanup.append({'step':name,'error':str(exc)})
if locked:
if panel_created:
clean('remove test panel', lambda: frame_headset.change(remote, 'remove') if frame_headset.status(remote)['managed'] else None)
if decky_created:
clean('remove test Decky', lambda: frame_decky.change(remote, 'remove') if frame_decky.status(remote)['managed'] else None)
if plugin_created:
# Only random per-run paths created after collision checks.
clean('remove test files', lambda: remote.python('''
import pathlib,shutil,json
base=pathlib.Path.home()/'.local/share/frame-control/decky'
for parent in ('plugins','settings','data','logs'):
p=base/parent/%r
if p.is_dir() and not p.is_symlink(): shutil.rmtree(p)
p=pathlib.Path(%r)
if p.is_dir() and not p.is_symlink(): shutil.rmtree(p)
print('{}')
''' % (folder, profile)))
if power_changed:
clean('restore compositor pause', lambda: remote(VRCMD + ' --set-settings-bool power.pauseCompositorOnStandby ' + ('1' if pause == 'true' else '0')))
clean('restore screen timeout', lambda: remote(VRCMD + ' --set-settings-float power.turnOffScreensTimeout ' + shlex.quote(timeout)))
clean('restore dashboard visibility', lambda: remote(VRCMD + ' --hidedashboard') if not before['dashboardVisible'] else None)
try:
report['after'] = remote.python(PROBE)
if power_changed and settings(report['after']) != settings(before):
cleanup.append({'step':'read back power settings','error':'Original values not restored'})
except Exception as exc: cleanup.append({'step':'after probe','error':str(exc)})
if watchdog and not cleanup:
clean('stop power recovery timer', lambda: remote.python('''
import subprocess,json
unit=%r
timer=subprocess.run(['systemctl','--user','show',unit+'.timer','--property=LoadState','--value'],capture_output=True,text=True)
if timer.stdout.strip()=='loaded': subprocess.run(['systemctl','--user','stop',unit+'.timer'],check=True,capture_output=True)
p=subprocess.run(['systemctl','--user','show',unit+'.service','--property=LoadState','--value'],capture_output=True,text=True)
if p.stdout.strip()=='loaded': subprocess.run(['systemctl','--user','stop',unit+'.service'],check=True,capture_output=True)
print('{}')
''' % (token+'-restore')))
if not cleanup:
clean('remove power recovery script', lambda: remote('rm -f ' + shlex.quote(recovery)))
report['powerRecoveryRemoved'] = not cleanup
if not cleanup: (args.output/'recovery.json').unlink(missing_ok=True)
if not cleanup:
clean('release owned lock', lambda: remote('test "$(cat ' + LOCK + '/owner)" = ' + shlex.quote(token) + ' && rm ' + LOCK + '/owner && rmdir ' + LOCK))
report['lockReleased'] = not cleanup
else:
report['lockRetainedForRecovery'] = token
report['cleanupErrors'] = cleanup
report['passed'] = bool(report.get('passed') and not cleanup)
(args.output/'result.json').write_text(json.dumps(report, indent=2)+'\n')
signal.signal(signal.SIGTERM, previous_term)
print(json.dumps(report, indent=2))
return 0 if report['passed'] else 1
if __name__ == '__main__':
sys.exit(main())
+24 -8
View File
@@ -4,6 +4,7 @@
Requires an already installed Frame Control VR panel, Decky user service, and
one Framey-managed plugin. It never installs, restarts, or removes a service.
"""
import argparse
import datetime
import hashlib
import json
@@ -18,10 +19,16 @@ import time
ROOT = Path(__file__).resolve().parents[1]
SSH = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame']
LOCK = '/tmp/frame-test.lock'
VIA = None
def ssh_argv(command):
argv = [*SSH, command]
return ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', VIA, shlex.join(argv)] if VIA else argv
def remote(command, stdin=None, timeout=15):
run = subprocess.run([*SSH, command], input=stdin, capture_output=True,
run = subprocess.run(ssh_argv(command), input=stdin, capture_output=True,
text=True, timeout=timeout)
if run.returncode:
raise RuntimeError((run.stderr or run.stdout).strip() or f'SSH exited {run.returncode}')
@@ -92,12 +99,14 @@ def color_count(text):
return len(set(re.findall(r'#[0-9A-Fa-f]{6,8}', text)))
def main():
result = {'time':datetime.datetime.now(datetime.timezone.utc).isoformat(), 'proof':'worn-headset check'}
def main(worn_confirmed=False):
result = {'time':datetime.datetime.now(datetime.timezone.utc).isoformat(), 'proof':'operator-confirmed worn check', 'operatorWornConfirmed':worn_confirmed}
token = 'framey-' + secrets.token_hex(12)
locked = False
challenge = False
try:
if not worn_confirmed:
raise RuntimeError('A wearer must be present; pass --worn-confirmed only after checking in person. Wake/activity and trusted pointer events alone do not prove wearing or controller hardware.')
# Do not read even standby status until this task owns the device lock.
remote('mkdir ' + LOCK + ' && { printf %s ' + shlex.quote(token) +
' > ' + LOCK + '/owner || { rm -f ' + LOCK + '/owner; rmdir ' + LOCK + '; exit 1; }; }')
@@ -126,11 +135,11 @@ def main():
time.sleep(1)
result['interaction'] = state
result['after'] = probe()
result['worn'] = isinstance(result['after']['activity'], int) and result['after']['activity'] < 3
result['activeHeadset'] = isinstance(result['after']['activity'], int) and result['after']['activity'] < 3
result['overlay'] = json.loads(remote('python3 -', stdin=OVERLAY_PROBE, timeout=15))
result['panelVisible'] = result['overlay']['visible'] is True
installed_ids = {p['id'] for p in before['plugins']}
result['controllerInput'] = bool(state.get('input', {}).get('at', 0) > state.get('started', 0))
result['trustedPointerInput'] = bool(state.get('input', {}).get('at', 0) > state.get('started', 0))
result['pluginPanelReady'] = bool(state.get('panel', {}).get('at', 0) > state.get('started', 0)
and state['panel'].get('pluginId') in installed_ids)
if result['panelVisible'] and result['pluginPanelReady']:
@@ -138,7 +147,7 @@ def main():
local = Path('/tmp')/('framey-worn-' + token + '.png')
try:
with local.open('wb') as out:
copy = subprocess.run([*SSH, 'cat ' + shlex.quote(shot)], stdout=out, stderr=subprocess.PIPE, timeout=20)
copy = subprocess.run(ssh_argv('cat ' + shlex.quote(shot)), stdout=out, stderr=subprocess.PIPE, timeout=20)
if copy.returncode: raise RuntimeError('Could not copy the SteamVR screenshot')
result['screenshotSha256'] = hashlib.sha256(local.read_bytes()).hexdigest()
pixels = subprocess.run(['convert', str(local), '-resize', '64x64!', 'txt:-'],
@@ -147,7 +156,7 @@ def main():
finally:
local.unlink(missing_ok=True)
remote('rm -f ' + shlex.quote(shot))
result['passed'] = (result['worn'] and result['panelVisible'] and result['controllerInput'] and
result['passed'] = (result['activeHeadset'] and result['panelVisible'] and result['trustedPointerInput'] and
result['pluginPanelReady'] and (result.get('screenshotColors') or 0) > 8)
except Exception as exc:
result['error'] = str(exc)
@@ -167,4 +176,11 @@ def main():
if __name__ == '__main__':
sys.exit(main())
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--host', default='frame')
parser.add_argument('--via', help='Paired SSH hop, e.g. lxso2')
parser.add_argument('--worn-confirmed', action='store_true', help='Operator confirms someone is wearing the Frame')
args = parser.parse_args()
SSH[-1] = args.host
VIA = args.via
sys.exit(main(args.worn_confirmed))
+117
View File
@@ -0,0 +1,117 @@
"""Small OpenVR diagnostic client; runs on the Frame, never polls another app's events.
Indices/signatures are for IVROverlay_027 and IVROverlayView_003 from Valve's
shipped openvr_capi.h, not interchangeable with IVROverlay_028.
"""
import ctypes as C
import hashlib
KEY = b'valve.steam.desktopgame.2001947813'
class Vector(C.Structure):
_fields_ = [('x', C.c_float), ('y', C.c_float)]
class Matrix(C.Structure):
_fields_ = [('m', (C.c_float * 4) * 3)]
class Mouse(C.Structure):
_fields_ = [('x', C.c_float), ('y', C.c_float), ('button', C.c_uint32)]
class Scroll(C.Structure):
_fields_ = [('xdelta',C.c_float),('ydelta',C.c_float),('unused',C.c_uint32),('viewportscale',C.c_float)]
class Data(C.Union):
_fields_ = [('mouse', Mouse), ('scroll', Scroll), ('reserved', C.c_uint64 * 6)]
class Event(C.Structure):
_pack_ = 4 # Linux OpenVR preserves its historical 4-byte event packing.
_fields_ = [('eventType', C.c_uint32), ('device', C.c_uint32),
('age', C.c_float), ('data', Data)]
class Overlay:
def __init__(self):
self.vr = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
self.vr.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
self.vr.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
self.vr.VR_GetGenericInterface.restype = C.c_void_p
error = C.c_int()
self.vr.VR_InitInternal2(C.byref(error), 3, None) # background, no scene focus
if error.value:
raise RuntimeError('OpenVR init: ' + str(error.value))
try:
self.table = self.interface(b'FnTable:IVROverlay_027')
self.handle = C.c_uint64()
rc = self.fn(0, C.c_int, C.c_char_p, C.POINTER(C.c_uint64))(KEY, C.byref(self.handle))
if rc:
raise RuntimeError('Frame Control overlay not found: ' + str(rc))
except Exception:
self.close()
raise
def interface(self, version):
error = C.c_int()
ptr = self.vr.VR_GetGenericInterface(version, C.byref(error))
if error.value or not ptr:
raise RuntimeError('OpenVR interface unavailable: ' + version.decode())
return C.cast(ptr, C.POINTER(C.c_void_p))
def fn(self, index, result, *args):
return C.CFUNCTYPE(result, *args)(self.table[index])
def snapshot(self):
h = self.handle
width, height = C.c_uint32(), C.c_uint32()
texture_error = self.fn(64, C.c_int, C.c_uint64, C.POINTER(C.c_uint32), C.POINTER(C.c_uint32))(h, C.byref(width), C.byref(height))
transform_type = C.c_int()
type_error = self.fn(31,C.c_int,C.c_uint64,C.POINTER(C.c_int))(h,C.byref(transform_type))
origin, matrix = C.c_int(), Matrix()
transform_error = self.fn(33, C.c_int, C.c_uint64, C.POINTER(C.c_int), C.POINTER(Matrix))(h, C.byref(origin), C.byref(matrix))
scale, method = Vector(), C.c_int()
scale_error = self.fn(49, C.c_int, C.c_uint64, C.POINTER(Vector))(h, C.byref(scale))
method_error = self.fn(47, C.c_int, C.c_uint64, C.POINTER(C.c_int))(h, C.byref(method))
return {'interface': 'IVROverlay_027',
'visible': bool(self.fn(43, C.c_bool, C.c_uint64)(h)),
'dashboardVisible': bool(self.fn(66, C.c_bool)()),
'activeDashboard': bool(self.fn(67, C.c_bool, C.c_uint64)(h)),
'texture': {'error': texture_error, 'width': width.value, 'height': height.value},
'transform': {'error': transform_error, 'typeError':type_error,'type':transform_type.value,'origin': origin.value,
'matrix': [list(row) for row in matrix.m]},
'input': {'error': method_error, 'method': method.value,
'scaleError': scale_error, 'scale': [scale.x, scale.y]}}
def post_mouse(self, kind, x, y, button=0):
# PostOverlayEvent is the supported overlay-view injection API. It is
# not a tracked controller and its void return does not prove delivery.
view = self.interface(b'FnTable:IVROverlayView_003')
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):
view = self.interface(b'FnTable:IVROverlayView_003')
event = Event(309, 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):
width, height = C.c_uint32(), C.c_uint32()
call = self.fn(6, C.c_int, C.c_uint64, C.c_void_p, C.c_uint32,
C.POINTER(C.c_uint32), C.POINTER(C.c_uint32))
rc = call(self.handle, None, 0, C.byref(width), C.byref(height))
size = width.value * height.value * 4
result = {'error': rc, 'width': width.value, 'height': height.value}
if 0 < size <= 32 * 1024 * 1024:
pixels = C.create_string_buffer(size)
rc = call(self.handle, pixels, size, C.byref(width), C.byref(height))
result['error'] = rc
if not rc:
result['sha256'] = hashlib.sha256(pixels.raw).hexdigest()
return result
def close(self):
self.vr.VR_ShutdownInternal()
+140
View File
@@ -0,0 +1,140 @@
"""Headset-side real input exercise, appended after framey_openvr.py by the runner."""
import json
from pathlib import Path
import sys
import time
import urllib.request
def exercise(profile, plugin_id, folder):
sys.path.insert(0, str(Path.home()/'.local/share/frame-control/headset/bundle/ui'))
import frame_steam
port = int((Path(profile)/'DevToolsActivePort').read_text().splitlines()[0])
frame_steam.CDP = f'http://127.0.0.1:{port}/json'
parent = frame_steam.Page('Frame Control VR [fc-headset]')
panel = None
vr = Overlay()
result = {'clickTransport':'IVROverlayView_003.PostOverlayEvent',
'textTransport':'CDP Input.insertText after OpenVR focus',
'controllerHardwareVerified':False}
def wait(read, accept, seconds=15):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
value = read()
if accept(value): return value
time.sleep(.25)
raise RuntimeError('Timed out waiting for live UI: ' + str(value))
def connect_panel():
def target():
targets = json.load(urllib.request.urlopen(frame_steam.CDP, timeout=5))
return next((p['title'] for p in targets if p.get('type') == 'iframe'
and '/framey/panel/' + str(plugin_id) in p.get('url','')), None)
page = frame_steam.Page(wait(target, bool))
page.eval('window.__vrClickCount=0;addEventListener("pointerup",e=>{window.__vrClickCount++;window.__vrLastTarget=e.target;});true')
return page
def command(page, method, params):
page.next_id += 1
page._send(json.dumps({'id':page.next_id,'method':method,'params':params}))
while True:
msg = json.loads(page._recv())
if msg.get('id') == page.next_id:
if 'error' in msg: raise RuntimeError(str(msg['error']))
return msg.get('result', {})
def text(): return panel.eval('document.body.innerText')
def click(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"}})')
time.sleep(.3) # let Chromium present the scrolled iframe before native input
pos = panel.eval(f'(()=>{{let r=document.querySelector({json.dumps(selector)}).getBoundingClientRect();return {{x:r.x+r.width/2,y:r.y+r.height/2}}}})()')
outer = parent.eval('(()=>{let f=byId("frameyFrame"),r=f.getBoundingClientRect();return {x:r.x+f.clientLeft,y:r.y+f.clientTop,w:innerWidth,h:innerHeight}})()')
sx, sy = vr.snapshot()['input']['scale']
x = (outer['x'] + pos['x']) * sx / outer['w']
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')
count = panel.eval('__vrClickCount')
vr.post_mouse(300, x, y)
vr.post_mouse(301, x, y, 1)
vr.post_mouse(302, x, y, 1)
wait(lambda:panel.eval('__vrClickCount'), lambda n:n > count, seconds=3)
hit = panel.eval(f'!!window.__vrLastTarget?.closest({json.dumps(selector)})')
if not hit:
raise RuntimeError('Native click hit another element: ' + str(panel.eval('({tag:__vrLastTarget?.tagName,text:__vrLastTarget?.textContent?.slice(0,100)})')))
try:
parent.eval(f'(async()=>{{await refreshDecky();openFrameyPanel({plugin_id},"Framey VR Check");return true}})()')
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
panel = connect_panel()
initial = wait(text, lambda s:'Saved setting: off' in s or 'Could not read setting:' in s)
if 'Could not read setting:' in initial: raise RuntimeError(initial)
result['initialText'] = initial
panel.eval('window.__vrEvents=[];addEventListener("pointerup",e=>__vrEvents.push({trusted:e.isTrusted,x:e.clientX,y:e.clientY}));true')
click('input[type=checkbox]')
result['savedText'] = wait(text, lambda s:'Saved setting: on' in s)
result['pointerEvents'] = panel.eval('__vrEvents')
result['trustedOverlayPointer'] = any(e['trusted'] for e in result['pointerEvents'])
disk = Path.home()/'.local/share/frame-control/decky/settings'/folder/'settings.json'
result['diskSetting'] = json.loads(disk.read_text())
# Fresh browser document, same normal parent API; no backend stub.
panel.sock.close(); panel = None
parent.eval(f'openFrameyPanel({plugin_id},"Framey VR Check")')
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
panel = connect_panel()
result['reopenedText'] = wait(text, lambda s:'Saved setting: on' in s)
result['reopenedChecked'] = panel.eval('document.querySelector("input[type=checkbox]").checked')
# Settings tab and quick action are real host controls too.
click('nav button:nth-child(2)')
wait(text, lambda s:'Saved setting: on' in s)
click('nav button:nth-child(1)')
wait(text, lambda s:'Saved setting: on' in s)
panel.eval('[...document.querySelectorAll("button")].find(b=>b.textContent==="Read saved setting").id="vr-quick"')
click('#vr-quick')
result['quickActionText'] = wait(text, lambda s:'Saved setting is on' in s)
# Isolated diagnostic controls use the actual shipped DFL shims. Their
# values are UI-only; they never change a user's settings or plugin.
panel.eval('''(()=>{
const mount=document.createElement('div');mount.id='vr-controls';document.body.prepend(mount);
function Controls(){const R=SP_REACT,[v,S]=R.useState(10),[t,T]=R.useState('');return R.createElement('section',null,
R.createElement(DFL.SliderField,{label:'VR test slider',min:0,max:100,value:v,showValue:true,onChange:S}),
R.createElement(DFL.TextField,{label:'VR test text',value:t,onChange:e=>T(e.target.value)}),
R.createElement('p',{id:'vr-control-values'},v+':'+t));}
SP_REACTDOM.createRoot(mount).render(SP_REACT.createElement(Controls));return true;
})()''')
wait(lambda:panel.eval('!!document.querySelector("#vr-controls input[type=range]")'), bool)
click('#vr-controls input[type=range]')
result['sliderValue'] = wait(lambda:panel.eval('document.querySelector("#vr-controls input[type=range]").value'), lambda v:v != '10')
click('#vr-controls input[type=text]')
# 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')
result['nativeScrollMoved'] = result['scrollAfter'] != result['scrollBefore']
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"})')
result['finalOverlay'] = vr.snapshot()
result['passed'] = (result['trustedOverlayPointer'] and result['diskSetting'] == {'enabled':True}
and result['reopenedChecked'] and result['sliderValue'] != '10'
and result['textValue'] == 'framey-ci')
return result
except Exception as exc:
result['error'] = str(exc)
result['passed'] = False
if panel:
result['failureText'] = text()
result['focus'] = panel.eval('({tag:document.activeElement?.tagName,type:document.activeElement?.type})')
return result
finally:
vr.close()
parent.sock.close()
if panel: panel.sock.close()
+66 -15
View File
@@ -10,6 +10,9 @@ import threading
import zipfile
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh
from framey_official import LEGACY_PLUGINS, PLUGINS as PANEL_PLUGINS, archive as official_archive
OFFICIAL_PLUGINS = PANEL_PLUGINS + LEGACY_PLUGINS
require() # Skip the module outside the disposable fake Frame, like its peers.
@@ -26,6 +29,10 @@ def archive(version):
ARTIFACTS = {hashlib.sha256(a).hexdigest(): a for a in (archive('1.0.0'), archive('1.1.0'))}
HASHES = list(ARTIFACTS)
for plugin in OFFICIAL_PLUGINS:
data = official_archive(plugin)
assert hashlib.sha256(data).hexdigest() == plugin['sha256']
ARTIFACTS[plugin['sha256']] = data
class StoreFixture(BaseHTTPRequestHandler):
@@ -35,10 +42,15 @@ class StoreFixture(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/plugins':
body = json.dumps([{'id':123,'name':'Sample Panel','author':'Fixture',
records = [{'id':999,'name':'Sample Panel','author':'Fixture',
'description':'A harmless panel','tags':[],
'versions':[{'name':['1.0.0','1.1.0'][self.release],
'hash':HASHES[self.release]}]}]).encode()
'hash':HASHES[self.release]}]}]
records.extend({'id':plugin['id'], 'name':plugin['name'], 'author':'Official Decky store',
'description':'Pinned official archive', 'tags':[],
'versions':[{'name':plugin['version'], 'hash':plugin['sha256']}]}
for plugin in OFFICIAL_PLUGINS)
body = json.dumps(records).encode()
content_type = 'application/json'
elif self.path.startswith('/versions/') and self.path.endswith('.zip'):
body = ARTIFACTS.get(self.path.split('/')[-1][:-4])
@@ -93,41 +105,80 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertEqual(status, 403)
catalogue = ok('GET', '/api/decky/store')['plugins']
self.assertEqual((catalogue[0]['version'], catalogue[0]['sha256']), ('1.0.0', HASHES[0]))
status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':123})
status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':999})
self.assertEqual(status, 400) # Third-party code needs explicit acknowledgement.
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':123,'acknowledged':True}))
{'action':'install','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
status, _, _ = api('POST', '/api/framey/method', {'id':123,'method':'get_setting','args':[]},
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'get_setting','args':[]},
headers={'X-Frame-UI':''})
self.assertEqual(status, 403)
status, _, _ = api('POST', '/api/framey/method', {'id':123,'method':'x;alert(1)','args':[]})
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'x;alert(1)','args':[]})
self.assertEqual(status, 400)
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
self.assertTrue(ok('POST', '/api/framey/method',
{'id':123,'method':'set_setting','args':['theme','light']})['result'])
{'id':999,'method':'set_setting','args':['theme','light']})['result'])
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
self.assertEqual(ok('POST', '/api/framey/method',
{'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}],
'legacy':True})['result'], {'success':True,'result':'light'})
self.assertEqual(ok('POST', '/api/framey/method',
{'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}],
'legacy':True})['result'], {'success':True,'result':True})
self.assertEqual(ok('POST', '/api/framey/method',
{'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved')
StoreFixture.release = 1
job = finished(ok('POST', '/api/decky/plugin',
{'action':'update','id':123,'acknowledged':True}))
{'action':'update','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':123,'acknowledged':True}))
{'action':'remove','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'], [])
self.assertEqual(ssh('test -e ~/.local/share/frame-control/decky/plugins/sample-panel && echo yes || echo no').strip(), 'no')
status, _, _ = api('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})
{'id':999,'method':'get_setting','args':['theme','dark']})
self.assertEqual(status, 400) # No method calls while the plugin is removed.
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':123,'acknowledged':True}))
{'action':'install','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
stale = finished(ok('POST', '/api/decky/plugin',
{'action':'update','id':999,'sha256':HASHES[0],
'acknowledged':True}))
self.assertIn('version changed', stale.get('error', ''))
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
def test_pinned_official_plugins_install_and_remove(self):
self.backend()
catalog = {plugin['id']:plugin for plugin in ok('GET', '/api/decky/store')['plugins']}
for plugin in OFFICIAL_PLUGINS:
with self.subTest(plugin=plugin['name']):
self.assertEqual(catalog[plugin['id']]['sha256'], plugin['sha256'])
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':plugin['id'],'acknowledged':True}))
self.assertFalse(job.get('error'), job)
found = [item for item in ok('GET', '/api/decky')['plugins']
if item['storeId'] == plugin['id']]
self.assertEqual(len(found), 1)
self.assertEqual(found[0]['version'], plugin['version'])
installed = f"~/.local/share/frame-control/decky/plugins/{plugin['folder']}"
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
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'])
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':plugin['id'],'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertFalse(any(item['storeId'] == plugin['id']
for item in ok('GET', '/api/decky')['plugins']))
+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')
@@ -28,10 +28,20 @@ function build(steam) {
// Synthetic Decky API for Framey's CDP bridge test. This does not model the
// real loader or prove frontend visibility on a headset.
const deckyBackend = {async call(route, plugin, method, ...args) {
if (route !== 'loader/call_plugin_method' || plugin !== 'Sample Panel')
if (!['loader/call_plugin_method','loader/call_legacy_plugin_method'].includes(route) || plugin !== 'Sample Panel')
throw Error('unexpected Decky plugin route');
const settings = (steam.decky_settings ||= {});
const owned = (settings[plugin] ||= {});
if (route === 'loader/call_legacy_plugin_method') {
const kwargs = args[0];
if (method === 'get_setting')
return {success:true, result:Object.hasOwn(owned, kwargs.key) ? owned[kwargs.key] : kwargs.default};
if (method === 'set_setting') {
owned[kwargs.key] = kwargs.value;
return {success:true, result:true};
}
return {success:false, result:'method unavailable in fake Decky backend'};
}
if (method === 'get_setting')
return Object.hasOwn(owned, args[0]) ? owned[args[0]] : args[1];
if (method === 'set_setting') {
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
# Pinned Decky store test archives
These are unmodified ZIPs downloaded from the official Decky store CDN on
2026-10-25. Each ZIP includes its upstream `LICENSE`. They are test data, not
plugins shipped or installed by Frame Control. The fake Frame tests serve them
from a local fixture so CI does not depend on the store being online.
| File | Official plugin | Version | Store SHA-256 |
| --- | --- | --- | --- |
| `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` |
The matching source projects are
[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).
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
actions, and start HLTB's frontend and shallow settings view with a stub Steam
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).
+30
View File
@@ -0,0 +1,30 @@
"""Pinned official Decky store archives used only in disposable test fixtures."""
from pathlib import Path
ROOT = Path(__file__).parent / 'fixtures/framey-official'
PLUGINS = (
{'id': 12, 'name': 'MusicControl', 'version': '1.1.6', 'api_version': 'legacy', 'folder': 'MusicControl', 'sha256': '246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62'},
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2,
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'},
{'id': 50, 'name': 'Storage Cleaner', 'version': '1.4.0', 'api_version': 1,
'folder': 'decky-storage-cleaner', 'sha256': '1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591'},
{'id': 18, 'name': 'Bluetooth', 'version': '3.1.1', 'api_version': 1,
'folder': 'Bluetooth', 'sha256': 'f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5'},
{'id': 26, 'name': 'HLTB for Deck', 'version': '2.0.12', 'api_version': 1,
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
)
# This archive has a legacy IIFE frontend with no manifest API version.
LEGACY_PLUGINS = (
{'id': 7, 'name': 'CSS Loader', 'version': '2.1.3', 'api_version': 'legacy',
'folder': 'SDH-CssLoader', 'sha256': '99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231'},
)
def archive(plugin):
return (ROOT / f"{plugin['id']}.zip").read_bytes()
+146 -6
View File
@@ -3,7 +3,7 @@ import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface;\n})();');
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI;\n})();');
assert.notEqual(testSource,source);
const states=[];
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
@@ -12,7 +12,11 @@ class Element {constructor(){this.children=[];this.textContent='';this.attribute
append(...children){this.children.push(...children);}setAttribute(key,value){this.attributes[key]=value;}
remove(){this.removed=true;}}
const elements=new Map();
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
const roots=new Map();
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 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()},
@@ -28,7 +32,48 @@ assert.deepEqual(jsx.children,['Saved']);
const fragments=window.SP_JSX.jsxs(window.SP_JSX.Fragment,{children:['one','two']});
assert.equal(fragments.type,React.Fragment);
assert.deepEqual(fragments.children,[['one','two']]);
const nodes=(tree,type)=>[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))];
const nodes=(tree,type)=>Array.isArray(tree)?tree.flatMap(child=>nodes(child,type)):
[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))];
// Disabled controls stay inert even if a caller invokes their handlers directly.
let changed=0;
const toggle=nodes(window.DFL.ToggleField({label:'Enabled',checked:true,disabled:true,
onChange:()=>changed++}),'input')[0];
const options=[{label:'Modes',options:[{label:'Slow',data:'slow'},{label:'Fast',data:'fast'}]}];
const dropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast',disabled:true,
onChange:()=>changed++}),'select')[0];
const button=nodes(window.DFL.ButtonItem({label:'Reload',description:'Read setting',disabled:true,
onClick:()=>changed++}),'button')[0];
const input=nodes(window.DFL.TextField({label:'Alias',value:'Old',placeholder:'New',disabled:true,
onChange:()=>changed++}),'input')[0];
const slider=nodes(window.DFL.SliderField({label:'Speed',value:2,min:0,max:10,step:2,disabled:true,
onChange:()=>changed++}),'input')[0];
const checkbox=nodes(window.DFL.DialogCheckbox({label:'Game',disabled:true,
onChange:()=>changed++}),'input')[0];
for(const control of [toggle,dropdown,button,input,slider,checkbox])assert.equal(control.props.disabled,true);
assert.equal(input.props.value,'Old');
assert.equal(slider.props.type,'range');
assert.equal(dropdown.props.value,'1');
assert.equal(nodes(dropdown,'optgroup').length,1);
toggle.props.onChange({target:{checked:false}});
dropdown.props.onChange({target:{value:'0'}});
button.props.onClick({});
input.props.onChange({target:{value:'New'}});
slider.props.onChange({target:{value:'4'}});
checkbox.props.onChange({target:{checked:true}});
assert.equal(changed,0);
let entered='';
const readyInput=nodes(window.DFL.TextField({label:'Alias',value:'Old',onChange:e=>{entered=e.target.value;}}),'input')[0];
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);
let selected;
const readyDropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast',
onChange:option=>{selected=option;}}),'select')[0];
readyDropdown.props.onChange({target:{value:'0'}});assert.equal(selected.data,'slow');
const readyButton=nodes(window.DFL.ButtonItem({label:'Reload',onClick:()=>changed++}),'button')[0];
assert.equal(readyButton.props.disabled,false);
readyButton.props.onClick({});assert.equal(changed,1);
let called=0;
const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{type:'title'},
frameyQuickActions:[{label:'Refresh data',onClick:async()=>{called++;return 'Fresh';}},
@@ -47,9 +92,10 @@ assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav'
// API v2 backend calls remain request-scoped and errors return to the caller.
const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test');
const reply=api.call('get_setting','mode','default');
assert.equal(messages[0].method,'get_setting');
assert.deepEqual(Array.from(messages[0].args),['mode','default']);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:messages[0].id,result:'dark'}});
const call=messages.find(message=>message.type==='framey:call');
assert.equal(call.method,'get_setting');
assert.deepEqual(Array.from(call.args),['mode','default']);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:call.id,result:'dark'}});
assert.equal(await reply,'dark');
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
@@ -59,10 +105,104 @@ assert.equal(toastNode.attributes.role,'status');
assert.equal(toastNode.children[0].textContent,'Saved');
assert.equal(toastNode.children[1].textContent,'Mode updated');
toast.dismiss();assert.equal(toastNode.removed,true);
// Decky's toast fields are ReactNode values. Keep JSX in the panel instead of
// showing "[object Object]", and release their React roots on dismissal.
const heading=React.createElement('em',null,'Saved');
const detail=React.createElement('span',null,'Mode updated');
const icon=React.createElement('svg',null);
const rich=api.toaster.toast({title:heading,body:[detail,' again'],icon,
subtext:'Open settings',showToast:true});
const richNode=elements.get('framey-toasts').children[1];
assert.equal(roots.get(richNode.children[0]).value,icon);
assert.equal(roots.get(richNode.children[1]).value,heading);
assert.deepEqual(Array.from(roots.get(richNode.children[2]).value),[detail,' again']);
assert.equal(richNode.children[3].textContent,'Open settings');
rich.dismiss();rich.dismiss();
assert.equal(richNode.removed,true);
for(const node of richNode.children.slice(0,3))assert.equal(roots.get(node).unmounted,true);
let opened=0;
const actionable=api.toaster.toast({title:'Open',onClick:()=>opened++});
const actionNode=elements.get('framey-toasts').children[2];
actionNode.children.at(-1).onclick();
assert.equal(opened,1);
assert.equal(actionNode.removed,true);
actionable.dismiss();
const hidden=api.toaster.toast({title:heading,showToast:false});
assert.equal(elements.get('framey-toasts').children.length,3);
hidden.dismiss();
function LocalRoute(){return React.createElement('p',null,'Route content');}
const undo=api.routerHook.addRoute('/plugin/details',LocalRoute);
assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),true);
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
assert.equal(window.DFL.Navigation.Navigate('/decky/store'),false);
assert.equal(window.DFL.findModuleChild(()=>true),null);
window.DFL.Navigation.CloseSideMenus();
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0);
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
// Legacy backend methods preserve Decky keyword arguments and success envelopes.
const legacyReply=window.__legacy.callPluginMethod('get_setting',{key:'theme'});
const legacyCall=messages.filter(message=>message.type==='framey:call').at(-1);
assert.equal(legacyCall.legacy,true);
assert.deepEqual(JSON.parse(JSON.stringify(legacyCall.args)),[{key:'theme'}]);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:legacyCall.id,
result:{success:true,result:'dark'}}});
assert.equal((await legacyReply).result,'dark');
assert.equal((await window.__legacy.callPluginMethod('get_setting',[])).success,false);
const failedLegacy=window.__legacy.callPluginMethod('get_setting',{});
const failedCall=messages.filter(message=>message.type==='framey:call').at(-1);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:failedCall.id,error:'Unavailable'}});
assert.equal((await failedLegacy).success,false);
// Local UI controls work without Steam components or fabricated hardware data.
const tabChanges=[];
const tabs=window.DFL.Tabs({tabs:[{id:'one',title:'One',content:'First'},
{id:'two',title:'Two',content:'Second'},{id:'locked',title:'Locked',disabled:true}],
activeTab:'two',onShowTab:id=>tabChanges.push(id)});
assert.equal(nodes(tabs,'button')[1].props['aria-selected'],true);
assert.deepEqual(nodes(tabs,'div').at(-1).children,['Second']);
nodes(tabs,'button')[0].props.onClick();
nodes(tabs,'button')[2].props.onClick();
assert.deepEqual(tabChanges,['one']);
assert.equal(window.DFL.Spinner({}).props.role,'status');
assert.equal(window.DFL.ProgressBar({nProgress:120}).props.value,100);
assert.equal(window.DFL.joinClassNames('one',null,'two'),'one two');
let eventCount=0;
const unsubscribe=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(()=>eventCount++);
assert.equal(typeof unsubscribe,'function');
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.indexedDB,undefined); // localForage must use bounded plugin storage.
const unpatch=window.DFL.beforePatch({},'method',()=>{});
assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch();
assert.equal(typeof window.DFL.showModal({type:'p'}).Close,'function');
let selectedMenuItem=0;
window.DFL.MenuItem({onSelected:()=>selectedMenuItem++}).props.onClick({});
window.DFL.MenuItem({disabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({});
window.DFL.MenuItem({bDisabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({});
window.DFL.MenuItem({onClick:()=>selectedMenuItem++,onSelected:()=>selectedMenuItem++}).props.onClick({});
assert.equal(selectedMenuItem,2);
for (const [locale,expected] of [['de-DE','german'],['zh-CN','schinese'],['zh-TW','tchinese'],
['pt-BR','brazilian'],['en-US','english'],['xx-ZZ','english']]) {
context.navigator.language=locale;
assert.equal(await window.SteamClient.Settings.GetCurrentLanguage(),expected);
}
function GameRoute(){return React.createElement('p',null,window.DFL.useParams().appid);}
const removeGame=api.routerHook.addRoute('/plugin/game/:appid/:view?',GameRoute);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
assert.equal(window.DFL.useParams().appid,'123');
assert.equal(window.DFL.useParams().view,undefined);
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,1);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true);
assert.equal(window.DFL.useParams().view,'art');
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art/extra'),false);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123?admin=1'),false);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game//art'),false);
assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute));
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);
+43
View File
@@ -0,0 +1,43 @@
// Start the unmodified official Bluetooth frontend and build its initial UI.
// Effects and click handlers are inert: no Bluetooth/backend operation runs.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const bundle=readFileSync(process.argv[2],'utf8');
const official=bundle.replace(/export \{ index as default \};\s*\/\/# sourceMappingURL=index.js.map\s*$/,
'window.__official = index;');
assert.notEqual(official,bundle);
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const host=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
assert.notEqual(host,source);
const react=new Proxy({Fragment:'fragment',Component:class {},
createElement:(type,props,...children)=>({type,props,children}),
createContext:value=>({Provider:()=>{},Consumer:()=>{},_currentValue:value}),
useState:value=>[value,()=>{}],useReducer:(_,value)=>[value,()=>{}],
useEffect(){},useRef:value=>({current:value}),useMemo:fn=>fn(),
useCallback:fn=>fn,forwardRef:fn=>fn,memo:fn=>fn},
{get:(target,key)=>target[key]??(()=>{})});
const window={React:react,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
const elements=new Map();
const messages=[];
const context=vm.createContext({window,navigator:{language:'en'},parent:{postMessage:message=>messages.push(message)},
document:{getElementById:id=>{if(!elements.has(id))elements.set(id,{});return elements.get(id);}},
addEventListener(){},setTimeout:()=>1,clearTimeout(){},console,URL,Blob});
vm.runInContext(host,context);
context.DFL=window.DFL;
context.SP_REACT=window.SP_REACT;
context.SP_JSX=window.SP_JSX;
context.LocalizationManager=window.LocalizationManager;
assert.equal(window.LocalizationManager.m_mapTokens.size,0);
assert.equal(window.LocalizationManager.m_mapFallbackTokens.get('Settings_Bluetooth_Devices'),'Devices');
vm.runInContext(official,context,{timeout:2000});
const plugin=window.__official();
assert.equal(plugin.content?.type?.name,'Content');
const panel=plugin.content.type();
assert.equal(panel.type,'div');
const text=JSON.stringify(panel);
assert.match(text,/Bluetooth/);
assert.match(text,/Devices/); // Readable fallback when Steam tokens are absent.
assert.doesNotMatch(text,/Settings_Bluetooth_Devices/);
assert.equal(messages.filter(message=>message.type==='framey:call').length,0);
+69
View File
@@ -0,0 +1,69 @@
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 parentSource=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
const hostSource=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8')
.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
assert.match(hostSource,/framey:cache:init/);
const values=new Map();
const localStorage={getItem:key=>values.get(key)??null,setItem:(key,value)=>values.set(key,value)};
const elements=new Map();
const el=id=>{if(!elements.has(id))elements.set(id,{id,hidden:false,removeAttribute(){},scrollIntoView(){}});
return elements.get(id);};
const parentListeners=[];
const parent=vm.createContext({document:{getElementById:el,createElement:()=>({})},
location:{hash:'',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem:()=>null,setItem(){}},localStorage,
window:{addEventListener:(type,fn)=>parentListeners.push({type,fn})},
setTimeout(){},setInterval(){},fetch(){throw Error('No network expected');},console});
vm.runInContext(parentSource,parent);
vm.runInContext('deckyState={plugins:[{storeId:14}]};openFrameyPanel(14,"Steam Status")',parent);
let session=vm.runInContext('frameySession',parent);
const firstURL=new URL(el('frameyFrame').src,'http://127.0.0.1');
const onParent=parentListeners.find(x=>x.type==='message').fn;
function iframe(){
const listeners=[];const queued=[];let live=false;
const frameWindow={location:{hash:`#${session}`},React:{Fragment:'fragment',Component:class {},createElement:()=>({}),isValidElement:()=>true},
ReactDOM:{createRoot:()=>({render(){}})}};
const proxy={postMessage:message=>listeners.find(x=>x.type==='message')?.fn({source:hostParent,data:message})};
el('frameyFrame').contentWindow=proxy;
const hostParent={postMessage:message=>live?onParent({source:proxy,data:message}):queued.push(message)};
const context=vm.createContext({window:frameWindow,document:{getElementById:()=>({})},
navigator:{language:'en'},parent:hostParent,DOMException,
addEventListener:(type,fn)=>listeners.push({type,fn}),setTimeout(){},clearTimeout(){}});
vm.runInContext(hostSource,context);
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};
}
let first=iframe();
assert.equal(first.storage.getItem('status-cache'),null);
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');
const oldSession=session;
vm.runInContext('openFrameyPanel(14,"Steam Status")',parent);
session=vm.runInContext('frameySession',parent);
assert.notEqual(session,oldSession);
const reopenedURL=new URL(el('frameyFrame').src,'http://127.0.0.1');
// Fragment-only navigation preserves the old document, host and cache session.
assert.notEqual(reopenedURL.pathname+reopenedURL.search,firstURL.pathname+firstURL.search);
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.key(0),'status-cache');
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.
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}"}');
reopened.send({type:'framey:cache:save',session:oldSession,entries:{poison:'yes'}});
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
reopened.storage.clear();
assert.equal(values.get('framey-plugin-cache-v1:14'),'{}');
+57
View File
@@ -0,0 +1,57 @@
// Execute the pinned official HLTB frontend's entry point with Framey's API.
// This checks module startup only; it never makes network or backend calls.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {inflateRawSync} from 'node:zlib';
import vm from 'node:vm';
const host=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const hostTest=host.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
assert.notEqual(hostTest,host);
const zip=readFileSync(new URL('../fixtures/framey-official/26.zip',import.meta.url));
const eocd=zip.lastIndexOf(Buffer.from([0x50,0x4b,0x05,0x06]));
assert.ok(eocd>0);
let offset=zip.readUInt32LE(eocd+16),bundle;
for(let i=0;i<zip.readUInt16LE(eocd+10);i++){
assert.equal(zip.readUInt32LE(offset),0x02014b50);
const nameLength=zip.readUInt16LE(offset+28),extraLength=zip.readUInt16LE(offset+30);
const commentLength=zip.readUInt16LE(offset+32);
const name=zip.subarray(offset+46,offset+46+nameLength).toString();
if(name==='hltb-for-deck/dist/index.js'){
const local=zip.readUInt32LE(offset+42);
assert.equal(zip.readUInt32LE(local),0x04034b50);
const start=local+30+zip.readUInt16LE(local+26)+zip.readUInt16LE(local+28);
const compressed=zip.subarray(start,start+zip.readUInt32LE(offset+20));
bundle=(zip.readUInt16LE(offset+10)===8?inflateRawSync(compressed):compressed).toString();
}
offset+=46+nameLength+extraLength+commentLength;
}
assert.ok(bundle);
const official=bundle.replace(/export \{ index as default \};\s*\/\/# sourceMappingURL=index.js.map\s*$/,
'window.__official = index;');
assert.notEqual(official,bundle);
const react=new Proxy({Fragment:'fragment',Component:class {},
createElement:(type,props,...children)=>({type,props,children}),
createContext:value=>({Provider:()=>{},Consumer:()=>{},_currentValue:value}),
forwardRef:fn=>fn,memo:fn=>fn,useState:value=>[value,()=>{}],
useContext:ctx=>ctx?._currentValue,useMemo:fn=>fn(),useCallback:fn=>fn,
useRef:value=>({current:value}),useEffect(){}},
{get:(target,key)=>target[key]??(()=>{})});
const window={React:react,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
const elements=new Map();
const context=vm.createContext({window,navigator:{language:'en'},parent:{postMessage(){}},
document:{getElementById:id=>{if(!elements.has(id))elements.set(id,{});return elements.get(id);}},
addEventListener(){},setTimeout:()=>1,clearTimeout(){},console,URL,Blob});
vm.runInContext(hostTest,context);
context.DFL=window.DFL;
context.SP_REACT=window.SP_REACT;
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(plugin.content?.type?.name,'QuickAccessView');
const panel=plugin.content.type();
assert.ok(panel?.type); // Read-only settings render with empty local data.
assert.equal(typeof plugin.onDismount,'function');
plugin.onDismount();
+20 -1
View File
@@ -10,8 +10,16 @@ const React={Fragment:Symbol('fragment'),Component:class {},
createElement:(type,props,...children)=>({type,props:props||{},children}),
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
const listeners=[];
const calls=[];
const parent={postMessage(message){
if(message.type!=='framey:call')return;
calls.push(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:()=>({})},
navigator:{language:'en'},parent:{postMessage(){}},addEventListener(){},
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(host,context);
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
@@ -23,3 +31,14 @@ const content=plugin.content.type();
assert.equal(content.type,window.DFL.PanelSection);
assert.equal(content.props.title,'Framey settings sample');
assert.equal(content.children[0][0].type,window.DFL.PanelSectionRow);
assert.equal(plugin.settings.type,plugin.content.type);
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]);
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);
plugin.onDismount();
assert.equal(window.DFL.useParams().topic,undefined);
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),false);
+44 -7
View File
@@ -48,39 +48,62 @@ el('deckySearch').value='no matching plugin'; el('deckySearch').oninput();
assert.equal(el('deckyStoreList').textContent,'No plugins match this search.');
// The page polls the actual job endpoint and keeps a separate visible result.
ctx.mockApi=async path => {
if(path==='/api/decky/plugin')return {job:'job-1'};
ctx.mockApi=async (path,body) => {
if(path==='/api/decky/plugin'){assert.equal(body.sha256,'a'.repeat(64));return {job:'job-1'};}
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
if(path==='/api/decky')return {running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2"})',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2');
assert.equal(el('deckyJobProgress').value,100);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
// A stopped Decky service must still offer recovery by removing its owned plugin.
ctx.mockApi=async path => path==='/api/decky' ?
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
Promise.reject(Error(`unexpected ${path}`));
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('deckyInstalled').children[0].children[2].disabled,false);
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);
ctx.mockApi=async path => {
if(path==='/api/decky/plugin')return {job:'job-2'};
ctx.mockApi=async (path,body) => {
if(path==='/api/decky/plugin'){assert.equal(body.sha256,'b'.repeat(64));return {job:'job-2'};}
if(path.startsWith('/api/job'))return {done:true,error:'archive digest mismatch'};
if(path==='/api/decky')return {running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2"})',ctx);
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"b".repeat(64)})',ctx);
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
assert.equal(session.has('frameyPluginJob'),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');};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('loadDeckyStore()',ctx);
assert.match(el('deckyStoreSummary').textContent,/Store unavailable: offline/);
assert.equal(el('deckyStoreList').children.length,0);
let finishOld, finishNew;
ctx.mockApi=path => {assert.equal(path,'/api/decky/store');
return new Promise(resolve=>{if(!finishOld)finishOld=resolve;else finishNew=resolve;});};
vm.runInContext('api=mockApi',ctx);
const old=vm.runInContext('loadDeckyStore()',ctx);
const current=vm.runInContext('loadDeckyStore()',ctx);
assert.equal(el('deckyStoreList').children.length,0);
assert.equal(el('deckyStoreSummary').textContent,'Loading official Decky store…');
finishNew({plugins:[{id:98,name:'Current',version:'2.0',description:'Latest'}]});
await current;
finishOld({plugins:[{id:97,name:'Stale',version:'1.0',description:'Older'}]});
await old;
assert.match(el('deckyStoreList').children[0].children[0].textContent,/Current/);
assert.doesNotMatch(el('deckyStoreList').children[0].children[0].textContent,/Stale/);
// A saved job does not survive a server restart. Clear it instead of offering
// Check result forever for an ID that the new server cannot know.
session.set('frameyPluginJob',JSON.stringify({job:'old-job',action:'install',name:'Plugin 2'}));
@@ -94,3 +117,17 @@ 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);
// 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/);
+47
View File
@@ -11,6 +11,7 @@ import sys
import tempfile
import unittest
from unittest.mock import patch
import warnings
import zipfile
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
@@ -40,6 +41,15 @@ class ArchiveTests(unittest.TestCase):
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([(p['id'], p['author']) for p in frame_decky_store.catalog()], [(42, 'Panel Author')])
def test_catalog_marks_underscore_root_and_omits_ambiguous_store_ids(self):
records = [{'id': 42, 'name': 'Needs root', 'tags': ['_root'],
'versions': [{'name': '1.0', 'hash': 'a' * 64}]},
{'id': 43, 'name': 'First', 'versions': [{'name': '1.0', 'hash': 'b' * 64}]},
{'id': 43, 'name': 'Second', 'versions': [{'name': '2.0', 'hash': 'c' * 64}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen',
return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([(p['id'], p['root']) for p in frame_decky_store.catalog()], [(42, True)])
def test_accepts_hashed_non_root_archive(self):
data, digest = archive()
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
@@ -54,6 +64,22 @@ class ArchiveTests(unittest.TestCase):
with self.assertRaises(ValueError):
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
def test_rejects_zip_path_collisions_and_non_object_manifest(self):
for name in ('Example/dist/index.js', 'Example/./dist/index.js',
'Example//dist/index.js'):
with self.subTest(name=name), warnings.catch_warnings():
warnings.simplefilter('ignore', UserWarning)
data, digest = archive(extra=(name, 'replacement'))
with self.assertRaisesRegex(ValueError, 'Unsafe|duplicate'):
frame_decky_store.archive_info(data, digest, 'Example')
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w') as bundle:
bundle.writestr('Example/plugin.json', '[]')
bundle.writestr('Example/dist/index.js', ' ' * 1200)
data = stream.getvalue()
with self.assertRaisesRegex(ValueError, 'manifest'):
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux')
class StoreTransaction(unittest.TestCase):
@@ -146,8 +172,29 @@ class StoreTransaction(unittest.TestCase):
self.assertEqual(installed.returncode, 0, installed.stderr)
self.assertEqual(self.apply('remove').returncode, 0)
def test_remote_apply_rejects_colliding_zip_paths(self):
for name in ('Example/dist/index.js', 'Example/./dist/index.js'):
with self.subTest(name=name), warnings.catch_warnings():
warnings.simplefilter('ignore', UserWarning)
data, _ = archive(extra=(name, 'replacement'))
result = self.apply('install', data)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.folder.exists())
class StoreHost(unittest.TestCase):
def test_reviewed_archive_changed_before_install_does_not_download(self):
state = {'managed': True, 'running': True, 'plugins': []}
catalog = [{'id': 42, 'name': 'Example', 'root': False,
'sha256': 'b' * 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, 'version changed'):
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
'install', 42, reviewed_hash='a' * 64)
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'}]}
+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')
+83
View File
@@ -0,0 +1,83 @@
"""Pinned official Decky bundles remain valid Framey panel inputs."""
import sandbox # noqa: F401
import base64
import hashlib
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import zipfile
import io
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_decky_store
import framey_panel
from framey_official import LEGACY_PLUGINS, PLUGINS, archive
class OfficialBundles(unittest.TestCase):
def test_legacy_css_loader_store_archive_has_unversioned_frontend(self):
plugin = LEGACY_PLUGINS[0]
data = archive(plugin)
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']), plugin['folder'])
with tempfile.TemporaryDirectory() as root, zipfile.ZipFile(io.BytesIO(data)) as bundle:
manifest = json.loads(bundle.read(plugin['folder'] + '/plugin.json'))
self.assertNotIn('api_version', manifest)
frontend = bundle.read(plugin['folder'] + '/dist/index.js')
self.assertIn(b'deckyFrontendLib.definePlugin', frontend)
bundle.extractall(root)
folder = Path(root) / plugin['folder']
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
with patch.object(framey_panel, 'PLUGINS', Path(root)):
page = framey_panel.panel(plugin['id']).decode()
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
self.assertIsNotNone(encoded)
self.assertEqual(base64.b64decode(encoded.group(1)), frontend)
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
def test_bluetooth_initial_panel_uses_safe_token_fallback(self):
with zipfile.ZipFile(io.BytesIO(archive(next(p for p in PLUGINS if p['id'] == 18)))) as bundle:
with tempfile.TemporaryDirectory() as folder:
frontend = Path(folder) / 'index.js'
frontend.write_bytes(bundle.read('Bluetooth/dist/index.js'))
script = Path(__file__).parent / 'page/framey_bluetooth.mjs'
result = subprocess.run(['node', str(script), str(frontend)],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
def test_hltb_frontend_starts_with_framey_module_stub(self):
script = Path(__file__).parent / 'page/framey_hltb.mjs'
result = subprocess.run(['node', str(script)], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_pinned_store_archives_install_and_pack_into_framey_panel(self):
with tempfile.TemporaryDirectory() as root:
plugins = Path(root)
for plugin in PLUGINS:
with self.subTest(plugin=plugin['name']):
data = archive(plugin)
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']),
plugin['folder'])
with zipfile.ZipFile(io.BytesIO(data)) as z:
manifest = json.loads(z.read(plugin['folder'] + '/plugin.json'))
self.assertEqual(manifest.get('api_version', 'legacy'), plugin['api_version'])
z.extractall(plugins)
folder = plugins / plugin['folder']
(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)
self.assertIsNotNone(encoded)
self.assertEqual(base64.b64decode(encoded.group(1)), (folder / 'dist/index.js').read_bytes())
if __name__ == '__main__':
unittest.main()
+38 -2
View File
@@ -1,4 +1,4 @@
"""Framey hosts only store-owned API v2 frontends and scopes backend calls."""
"""Framey hosts store-owned modern Decky frontends and scopes backend calls."""
import sandbox # noqa: F401
import gzip
import http.client
@@ -20,6 +20,11 @@ import server
class Panel(unittest.TestCase):
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)
self.assertEqual(result.returncode, 0, result.stderr)
def test_vr_store_browsing_and_search(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_store.mjs')],
capture_output=True, text=True, timeout=10)
@@ -53,10 +58,41 @@ class Panel(unittest.TestCase):
with self.assertRaises(FileNotFoundError):
framey_panel.panel(15)
def test_legacy_root_symlink_and_unmanaged_are_refused(self):
def test_legacy_iife_is_admitted_without_api_manifest(self):
(self.folder/'plugin.json').write_text(json.dumps({'name':'Legacy', 'flags':[]}))
(self.folder/'dist/index.js').write_text(
'(function(DFL, React){return DFL.definePlugin(serverAPI=>({content:React.createElement("p",null,"Legacy")}));})(DFL, SP_REACT);')
self.assertIn('framey-bundle', framey_panel.panel(14).decode())
(self.folder/'dist/index.js').write_text('window.unrecognized = true;')
with self.assertRaises(FileNotFoundError):
framey_panel.panel(14)
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])
self.assertEqual(payload, {'name':'Legacy', 'method':'read_setting',
'args':[{'key':'mode'}], 'legacy':True})
self.assertIn('loader/call_legacy_plugin_method', stdin)
return json.dumps({'result':{'success':True, 'result':'dark'}})
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':14,'name':'Legacy'}]}):
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True),
{'result':{'success':True, 'result':'dark'}})
for args, legacy in [([],True), ([[]],True), ([{},{}],True), ([{}],'true')]:
with self.assertRaises(framey_panel.PanelError):
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy)
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
manifest = self.folder/'plugin.json'
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':0}))
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':1}))
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
(self.folder/'dist/index.js').write_text(
'// __DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit\n'
"export default () => ({content: SP_REACT.createElement('p', null, 'OK')});")
self.assertIn('framey-bundle', framey_panel.panel(14).decode())
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':['root']}))
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
+13 -1
View File
@@ -15,7 +15,7 @@ EXAMPLE = Path(__file__).resolve().parents[1] / 'examples/framey-settings/main.p
def backend(settings_dir):
decky = types.ModuleType('decky')
decky.decky_SETTINGS_DIR = settings_dir
decky.DECKY_PLUGIN_SETTINGS_DIR = settings_dir
spec = importlib.util.spec_from_file_location('framey_sample', EXAMPLE)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {'decky': decky}):
@@ -45,6 +45,18 @@ class SamplePlugin(unittest.TestCase):
with self.assertRaises(ValueError): asyncio.run(plugin.set_setting('enabled', 'yes'))
self.assertFalse((Path(folder) / 'settings.json').exists())
def test_corrupt_or_old_setting_uses_default_and_can_be_replaced(self):
with tempfile.TemporaryDirectory() as folder:
plugin = backend(folder)
path = Path(folder) / 'settings.json'
for data in ('broken JSON', '[]', '{"enabled": "yes"}'):
with self.subTest(data=data):
path.write_text(data)
self.assertFalse(asyncio.run(plugin.get_setting('enabled', False)))
self.assertTrue(asyncio.run(plugin.get_setting('enabled', True)))
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
if __name__ == '__main__':
unittest.main()
+74
View File
@@ -0,0 +1,74 @@
"""Safety gates for unattended device work; no headset or OpenVR loaded."""
import importlib.util
import contextlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
PATH = Path(__file__).resolve().parents[1]/'scripts/framey-vr-check.py'
spec = importlib.util.spec_from_file_location('framey_vr_check', PATH)
check = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check)
class Safety(unittest.TestCase):
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
remote.side_effect = RuntimeError('File exists')
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(check.main(['--exercise','--output',folder]), 1)
remote.python.assert_not_called()
self.assertEqual(remote.call_count, 1)
self.assertIn('mkdir /tmp/frame-test.lock', remote.call_args.args[0])
self.assertFalse(json.loads((Path(folder)/'result.json').read_text())['passed'])
def test_low_battery_never_wakes_or_installs_and_releases_lock(self):
state = {'battery':[19], 'pause':'power.pauseCompositorOnStandby=true',
'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False}
with 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(['--exercise','--output',folder]), 1)
install.assert_not_called()
self.assertEqual(remote.call_count, 2)
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args.args[0])
self.assertFalse(any('--handlewakeup' in c.args[0] for c in remote.call_args_list))
def test_power_restore_after_partial_setter_failure(self):
state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true',
'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False}
with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory:
remote = factory.return_value
remote.python.return_value = state
def run(cmd, **_):
if '--set-settings-float power.turnOffScreensTimeout 120' in cmd:
raise RuntimeError('setter failed')
return ''
remote.side_effect = run
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(check.main(['--wake','--output',folder]), 1)
commands = [c.args[0] for c in remote.call_args_list]
self.assertTrue(any('--set-settings-bool power.pauseCompositorOnStandby 1' in c for c in commands))
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_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)
original = check.frame_headset.bundle()
patched = check.debug_bundle(original, '/tmp/owned-profile')
import tarfile
with tarfile.open(fileobj=io.BytesIO(patched),mode='r:gz') as archive:
source=archive.extractfile('ui/frame_headset_runtime.py').read().decode()
self.assertIn("'--remote-debugging-port=0'", source)
self.assertIn("'/tmp/owned-profile'", source)
self.assertNotIn("str(BASE / 'profile')", source)
+7 -1
View File
@@ -20,9 +20,15 @@ class WornGate(unittest.TestCase):
with patch.object(worn, 'probe', return_value=state) as probe, \
patch.object(worn, 'remote', side_effect=remote_side_effect) as remote, \
contextlib.redirect_stdout(out):
result = worn.main()
result = worn.main(worn_confirmed=True)
return result, out.getvalue(), remote, probe
def test_no_wearer_confirmation_means_no_sensor_or_input_claim(self):
with patch.object(worn,'remote') as remote, contextlib.redirect_stdout(io.StringIO()) as out:
self.assertEqual(worn.main(),1)
remote.assert_not_called()
self.assertIn('trusted pointer events alone do not prove',out.getvalue())
def test_standby_is_read_only_under_owned_lock_and_releases_it(self):
state = {'battery':[{'percent':99}], 'activity':3, 'panelService':True,
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
+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():
+42 -10
View File
@@ -13,6 +13,7 @@ import urllib.request
import zipfile
import frame_decky
import framey_compatibility
STORE_URL = 'https://plugins.deckbrew.xyz/plugins'
@@ -59,8 +60,16 @@ def catalog():
'description': str(item.get('description') or '')[:300],
'author': author[:100],
'version': latest['name'][:40], 'sha256': latest['hash'],
'root': 'root' in tags})
return result
'root': 'root' in tags or '_root' in tags})
# A repeated store ID makes a clicked row ambiguous: change() resolves by ID
# and could install a different archive than the one the user reviewed.
counts = {}
for item in result:
counts[item['id']] = counts.get(item['id'], 0) + 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):
@@ -77,13 +86,20 @@ def archive_info(data, expected_hash, expected_name):
folder = manifests[0].split('/')[0]
if not SAFE_FOLDER.fullmatch(folder):
raise ValueError('Invalid plugin folder')
seen = set()
for entry in files:
name = entry.filename
mode = entry.external_attr >> 16
if ('\\' in name or name.startswith('/') or '..' in name.split('/') or
name.split('/')[0] != folder or entry.file_size > 30 * 1024 * 1024 or
mode & 0o170000 == 0o120000):
kind = mode & 0o170000
parts = name.split('/')
if entry.is_dir() and parts[-1] == '':
parts = parts[:-1]
if ('\\' in name or name.startswith('/') or
any(part in ('', '.', '..') for part in parts) or name in seen or
parts[0] != folder or entry.file_size > 30 * 1024 * 1024 or
kind not in (0, 0o040000, 0o100000)):
raise ValueError('Unsafe plugin archive member')
seen.add(name)
if entry.is_dir():
continue
with archive.open(entry) as source:
@@ -93,6 +109,8 @@ def archive_info(data, expected_hash, expected_name):
if header[:2] == b'MZ':
raise ValueError('Plugin contains a Windows binary: ' + name)
manifest = json.loads(archive.read(manifests[0]))
if not isinstance(manifest, dict):
raise ValueError('Invalid plugin manifest')
if manifest.get('name') != expected_name:
raise ValueError('Plugin name differs from the official store')
flags = manifest.get('flags', [])
@@ -142,10 +160,17 @@ try:
files=z.infolist()
if not files or len(files)>3000 or sum(f.file_size for f in files)>150*1024*1024:
raise RuntimeError('Plugin archive too large')
if any(f.filename.split('/')[0]!=folder or f.filename.startswith('/') or
'..' in f.filename.split('/') or '\\' in f.filename or
f.file_size>30*1024*1024 or f.external_attr>>16&0o170000==0o120000 for f in files):
raise RuntimeError('Unsafe plugin archive')
seen=set()
for f in files:
parts=f.filename.split('/')
if f.is_dir() and parts[-1]=='': parts=parts[:-1]
kind=f.external_attr>>16&0o170000
if (not parts or parts[0]!=folder or f.filename.startswith('/') or
any(part in ('','.','..') for part in parts) or '\\' in f.filename or
f.filename in seen or f.file_size>30*1024*1024 or
kind not in (0,0o040000,0o100000)):
raise RuntimeError('Unsafe plugin archive')
seen.add(f.filename)
stage.mkdir(mode=0o700)
z.extractall(stage)
unpacked=stage/folder
@@ -172,9 +197,12 @@ finally:
'''
def change(ssh, action, plugin_id, report=lambda stage, percent=None: None):
def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, reviewed_hash=None):
if action not in ('install', 'update', 'remove') or type(plugin_id) is not int or plugin_id <= 0:
raise ValueError('Choose a valid store plugin action')
if reviewed_hash is not None and (action == 'remove' or not isinstance(reviewed_hash, str)
or not SHA.fullmatch(reviewed_hash)):
raise ValueError('Invalid reviewed store version')
report('Checking installed plugins', 5)
state = frame_decky.status(ssh)
if not state['managed']:
@@ -194,8 +222,12 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None):
plugin = next((item for item in catalog() if item['id'] == plugin_id), None)
if not plugin:
raise ValueError('Plugin is not in the official Decky store')
if reviewed_hash is not None and plugin['sha256'] != reviewed_hash:
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')
if plugin.get('compatibility', {}).get('blockInstall'):
raise ValueError(plugin['compatibility']['reason'])
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')
File diff suppressed because it is too large. Load diff
+255 -32
View File
@@ -11,7 +11,14 @@
: R.createElement(type, elementProps);
};
window.SP_JSX = {jsx, jsxs:jsx, Fragment:R.Fragment};
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en']};
// 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'],
m_mapTokens:new Map(), m_mapFallbackTokens:new Map([
['Settings_Bluetooth_Connected','Connected'],
['Settings_Bluetooth_NotConnected','Not connected'],
['Settings_Bluetooth_Devices','Devices'],
])};
const root = document.getElementById('framey-root');
const error = document.getElementById('framey-error');
const toastRoot = document.getElementById('framey-toasts');
@@ -19,19 +26,64 @@
const routes = new Map();
const routeListeners = new Set();
let currentRoute = null;
let currentParams = {};
let nextId = 0;
let plugin;
// 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.
const cache = new Map();
const cacheLimit = 128 * 1024;
const cacheSession = window.location?.hash.slice(1) || '';
let acceptSeed;
const seeded = new Promise(resolve => {acceptSeed = resolve;});
const storage = {
get length() {return cache.size;},
key(index) {return [...cache.keys()][index] ?? null;},
getItem(key) {return cache.get(String(key)) ?? null;},
setItem(key, value) {
const name = String(key), data = String(value);
const next = new Map(cache); next.set(name, data);
if (next.size > 64 || name.length > 256 || data.length > 65536 ||
JSON.stringify(Object.fromEntries(next)).length > cacheLimit)
throw new DOMException('Framey plugin cache is full', 'QuotaExceededError');
cache.set(name, data); persistCache();
},
removeItem(key) {cache.delete(String(key)); persistCache();},
clear() {cache.clear(); persistCache();},
};
function persistCache() {
parent.postMessage({type:'framey:cache:save', session:cacheSession,
entries:Object.fromEntries(cache)}, '*');
}
Object.defineProperty(window, 'localStorage', {configurable:true, value:storage});
// localForage otherwise chooses the browser's exposed-but-denied IndexedDB
// driver in an opaque iframe. Let it detect the bounded localStorage driver.
Object.defineProperty(window, 'indexedDB', {configurable:true, value:undefined});
parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*');
setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable.
function ask(method, args) {
function ask(method, args, legacy=false) {
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}, '*');
parent.postMessage({type:'framey:call', id, method, args, legacy}, '*');
});
}
addEventListener('message', event => {
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
event.data.session === cacheSession && acceptSeed) {
const entries = event.data.entries;
if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
for (const [key, value] of Object.entries(entries))
if (key.length <= 256 && typeof value === 'string' && value.length <= 65536)
cache.set(key, value);
}
acceptSeed(); acceptSeed = null;
return;
}
if (event.source !== parent || !event.data || event.data.type !== 'framey:reply') return;
const item = pending.get(event.data.id);
if (!item) return;
@@ -46,65 +98,220 @@
const passthrough = tag => ({children, ...props}) => R.createElement(tag, props, children);
const Field = ({label, description, children}) => R.createElement('label', null,
R.createElement('strong', null, label), description && R.createElement('small', null, description), children);
const ToggleField = ({label, description, checked, onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:'checkbox', checked:!!checked, onChange:e=>onChange?.(e.target.checked)}));
const DropdownItem = ({label, description, rgOptions=[], selectedOption, onChange}) => R.createElement(Field, {label, description},
R.createElement('select', {value:String(selectedOption ?? ''), onChange:e=>onChange?.(rgOptions.find(x=>String(x.data)===e.target.value))},
rgOptions.map(x=>R.createElement('option', {key:String(x.data), value:String(x.data)}, x.label))));
const ButtonItem = ({label, children, onClick}) => R.createElement(Field, {label},
R.createElement('button', {type:'button', onClick}, children || label));
const ToggleField = ({label, description, checked, disabled, onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:'checkbox', checked:!!checked, disabled:!!disabled,
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}));
const DropdownItem = ({label, description, rgOptions=[], selectedOption, disabled, strDefaultLabel, onChange}) => {
const choices=[];
const option = entry => {
const index=choices.push(entry)-1;
return R.createElement('option', {key:index, value:String(index)}, entry.label);
};
const entries=(Array.isArray(rgOptions) ? rgOptions : []).map(entry =>
Array.isArray(entry.options) ? R.createElement('optgroup', {key:String(entry.label), label:String(entry.label)},
entry.options.map(option)) : option(entry));
const selected=choices.findIndex(entry=>Object.is(entry.data, selectedOption));
return R.createElement(Field, {label, description}, R.createElement('select', {
value:selected < 0 ? '' : String(selected), disabled:!!disabled,
onChange:e=>{const entry=choices[Number(e.target.value)];if(!disabled && entry)onChange?.(entry);}},
selected < 0 && R.createElement('option', {value:'', disabled:true}, strDefaultLabel || 'Choose an option'),
entries));
};
const ButtonItem = ({label, description, children, disabled, onClick}) => R.createElement(Field, {label, description},
R.createElement('button', {type:'button', disabled:!!disabled,
onClick:e=>{if(!disabled)onClick?.(e);}}, children || label));
const DialogCheckbox = ({label, checked, defaultChecked, disabled, onChange}) =>
R.createElement('label', null,
R.createElement('input', {type:'checkbox', checked, defaultChecked, disabled:!!disabled,
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}), label);
const TextField = ({label, description, value, placeholder, disabled, bIsPassword,
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,
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));
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…');
const Tabs = ({tabs=[], activeTab, onShowTab}) => {
const [selected, setSelected] = R.useState(tabs[0]?.id);
const current = tabs.find(tab=>tab.id === (activeTab ?? selected)) || tabs[0];
return R.createElement('div', null,
R.createElement('div', {role:'tablist'}, ...tabs.map(tab=>R.createElement('button', {
type:'button', key:tab.id, role:'tab', 'aria-selected':tab===current,
disabled:!!tab.disabled, onClick:()=>{if(!tab.disabled){setSelected(tab.id);onShowTab?.(tab.id);}}}, tab.title))),
R.createElement('div', {role:'tabpanel'}, current?.content));
};
const SidebarNavigation = ({pages=[], title}) => R.createElement('section', null,
title && R.createElement('h2', null, title), R.createElement(Tabs, {
tabs:pages.filter(page=>page.visible!==false).map((page,index)=>({
id:page.route || String(index), title:page.title, content:page.content}))}));
const ProgressBar = ({nProgress=0, value, max=100}) => R.createElement('progress', {
value:Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max});
const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) =>
R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled),
onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children);
const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null,
R.createElement('h2', null, strTitle), children,
R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close'));
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
const closeModal = () => modalRoot.render(null);
const localPath = path => typeof path === 'string' && path.length <= 120 &&
/^\/[A-Za-z0-9/_-]+$/.test(path) && !path.includes('//');
const routePattern = path => typeof path === 'string' && path.length <= 120 &&
/^\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??)(?:\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??))*$/.test(path) &&
!/\?\//.test(path) &&
new Set([...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].map(match => match[1])).size ===
[...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].length;
function matchRoute(pattern, path) {
const expected = pattern.slice(1).split('/');
const actual = path.slice(1).split('/');
if (actual.length > expected.length) return null;
const params = {};
for (let index = 0; index < expected.length; index++) {
const segment = expected[index], value = actual[index];
if (segment[0] === ':') {
const optional = segment.endsWith('?'), name = segment.slice(1, optional ? -1 : undefined);
if (value === undefined) { if (!optional) return null; }
else params[name] = value;
} else if (segment !== value) return null;
}
return params;
}
function activeRoute(path) {
if (routes.has(path)) return {component:routes.get(path), params:{}};
for (const [pattern, component] of routes) {
const params = matchRoute(pattern, path);
if (params) return {component, params};
}
return null;
}
function navigate(path) {
if (path !== null && (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || !routes.has(path))) return false;
if (path !== null && !localPath(path)) return false;
const match = path === null ? null : activeRoute(path);
if (path !== null && !match) return false;
currentRoute = path;
currentParams = match?.params || {};
for (const listener of routeListeners) listener(path);
return true;
}
const routerHook = {
addRoute(path, component) {
if (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) ||
if (!routePattern(path) ||
(typeof component !== 'function' && !R.isValidElement(component)))
throw Error('Framey route needs a local path and React component');
routes.set(path, component);
return () => {routes.delete(path); if(currentRoute === path) navigate(null);};
return () => routerHook.removeRoute(path);
},
removeRoute(path) {routes.delete(path); if(currentRoute === path) navigate(null);},
removeRoute(path) {routes.delete(path); if(currentRoute && !activeRoute(currentRoute)) navigate(null);},
// Steam routes and global components have no equivalent in the isolated panel.
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
};
const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;};
const showModal = element => {
modalRoot.render(R.createElement('section', null,
R.createElement('button', {type:'button',onClick:closeModal}, 'Close'), element));
return {Close:closeModal};
};
const DFL = window.DFL = {
definePlugin: init => init,
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, ButtonItem,
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem,
Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField,
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
DialogButton: passthrough('button'), ConfirmModal,
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
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'),
Menu:passthrough('div'), MenuGroup:passthrough('section'), MenuItem,
ProgressBar, ProgressBarWithInfo:ProgressBar,
gamepadDialogClasses:{}, quickAccessMenuClasses:{}, gamepadTabbedPageClasses:{},
gamepadSliderClasses:{}, gamepadContextMenuClasses:{}, scrollPanelClasses:{},
quickAccessControlsClasses:{}, mainMenuAppRunningClasses:{}, libraryAssetImageClasses:{},
joinClassNames:(...names)=>names.filter(Boolean).join(' '),
sleep:ms=>new Promise(resolve=>setTimeout(resolve,Math.max(0,Number(ms)||0))),
staticClasses: {Title:'framey-title'},
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null),
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), CloseSideMenus:()=>navigate(null),
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
showModal: element => modalRoot.render(R.createElement('section', null,
R.createElement('button', {onClick:closeModal}, 'Close'), element)),
showModal, showContextMenu:showModal,
// Steam app, library, and store patches have no target inside this panel.
afterPatch: () => () => {}, createReactTreePatcher: () => () => {},
findInReactTree: () => null, findModuleExport: () => null,
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,
appDetailsClasses: {}, appDetailsHeaderClasses: {},
useParams: () => ({}),
useParams: () => currentParams,
};
// 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;};
const register = names => Object.fromEntries(names.map(name=>[name,subscription]));
const steamLanguage = () => {
const locale = (navigator.language || 'en').toLowerCase();
if (/^zh-(tw|hk|mo|hant)/.test(locale)) return 'tchinese';
if (locale.startsWith('pt-br')) return 'brazilian';
if (locale.startsWith('es-419')) return 'latam';
return ({en:'english',de:'german',fr:'french',es:'spanish',it:'italian',ja:'japanese',
ko:'koreana',zh:'schinese',ru:'russian',pl:'polish',pt:'portuguese',nl:'dutch',
da:'danish',fi:'finnish',sv:'swedish',no:'norwegian',cs:'czech',hu:'hungarian',
ro:'romanian',th:'thai',tr:'turkish',vi:'vietnamese',uk:'ukrainian',id:'indonesian'
})[locale.split('-')[0]] || 'english';
};
window.SteamClient = {
Settings:{GetCurrentLanguage:async()=>steamLanguage(),
...register(['RegisterForSettingsChanges'])},
Apps:register(['RegisterForAppDetails','RegisterForGameActionTaskChange',
'RegisterForGameActionStart','RegisterForGameActionEnd']),
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
UI:register(['RegisterForFocusChangeEvents'])},
Input:register(['RegisterForControllerInputMessages','RegisterForControllerStateChanges']),
User:register(['RegisterForLoginStateChange','RegisterForPrepareForSystemSuspendProgress',
'RegisterForResumeSuspendedGamesProgress','RegisterForCurrentUserChanges']),
Overlay:register(['RegisterForOverlayActivated']),
UI:register(['RegisterForUIModeChanged']),
};
DFL.Router = {...DFL.Navigation, MainRunningApp:undefined};
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => {
const data = {title, body};
const toaster = {toast: ({title, body, subtext, icon, logo, duration, showToast=true, onClick}={}) => {
const data = {title, body, subtext, icon, logo};
if (!showToast) return {data, dismiss(){}};
const item = document.createElement('section');
item.setAttribute('role','status');
const heading = document.createElement('strong'); heading.textContent = String(title || 'Plugin notice');
const detail = document.createElement('p'); detail.textContent = String(body || '');
const roots = [];
const addContent = (tag, value) => {
if (value === null || value === undefined || value === false) return;
const node = document.createElement(tag);
if (R.isValidElement(value) || Array.isArray(value)) {
const root = window.ReactDOM.createRoot(node);
roots.push(root);
root.render(value);
} else if (typeof value === 'string' || typeof value === 'number') {
node.textContent = String(value);
} else return;
item.append(node);
};
addContent('span', logo);
addContent('span', icon);
addContent('strong', title || 'Plugin notice');
addContent('p', body);
addContent('small', subtext);
const close = document.createElement('button'); close.type='button'; close.textContent='Dismiss';
const dismiss = () => {item.remove(); clearTimeout(timer);};
close.onclick=dismiss; item.append(heading, detail, close);
let dismissed = false;
const dismiss = () => {
if (dismissed) return;
dismissed = true;
clearTimeout(timer);
for (const root of roots) root.unmount();
item.remove();
};
close.onclick=dismiss; item.append(close);
if (typeof onClick === 'function') {
const open = document.createElement('button'); open.type='button'; open.textContent='Open';
open.onclick=()=>{onClick(); dismiss();}; item.append(open);
@@ -150,8 +357,8 @@
...actions.map((action, index) => R.createElement('button', {key:index, type:'button',
disabled:running !== null, onClick:()=>runAction(action,index)}, action.label))),
message && R.createElement('p', {role:'status'}, message),
R.createElement('div', null, route ? (typeof routes.get(route) === 'function' ?
R.createElement(routes.get(route)) : routes.get(route)) :
R.createElement('div', null, route ? (typeof activeRoute(route)?.component === 'function' ?
R.createElement(activeRoute(route).component) : activeRoute(route)?.component) :
(view === 'settings' ? plugin.settings : plugin.content)));
}
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
@@ -169,14 +376,30 @@
useQuickAccessVisible: () => true,
}),
};
const legacyServerAPI = {
routerHook, toaster,
callPluginMethod: (method, kwargs={}) => {
if (!kwargs || typeof kwargs !== 'object' || Array.isArray(kwargs))
return Promise.resolve({success:false, result:'Legacy plugin arguments must be an object'});
// Decky's legacy route takes keyword arguments and returns this envelope.
return ask(method, [kwargs], true).catch(failure=>({success:false, result:String(failure.message || failure)}));
},
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
};
async function start() {
try {
await seeded;
const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], {type:'text/javascript'}));
// 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'}));
let module;
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();
plugin = await module.default(legacyServerAPI);
if (legacy && plugin?.title && !plugin.titleView) plugin.titleView = plugin.title;
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
R.createElement(PluginSurface, {plugin})));
+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
+19 -7
View File
@@ -17,7 +17,7 @@ class PanelError(ValueError):
def local_bundle(plugin_id):
"""Read only an API v2 bundle with a store receipt; reject linked paths."""
"""Read a modern Decky bundle with a store receipt; reject 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():
@@ -43,11 +43,19 @@ def local_bundle(plugin_id):
if not isinstance(data, dict):
raise FileNotFoundError('Framey plugin manifest unavailable')
flags = data.get('flags', [])
if data.get('api_version') != 2 or not isinstance(flags, list) or any(x in flags for x in ('root', '_root')):
raise FileNotFoundError('This Decky frontend is not API v2 or needs root')
version = data.get('api_version')
if ((version is not None and (type(version) is not int or version not in (1, 2))) or
not isinstance(flags, list) or any(x in flags for x in ('root', '_root'))):
raise FileNotFoundError('This Decky frontend has an unsupported API or needs root')
if not entry.is_file() or entry.stat().st_size > 8*1024*1024:
raise FileNotFoundError('Framey plugin frontend unavailable')
return entry.read_bytes()
bundle = entry.read_bytes()
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
except (OSError, ValueError):
raise FileNotFoundError('Framey plugin files are invalid')
raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store')
@@ -72,23 +80,27 @@ 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])
expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call('loader/call_plugin_method'," + json.dumps(payload['name']) + ',' + json.dumps(payload['method']) + ',' + ','.join(json.dumps(x) for x in payload['args']) + ')})()'
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]) + ')})()'
page = Page()
try: print(json.dumps({'result':page.eval(expression)}))
finally: page.sock.close()
'''
def call_method(ssh, plugin_id, method, args):
def call_method(ssh, plugin_id, method, args, legacy=False):
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 not METHOD.fullmatch(method):
raise PanelError('Invalid plugin method')
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args)) > 8192:
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')
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'], 'method':method, 'args':args}
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))
+52 -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,7 +104,9 @@ 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;}}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=null, deckyBusy=false;
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';}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
activeFrameyId=null, frameySession=null, deckyBusy=false;
async function refreshDecky(){try{deckyState=await api('/api/decky');
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.':
@@ -111,21 +114,26 @@ async function refreshDecky(){try{deckyState=await api('/api/decky');
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);}
list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.';
showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}}
async function loadDeckyStore(){byId('deckyStorePanel').hidden=false;
async function loadDeckyStore(){const request=++deckyStoreRequest;byId('deckyStorePanel').hidden=false;
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
byId('deckyStoreSummary').textContent='Loading official Decky store…';
try{const data=await api('/api/decky/store');deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
catch(error){byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
try{const data=await api('/api/decky/store');if(request!==deckyStoreRequest)return;
deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
catch(error){if(request!==deckyStoreRequest)return;
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
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`;
@@ -135,12 +143,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||!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;
@@ -169,7 +178,8 @@ async function followDeckyJob(job,action,name){
async function deckyChange(action,plugin){if(deckyBusy||!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,acknowledged:true});
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
if(!started.job)throw Error('The server did not return a job ID');
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
await followDeckyJob(started.job,action,plugin.name);
@@ -185,25 +195,51 @@ async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.get
finally{deckyBusy=false;await refreshDecky();}}
byId('deckyJobCheck').onclick=checkDeckyJob;
function openFrameyPanel(id,name){
if(!(deckyState?.plugins||[]).some(p=>p.storeId===id))return;
activeFrameyId=id;byId('frameyTitle').textContent=name;
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
byId('frameyCompatibility').textContent=compatibilityText(installed);
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
byId('frameyPanelStatus').textContent='Loading plugin panel…';
byId('frameyFrame').src=`/framey/panel/${id}`;
byId('frameyFrame').src=`/framey/panel/${id}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
byId('frameyPanelStatus').textContent='No compatible API v2 panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
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;};
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
function frameyCacheRead(id){
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
if(!data||typeof data!=='object'||Array.isArray(data))return {};
const pairs=Object.entries(data);
return pairs.length<=64&&pairs.every(([key,value])=>key.length<=256&&typeof value==='string'&&value.length<=65536)&&
JSON.stringify(data).length<=128*1024?data:{};
}catch(_){return {};}
}
function frameyCacheSave(id,entries){
if(!entries||typeof entries!=='object'||Array.isArray(entries))return;
const pairs=Object.entries(entries);
if(pairs.length>64||pairs.some(([key,value])=>key.length>256||typeof value!=='string'||value.length>65536))return;
const encoded=JSON.stringify(entries);
if(encoded.length>128*1024)return;
try{localStorage.setItem(frameyCacheKey(id),encoded);}catch(_){} // Private browsing can deny storage.
}
byId('frameyClose').onclick=()=>{activeFrameyId=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
window.addEventListener('message',async event=>{
if(activeFrameyId===null||event.source!==byId('frameyFrame').contentWindow)return;
const req=event.data;if(!req||typeof req!=='object')return;
if(req.type==='framey:cache:init'&&req.session===frameySession){
event.source.postMessage({type:'framey:cache:seed',session:frameySession,
entries:frameyCacheRead(activeFrameyId)},'*');return;
}
if(req.type==='framey:cache:save'){
if(req.session===frameySession)frameyCacheSave(activeFrameyId,req.entries);
return;
}
if(req.type==='framey:ready'){byId('frameyPanelStatus').textContent='Plugin panel ready';
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});reply.result=data.result;}
try{const data=await api('/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,'*');
});
+6 -3
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';
@@ -2244,7 +2246,8 @@ function showDeckyStore() {
}
async function changeDeckyPlugin(action, plugin) {
if (!$('deckyAck').checked || !confirm(`${action} ${plugin.name}? This official store plugin runs code as your Frame user.`)) return;
try { await runJob(`${action} ${plugin.name}`, 'decky-plugin', () => api('/api/decky/plugin', {action,id:plugin.id,acknowledged:true}));
try { await runJob(`${action} ${plugin.name}`, 'decky-plugin', () => api('/api/decky/plugin',
{action,id:plugin.id,sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true}));
await loadDecky(); showDeckyStore(); } catch(error) { $('deckyStoreState').textContent=error.message; }
}
function deckyButtons() {
+4 -2
View File
@@ -571,15 +571,17 @@ def decky_plugin(body):
raise Failure('Choose an official store plugin and action', 400)
if body.get('acknowledged') is not True:
raise Failure('Confirm that store plugins run code as your Frame user', 400)
reviewed_hash = body.get('sha256')
return start_job('Decky plugin ' + action,
lambda report: frame_decky_store.change(ssh, action, plugin_id, report), progress=True)
lambda report: frame_decky_store.change(ssh, action, plugin_id, report,
reviewed_hash=reviewed_hash), progress=True)
def framey_method_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.call_method(ssh, body.get('id'), body.get('method'), body.get('args'))
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'), body.get('legacy', False))
except framey_panel.PanelError as exc:
raise Failure(str(exc), 400) from exc