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
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 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 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 1aaa3c8c06 Support legacy Decky panels and keyword backend calls 2026-10-25 23:12:28 +00:00
SAPHID 0867debebd Pin CSS Loader and SteamGridDB store compatibility cases 2026-10-25 23:07:09 +00:00
35 changed files with 14749 additions and 57 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.36",
"version": "0.4.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.36",
"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.36",
"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"
]
},
{
+4 -4
View File
@@ -1,6 +1,6 @@
# What we have tested
Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
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 |
|---|---|---|---|---|
@@ -22,8 +22,8 @@ Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified. The new large read-only status screen has browser layout and packaging checks only | 25 Oct, v0.4.29 device check and v0.4.31 status screen: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [PR #220](https://github.com/saphid/frame-control/pull/220), [panel guide](headset-app.md) |
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. v0.4.35 treats `_root` store tags as root-required and hides duplicate IDs; this has test coverage, not a live store check. v0.4.36 stops a store install or update when the catalog hash changed after review; this was tested with a fake Frame, not with a live store change on the headset. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.36 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [PR #246](https://github.com/saphid/frame-control/pull/246), [PR #253](https://github.com/saphid/frame-control/pull/253), [compatibility table](decky-frame.md) |
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Official Steam Status rendered in local Chromium; a session-only cache avoided its earlier storage errors. A later local browser check restored a bounded per-plugin cache after panel reopen; live Decky and offline behavior were not tested. Fake Frame tests cover setting read/write through update and reinstall and install/remove two pinned official API v2 archives plus the modern API v1 Storage Cleaner archive. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle with a settings tab and read-only backend quick action. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set. Storage Cleaner 1.4.0 showed an empty-data panel and four disabled cleanup buttons in local Chromium with a stub backend; no cleanup action was tested. HLTB for Deck 2.0.12 built a shallow settings view and Bluetooth 3.1.1 built its initial panel in local frontend tests without effects or backend calls. Their badges, search, pairing and Bluetooth changes remain unverified. A hash match, local panel and fake Frame install do not prove VR use. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.36: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [#236](https://github.com/saphid/frame-control/pull/236), [#237](https://github.com/saphid/frame-control/pull/237), [#238](https://github.com/saphid/frame-control/pull/238), [#239](https://github.com/saphid/frame-control/pull/239), [#240](https://github.com/saphid/frame-control/pull/240), [#241](https://github.com/saphid/frame-control/pull/241), [#243](https://github.com/saphid/frame-control/pull/243), [#244](https://github.com/saphid/frame-control/pull/244), [#245](https://github.com/saphid/frame-control/pull/245), [#247](https://github.com/saphid/frame-control/pull/247), [#248](https://github.com/saphid/frame-control/pull/248), [#251](https://github.com/saphid/frame-control/pull/251), [#252](https://github.com/saphid/frame-control/pull/252), [#253](https://github.com/saphid/frame-control/pull/253), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 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.
+16 -5
View File
@@ -44,9 +44,9 @@ For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-aut
### Which of these plugins can open a Framey panel?
The separate Framey panel accepts a Frame Control store install when its
`plugin.json` declares API v1 or v2 and it has a modern Decky frontend bundle
in `dist/index.js`. A backend load does not bypass this frontend check. We downloaded the 11 versions above
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:
@@ -54,16 +54,27 @@ catalogue. All 11 had a frontend bundle, but their manifests differed:
| --- | --- | --- |
| 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 | Framey refuses these frontends. Audio Loader and PowerTools also request root. |
| 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 and HLTB cases noted above; none shows
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
+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,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": []
}
+7 -2
View File
@@ -57,15 +57,20 @@ 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; older API v1 bundles still show an incompatibility message | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; recognized legacy IIFE bundles also use the legacy bridge | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
| Legacy Decky IIFE frontend, with no API manifest field | Wraps the returned initializer as an ES module inside the same opaque iframe; passes `serverAPI` and preserves the legacy title | MusicControl 1.1.6 exact official archive, local Chromium startup with backend methods blocked; fake Frame installation/pack/removal |
| Legacy `serverAPI.callPluginMethod(method, kwargs)` | Uses Decky's `loader/call_legacy_plugin_method` RPC, preserving keyword arguments and `{success, result}` replies | Fake Frame HTTP/CDP read/write with a synthetic backend; no official plugin backend action or worn VR result |
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
| `SteamClient` subscriptions and `DFL.Router.MainRunningApp` | Explicit inert registration methods return disposable handles; no events or attached running game. Mutating Steam/power methods are unavailable. | Host contract tests; these are safe fallbacks, not live Steam functionality |
| `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check |
| Optional `settings`, `frameyQuickActions` | Separate settings tab; 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 |
| `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. |
+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.
+15 -1
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
@@ -24,7 +26,10 @@ and swaps one managed plugin
folder with rollback on failure. It rejects non-ARM64 ELF and Windows binaries.
The Decky user service restarts after a file change. Framey's panel host accepts
API v2 bundles and API v1 bundles using Decky's modern frontend initializer,
with `dist/index.js`; other installed plugins remain manageable but have no
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.
@@ -80,6 +85,15 @@ 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,
then checks it again. It also confirms removal blocks method calls and a
+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()
+14 -3
View File
@@ -10,7 +10,9 @@ import threading
import zipfile
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh
from framey_official import PLUGINS as OFFICIAL_PLUGINS, archive as official_archive
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.
@@ -122,6 +124,15 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertEqual(ok('POST', '/api/framey/method',
{'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':999,'acknowledged':True}))
@@ -148,7 +159,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertIn('version changed', stale.get('error', ''))
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
def test_pinned_official_api_v1_and_v2_plugins_install_and_remove(self):
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:
@@ -165,7 +176,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
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'))['api_version'], plugin['api_version'])
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)
+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.
+2
View File
@@ -7,6 +7,7 @@ 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` |
@@ -14,6 +15,7 @@ from a local fixture so CI does not depend on the store being online.
| `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),
+9
View File
@@ -4,6 +4,7 @@ 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,
@@ -14,6 +15,14 @@ PLUGINS = (
'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'},
)
+81 -2
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()},
@@ -101,6 +105,31 @@ 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);
@@ -111,6 +140,56 @@ 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);
+15 -1
View File
@@ -67,7 +67,7 @@ ctx.mockApi=async path => path==='/api/decky' ?
Promise.reject(Error(`unexpected ${path}`));
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('deckyInstalled').children[0].children[2].disabled,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
el('deckySearch').value='';el('deckyStoreFilter').value='all';
vm.runInContext('showDeckyStore()',ctx);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
@@ -117,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/);
+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')
+21 -2
View File
@@ -17,10 +17,29 @@ import io
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_decky_store
import framey_panel
from framey_official import PLUGINS, archive
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:
@@ -49,7 +68,7 @@ class OfficialBundles(unittest.TestCase):
plugin['folder'])
with zipfile.ZipFile(io.BytesIO(data)) as z:
manifest = json.loads(z.read(plugin['folder'] + '/plugin.json'))
self.assertEqual(manifest['api_version'], plugin['api_version'])
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']}))
+25
View File
@@ -58,6 +58,31 @@ class Panel(unittest.TestCase):
with self.assertRaises(FileNotFoundError):
framey_panel.panel(15)
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}))
+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():
+7 -1
View File
@@ -13,6 +13,7 @@ import urllib.request
import zipfile
import frame_decky
import framey_compatibility
STORE_URL = 'https://plugins.deckbrew.xyz/plugins'
@@ -65,7 +66,10 @@ def catalog():
counts = {}
for item in result:
counts[item['id']] = counts.get(item['id'], 0) + 1
return [item for item in result if counts[item['id']] == 1]
unique = [item for item in result if counts[item['id']] == 1]
for item in unique:
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
return unique
def archive_info(data, expected_hash, expected_name):
@@ -222,6 +226,8 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
raise ValueError('The official store version changed; refresh the store and review it again')
if plugin['root']:
raise ValueError('Root plugins are not supported by the Frame user service')
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
+123 -13
View File
@@ -57,16 +57,19 @@
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 => {
@@ -133,6 +136,25 @@
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'));
@@ -189,34 +211,107 @@
// 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,
Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField,
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
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), 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: () => () => {},
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: () => 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);
@@ -281,15 +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
+14 -6
View File
@@ -44,12 +44,16 @@ def local_bundle(plugin_id):
raise FileNotFoundError('Framey plugin manifest unavailable')
flags = data.get('flags', [])
version = data.get('api_version')
if 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 is not API v1/v2 or needs root')
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')
bundle = entry.read_bytes()
if version == 1 and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle:
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):
@@ -76,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))
+12 -7
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,6 +104,7 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}};
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
}catch(error){list.textContent=error.message;}}
function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
activeFrameyId=null, frameySession=null, deckyBusy=false;
async function refreshDecky(){try{deckyState=await api('/api/decky');
@@ -112,6 +114,7 @@ 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';
@@ -130,7 +133,7 @@ function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren
const q=byId('deckySearch').value.trim().toLowerCase();
const filter=byId('deckyStoreFilter').value;
const found=deckyStore.filter(p=>(!q||`${p.name} ${p.author||''} ${p.description}`.toLowerCase().includes(q))&&
(filter==='all'||(filter==='compatible'&&!p.root)||(filter==='installed'&&(deckyState?.plugins||[]).some(x=>x.storeId===p.id))));
(filter==='all'||(filter==='compatible'&&!p.root&&!p.compatibility?.blockInstall)||(filter==='installed'&&(deckyState?.plugins||[]).some(x=>x.storeId===p.id))));
const size=20, pages=Math.max(1,Math.ceil(found.length/size));
deckyStoreIndex=Math.min(deckyStoreIndex,pages-1);
byId('deckyStoreSummary').textContent=`${found.length} plugin${found.length===1?'':'s'} found`;
@@ -140,12 +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;
@@ -191,7 +195,8 @@ 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;
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}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
@@ -234,7 +239,7 @@ window.addEventListener('message',async event=>{
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,'*');
});
+4 -2
View File
@@ -2189,6 +2189,7 @@ $('extensionClose').onclick = () => {
$('extensionFrame').removeAttribute('src');
$('extensionPanel').hidden = true;
};
function deckyCompatibilityText(plugin) {const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
let deckyInfo = null;
let deckyCatalog = [];
async function loadDecky() {
@@ -2200,7 +2201,7 @@ async function loadDecky() {
const managedList=$('deckyManagedList');managedList.replaceChildren();
for(const plugin of d.plugins||[]){if(!plugin.storeId)continue;
const row=document.createElement('div');row.className='hint';
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''}`;
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''} · ${deckyCompatibilityText(plugin)}`;
const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove plugin';
remove.disabled=!d.managed||!$('deckyAck').checked;
remove.onclick=()=>changeDeckyPlugin('remove',{id:plugin.storeId,name:plugin.name});
@@ -2226,8 +2227,9 @@ function showDeckyStore() {
const description = document.createElement('span'); description.textContent = ` ${plugin.description}`;
const entry = installed.find(p => p.storeId === plugin.id);
const unmanaged = installed.some(p => p.name === plugin.name && p.storeId !== plugin.id);
const reason = plugin.root ? 'Needs root; not supported by the Frame user service.' : unmanaged ? 'Existing plugin is not managed by Frame Control.' : '';
const reason = plugin.root ? 'Needs root; not supported by the Frame user service.' : plugin.compatibility?.blockInstall ? plugin.compatibility.reason : unmanaged ? 'Existing plugin is not managed by Frame Control.' : '';
row.append(title, description);
const compatibility=document.createElement('p');compatibility.textContent=deckyCompatibilityText(plugin);row.append(compatibility);
if (reason) { const note=document.createElement('small'); note.textContent=` ${reason}`; row.append(note); }
else {
const button=document.createElement('button'); button.className='small';
+1 -1
View File
@@ -581,7 +581,7 @@ 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