Compare commits

..
Author SHA1 Message Date
SAPHID 8423a309ff Document API v1 Framey limits for v0.4.35 2026-10-25 21:42:29 +00:00
SAPHID 698a3b11d9 Merge remote-tracking branch 'origin/main' into release/v0.4.35 2026-10-25 21:42:13 +00:00
Alex Southwell 5952bf8e9d Merge pull request #247 from saphid/feat/framey-api-v1-panels
Host modern Decky API v1 plugin panels in Framey
2026-10-25 21:41:58 +00:00
SAPHID d186dfbc60 Merge remote-tracking branch 'origin/main' into feat/framey-api-v1-panels
# Conflicts:
#	docs/frame-plugin-author.md
#	docs/framey-loader.md
2026-10-25 21:33:37 +00:00
SAPHID c7e76fdd64 Document v0.4.35 Framey coverage limits 2026-10-25 21:33:29 +00:00
SAPHID 559eec96b3 Merge remote-tracking branch 'origin/main' into release/v0.4.35 2026-10-25 21:32:22 +00:00
Alex Southwell 020f93a187 Merge pull request #248 from saphid/docs/framey-sample-panels
Show Framey settings and quick action in author sample
2026-10-25 21:32:00 +00:00
Alex Southwell 69a912cc7d Merge pull request #245 from saphid/feat/framey-plugin-cache
Persist bounded Framey plugin cache across panel reopen
2026-10-25 21:31:53 +00:00
SAPHID aa6078c11a Keep fake Frame store test scoped to remote installs 2026-10-25 21:31:01 +00:00
SAPHID 01d1fba915 Merge remote-tracking branch 'origin/main' into release/v0.4.35 2026-10-25 21:28:14 +00:00
Alex Southwell e89ff39cff Merge pull request #246 from saphid/fix/framey-store-catalog-edges
Handle root tags and ambiguous Decky store IDs
2026-10-25 21:27:24 +00:00
SAPHID 4cfbd5883f Merge remote-tracking branch 'origin/main' into feat/framey-api-v1-panels
# Conflicts:
#	docs/framey-loader.md
2026-10-25 21:25:09 +00:00
SAPHID 33fdd9d296 Show Framey settings and quick action in author sample 2026-10-25 21:23:09 +00:00
SAPHID 45bd789cb7 Reconcile Framey cache bridge with session shim 2026-10-25 21:21:13 +00:00
SAPHID 1db65d6c82 Merge remote-tracking branch 'origin/main' into feat/framey-plugin-cache 2026-10-25 21:20:22 +00:00
SAPHID 6b6ec64381 Host modern Decky API v1 plugin panels in Framey 2026-10-25 21:20:14 +00:00
SAPHID 1e9aa42282 Prepare Frame Control v0.4.35 2026-10-25 21:19:04 +00:00
Alex Southwell 1e69ca4194 Merge pull request #244 from saphid/fix/framey-session-cache
Give isolated Framey panels a session cache
2026-10-25 21:17:49 +00:00
SAPHID 8296657a45 Handle root tags and ambiguous Decky store IDs 2026-10-25 21:13:56 +00:00
SAPHID 2b2d72ac9a Persist bounded Framey plugin cache across panel reopen 2026-10-25 21:12:30 +00:00
SAPHID fe4e09032d Provide session cache to isolated Framey panels 2026-10-25 21:09:45 +00:00
Alex Southwell 1356ea4df1 Merge pull request #243 from saphid/docs/framey-steam-status-browser
Record local Steam Status Framey frontend result
2026-10-25 11:04:33 +00:00
SAPHID c79ba7571f Record local Steam Status Framey frontend check 2026-10-25 10:55:46 +00:00
Alex Southwell 76dcd0c426 Merge pull request #242 from saphid/release/v0.4.34
Prepare Frame Control v0.4.34
2026-10-25 10:43:24 +00:00
SAPHID f668a10982 Prepare Frame Control v0.4.34 release 2026-10-25 10:35:56 +00:00
Alex Southwell 29f2f57bc0 Merge pull request #236 from saphid/feat/framey-ui-controls
Support more Decky settings controls in Framey
2026-10-25 10:35:17 +00:00
Alex Southwell a257ec0bf9 Merge pull request #241 from saphid/docs/framey-official-api-audit
Document official Decky frontend compatibility by manifest
2026-10-25 10:31:17 +00:00
Alex Southwell 99504554e0 Merge pull request #240 from saphid/feat/framey-sample-recovery
Recover Framey sample settings from damaged data
2026-10-25 10:28:41 +00:00
Alex Southwell b53293eddd Merge pull request #239 from saphid/feat/framey-zip-collisions
Reject colliding Decky plugin ZIP paths
2026-10-25 10:23:20 +00:00
Alex Southwell 229f17696e Merge pull request #238 from saphid/feat/framey-official-fixtures
Test official Decky plugin archives in fake Frame
2026-10-25 10:22:58 +00:00
SAPHID f6a88f1a51 Merge remote-tracking branch 'origin/main' into feat/framey-ui-controls
# Conflicts:
#	docs/frame-plugin-author.md
#	tests/page/framey_actions.mjs
#	ui/framey-host.js
2026-10-25 10:19:20 +00:00
Alex Southwell b7caa7d7d3 Merge pull request #237 from saphid/fix/framey-disabled-controls
Honor disabled state in Framey plugin controls
2026-10-25 10:18:09 +00:00
SAPHID a61dfdd3ab Recover sample Decky settings from damaged data 2026-10-25 10:17:29 +00:00
SAPHID 840f53f032 Reject colliding Decky plugin ZIP paths 2026-10-25 10:16:03 +00:00
SAPHID 5932b6a8a4 Document official Decky frontend API compatibility audit 2026-10-25 10:15:48 +00:00
SAPHID 3f2e32db87 Test pinned official Decky plugins in fake Frame 2026-10-25 10:14:14 +00:00
SAPHID a6a1e0b258 Honor disabled state in Framey plugin controls 2026-10-25 10:09:57 +00:00
SAPHID db74e3be96 Support more Decky panel controls in Framey 2026-10-25 10:09:56 +00:00
Alex SouthwellandSAPHID faf7bc6224 Prepare Frame Control 0.4.33 (#235)
* Prepare Frame Control 0.4.33

* Record Framey sample and Decky recovery limits

---------

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

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.33",
"version": "0.4.35",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.33",
"version": "0.4.35",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.33",
"version": "0.4.35",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+3 -3
View File
@@ -1,6 +1,6 @@
# What we have tested
Current for Frame Control **v0.4.33**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
Current for Frame Control **v0.4.35**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
@@ -23,7 +23,7 @@ Current for Frame Control **v0.4.33**, 25 October 2026. “Real Frame + physical
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified. The new large read-only status screen has browser layout and packaging checks only | 25 Oct, v0.4.29 device check and v0.4.31 status screen: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [PR #220](https://github.com/saphid/frame-control/pull/220), [panel guide](headset-app.md) |
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.33 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [compatibility table](decky-frame.md) |
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Fake Frame tests cover setting read/write through update and reinstall; page tests cover the official JSX runtime, stale job recovery and an author sample bundle. The sample is not published in the store. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.33: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
| Experimental 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. 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.35 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), [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. 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.35: [#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), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
+24 -2
View File
@@ -42,6 +42,27 @@ files or behavior. The official store's digest checks the archive in transit;
it does not certify plugin code. Plugins run with the headset user's access.
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
### Which of these plugins can open a Framey panel?
The separate Framey panel accepts a Frame Control store install only when its
`plugin.json` declares `api_version: 2` and it has `dist/index.js`. A backend
load does not bypass this frontend check. We downloaded the 11 versions above
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
catalogue. All 11 had a frontend bundle, but their manifests differed:
| Manifest in the checked archive | Plugins | Framey panel result |
| --- | --- | --- |
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Framey refuses these frontends. Wine Cellar and MagicPods also fail the ARM64 archive check. |
| 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. |
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.
This is a static PC inspection. It does not show that any 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.
The new store transaction was also exercised on the same real Frame:
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
store update → store remove → managed loader remove**. Status reported the
@@ -57,8 +78,9 @@ change Frame Control owned plugin folders under
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
back an update if the service restart fails. The current VR shell does not
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
shows installed plugins and store actions. Its separate Framey iframe now hosts
compatible API v2 plugin pages, settings, local routes, and panel toasts.
shows installed plugins and store actions. Its separate Framey iframe hosts
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
with settings, local routes, and panel toasts where supported.
Steam route patches and system notifications remain unavailable. The panel
still needs a worn-headset visual and controller check. See the
[current compatibility table](frame-plugin-author.md#framey-api-v2-compatibility).
@@ -0,0 +1,20 @@
# API v1 panel check, 25 October 2026
The unmodified official Storage Cleaner 1.4.0 archive (store ID 50, SHA-256
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`)
declares `api_version: 1`, but its bundled frontend connects to Decky's modern
frontend API. Framey admitted that bundle from a local store receipt and
rendered it in Chromium inside an iframe with `sandbox="allow-scripts
allow-popups"`.
A test parent returned empty lists and `0B` for the plugin's two list and two
size backend calls. Chromium reported `framey:ready`, displayed Shader Cache
and Compatibility Data sections, and showed all four cleanup buttons disabled.
No cleanup action was invoked. The backend was a stub; the test did not run the
plugin's Python code or connect to a headset.
This confirms a panel shell for this exact archive with empty data. Storage
Cleaner may remove compatibility data and local game saves when used with a
real backend. Its cleanup actions, game names, SteamClient app details, VR
visibility, and controller input remain unverified on Frame. Do not infer that
its deletion controls are safe or complete in VR from this browser check.
@@ -0,0 +1,19 @@
# Framey cache check, 25 October 2026
The official Steam Status 1.2.3 ZIP pinned in
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
allow-popups"`, so its native storage remained unavailable. The new Framey
storage facade accepted a set/get probe without a `SecurityError`. Steam
Status rendered and issued four `get_settings` calls to a stub parent backend.
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
snapshot and the plugin wrote the same cache key again. The browser log had
zero `SecurityError` or `localStorage` errors.
The parent was a local test page with in-memory Decky backend replies. One
public status fetch also failed in the test browser, so this check does not
establish offline correctness or the freshness of Steam Status data. The
per-plugin save/reopen protocol, stale panel session rejection, and cache
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
live Decky settings, VR visibility, and controller input remain unverified.
@@ -0,0 +1,116 @@
{
"checkedAtUtc": "2026-10-25",
"source": "https://plugins.deckbrew.xyz/plugins",
"method": "Downloaded exact current official store versions; local SHA-256 matched each store hash; inspected ZIP plugin.json and dist/index.js without running plugin code.",
"limit": "Static inspection on PC. Prior backend load results are documented in docs/decky-frame.md; VR visibility and controller input remain unverified.",
"plugins": [
{
"storeId": 7,
"name": "CSS Loader",
"storeVersion": "2.1.3",
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
"manifestApiVersion": null,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 14,
"name": "ProtonDB Badges",
"storeVersion": "1.3.4",
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
"manifestApiVersion": 2,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 16,
"name": "Audio Loader",
"storeVersion": "1.6.1",
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
"manifestApiVersion": null,
"manifestFlags": [
"_root"
],
"frontendBundle": true
},
{
"storeId": 18,
"name": "Bluetooth",
"storeVersion": "3.1.1",
"sha256": "f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 21,
"name": "PowerTools",
"storeVersion": "2.0.3",
"sha256": "47614f53b8c538c4caa15f89a01e4ab106fa328e89f78545bacb3166d104d964",
"manifestApiVersion": null,
"manifestFlags": [
"root",
"global-dfl"
],
"frontendBundle": true
},
{
"storeId": 23,
"name": "Animation Changer",
"storeVersion": "1.3.2",
"sha256": "f2c62b90ca60d8a80b6d0f75d8027552b1509c7a05842c6f4a24a9072846d133",
"manifestApiVersion": null,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 26,
"name": "HLTB for Deck",
"storeVersion": "2.0.12",
"sha256": "bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 36,
"name": "SteamGridDB",
"storeVersion": "1.8.1",
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
"manifestApiVersion": 1,
"manifestFlags": [
"debug"
],
"frontendBundle": true
},
{
"storeId": 50,
"name": "Storage Cleaner",
"storeVersion": "1.4.0",
"sha256": "1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 68,
"name": "Wine Cellar",
"storeVersion": "0.2.1",
"sha256": "7e077b933c234414d172b5c6d1c804b026bf39fa4e18e6663495a282637031e8",
"manifestApiVersion": 1,
"manifestFlags": [
"debug"
],
"frontendBundle": true
},
{
"storeId": 82,
"name": "MagicPods",
"storeVersion": "2.0.21",
"sha256": "abe2eef5788cb3ff6e73fcb9783e30a54b127e28c761a9bf99e64ab5fc4be9ab",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
}
]
}
@@ -0,0 +1,26 @@
# Framey session cache browser check, 25 October 2026
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
used a per-panel memory substitute. It did **not** change the iframe's sandbox
flags or give plugin code Frame Control's UI key.
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
from `tests/fixtures/framey-official/123.zip` (SHA-256
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
four `get_settings` calls. That reply was a stub, not the Decky backend.
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
The parent could not read the iframe document (`contentDocument` was null).
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
Managers and Players Online. Its public status request returned HTTP 200.
No cache `SecurityError` appeared in the captured console, while the
same archive had logged that error with the v0.4.34 host.
Node host tests also checked item updates, removal, clear, key order, string
conversion, quota rejection, and preservation of an existing item after a
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
values **in memory**. Closing or reloading the panel loses this cache. This
check did not test offline behavior, persistent plugin settings, a live Decky
backend, SteamVR visibility, controller input, or headset performance.
@@ -0,0 +1,28 @@
# Steam Status in the local Framey browser, 25 October 2026
This check used the unmodified official Steam Status 1.2.3 archive already
pinned in `tests/fixtures/framey-official/123.zip`. Its SHA-256 is
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`.
The archive declares Decky API v2. No headset was contacted for this check.
On a Linux PC, we extracted the archive to a disposable plugin folder with a
Frame Control store receipt. A local HTTP server served the page from
`framey_panel.panel(123)`, Frame Control's React 18.3.1 runtime and its
`framey-host.js`. A parent page put the panel in an iframe with
`sandbox="allow-scripts"` and returned `{}` for the plugin's `get_settings`
backend calls. This was a **stub**, not the Decky service.
Chromium reported `framey:ready` and four `get_settings` calls. The page showed
Steam Status, Store, Community, WebAPI, Connection Managers, Players Online,
and other controls. Its own public `https://steamstatus.schelstraete.org/api/v1/status`
request returned HTTP 200 and populated the status view. A click on **Refresh**
sent Framey's trusted pointer event to the parent; this test did not prove a
fresh status fetch or settings write. The [browser screenshot](framey-steam-status-browser-2026-10-25.png)
contains only the local test page and public service data.
The plugin logged `SecurityError` while reading and writing `localStorage` in
the opaque-origin iframe. It caught the errors and rendered anyway. Its cache
and offline behavior in Framey are **not verified**. The real Decky backend,
live settings, SteamVR visibility, controller input and headset performance
were not tested. This result supports only local frontend rendering for this
exact archive and Frame Control v0.4.34 host code.
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+24 -3
View File
@@ -29,19 +29,26 @@ before describing it as working in VR. Until a QAM bridge is verified, a Frame
Control extension is the simplest route for a self-contained read-only VR panel.
Frame Control now has an experimental [Framey panel host](framey-loader.md)
for API v2 Decky frontend bundles. It renders common settings controls in the
for API v2 Decky frontend bundles and API v1 bundles that use Decky's modern
frontend API initializer. It renders common settings controls in the
separate SteamVR dashboard panel, but Steam library and store page patches
remain unavailable. Wearer-visible use is not yet verified.
The [settings sample](../examples/framey-settings/README.md) shows an official
Decky JSX build, backend persistence, a toggle and a local toast. Framey offers
only official-store-owned plugins in its VR panel.
For persistent backend settings, use Decky's `decky.decky_SETTINGS_DIR` rather
than assuming an environment variable or Steam Deck home path. Validate stored
types when reading, write through a temporary file before replacing the old
file, and give the panel a safe default if settings are missing or damaged.
Removing a managed plugin leaves its Decky settings behind; document how users
can reset data your plugin owns.
To test safely, use a separate Frame Control managed Decky install, verify the
ZIP digest from the official store, install one plugin at a time, and inspect
`journalctl --user -u frame-control-decky.service`. Remove only your own
plugin folder when you finish. See the current [compatibility table](decky-frame.md).
## Framey API v2 compatibility
## Framey Decky API compatibility
Framey hosts a plugin's frontend in an isolated iframe in the Frame Control
SteamVR panel. The table describes the current implementation and its tests;
@@ -50,11 +57,15 @@ 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. |
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls | Local browser and host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
| `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 |
| `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. |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack` | Local routes inside this plugin panel only | Host unit test |
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
@@ -64,3 +75,13 @@ Keep routes local to the panel and give every operation a visible result. A
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
Frame Control store; only paths registered by that same plugin can navigate.
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
An API v1 manifest does not guarantee a working Framey panel. Test the exact
store archive and do not assume SteamClient app details, library patches, or
destructive actions work in VR. The
[Storage Cleaner browser check](evidence/framey-api-v1-browser-2026-10-25.md)
used empty stub data and did not invoke its cleanup actions.
Use the Decky Python backend for settings that must survive browser cache loss.
The Framey `localStorage` bridge is for disposable frontend caches, including
the cache read attempted by Steam Status 1.2.3. It does not expose another
plugin's cache or the Frame Control UI key. A real Steam Status panel reopen
and a worn-headset settings check are still needed.
+34 -5
View File
@@ -19,11 +19,14 @@ panel or Decky backend keeps plugin folders until each is removed separately.
Frame Control's existing official-store manager downloads a published ZIP from
Decky's content-addressed CDN, verifies its store SHA-256 hash and manifest,
rejects unsafe ZIP members and root plugins, and swaps one managed plugin
rejects unsafe ZIP members, path collisions, special files and root plugins,
and swaps one managed plugin
folder with rollback on failure. It rejects non-ARM64 ELF and Windows binaries.
The Decky user service restarts after a file change. Framey's panel host accepts
only API v2 bundles with `dist/index.js`; other installed plugins remain
manageable but have no Framey panel. The store manager does not currently
API v2 bundles and API v1 bundles using Decky's modern frontend initializer,
with `dist/index.js`; other installed plugins remain manageable but have no
Framey panel. API v1 admission is a compatibility attempt, not a guarantee
that the plugin's controls work in VR. The store manager does not currently
screen a plugin's runtime network downloads.
## What the compatibility host supplies
@@ -36,8 +39,15 @@ through Decky's *existing* websocket connection. Framey never opens a second
Decky websocket, because Decky allows only one frontend connection. The iframe
does not receive Frame Control's UI key. React and ReactDOM are bundled for
offline use under their MIT licenses in `ui/vendor`.
Framey gives each plugin a bounded `localStorage` substitute because browser
storage is denied to an opaque-origin iframe. The parent panel keeps a separate
cache for each plugin: up to 64 keys, 64 KiB per value and 128 KiB total. It
restores the cache when a panel reopens if the parent's browser storage is
available. If storage is denied or cleared, the cache lasts only for the current
panel. Use the plugin backend for settings that must survive cache loss.
The host currently supplies sections, rows, buttons, toggles, dropdowns,
The host currently supplies sections, rows, buttons, toggles, text fields,
sliders, grouped dropdowns,
scroll containers, a modal, locale information, local panel routes and toasts,
and backend calls for the plugin that owns the panel. Steam library/store page
patches, global Steam UI components, file pickers, and privileged root methods
@@ -53,6 +63,22 @@ live Decky settings persistence, or badges on Steam game pages. The last item
has no supported injection target in the Frame Control panel.
The [browser and fake Frame evidence](evidence/framey-browser-2026-10-25.md)
records exactly what was exercised.
The pinned official Steam Status 1.2.3 frontend also
[rendered in local Chromium](evidence/framey-steam-status-browser-2026-10-25.md)
with a stubbed settings reply. Its public status request worked, but its cache
logged `localStorage` errors in Framey's v0.4.34 opaque iframe. A later
[local browser check](evidence/framey-session-cache-2026-10-25.md) confirmed
that a panel-lifetime substitute avoids those errors while preserving the
opaque origin. A [second browser check](evidence/framey-cache-browser-2026-10-25.md)
tested the per-plugin cache after reopening a panel. These checks used a stub
backend and no worn headset.
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4,
Steam Status 1.2.3, and Storage Cleaner 1.4.0 archives, checks their API v1/v2
manifests and frontend bytes, and verifies they can be packed as Framey panels.
It does not execute their actual backend code or prove their frontend controls
work in VR. Storage Cleaner rendered an
[empty-data panel in local Chromium](evidence/framey-api-v1-browser-2026-10-25.md).
Its cleanup actions were not tested; they may remove local game saves.
The fake Frame integration test now writes a Decky setting through Framey's
scoped backend endpoint, reads it in a new CEF request, updates the plugin,
@@ -64,7 +90,10 @@ The in-headset **Open official store** view loads the full official catalogue.
Search matches names, authors, and descriptions. Filters show non-root plugins
or installed plugins, and Previous/Next buttons browse 20 results per page.
The root label is a compatibility warning; other APIs are checked only when a
plugin is opened. The store still needs the managed Decky user service running
plugin is opened. Both `root` and `_root`
store tags mark a plugin as needing root, and duplicate store IDs are omitted
because an ID alone could select a different archive than the visible row.
The store still needs the managed Decky user service running
before its Install or Update buttons are enabled. If a plugin prevents Decky
from starting, Remove remains available for Frame Control-owned plugins while
the managed service is stopped; it does not restart the service.
+12 -4
View File
@@ -4,7 +4,14 @@ This is a small Decky API v2 plugin for authors. Its panel uses the official
`@decky/api`, `@decky/ui`, and `@decky/rollup` packages. A toggle calls a Python
backend method, stores one boolean under `decky.decky_SETTINGS_DIR`,
and reads it back when the panel opens. The backend rejects other keys and
values. The frontend also shows a local toast after a successful save.
values. The frontend also shows a local toast after a successful save. A
Framey settings tab exposes the same toggle, and a read-only quick action
fetches the saved value without changing it. These two fields are Framey
additions; Decky's own menu still uses the main panel content. The quick
action calls the backend, so it reads the persisted setting rather than a
browser cache. A missing, damaged, or old non-boolean value reads as the
caller's default;
the next save atomically replaces the sample's settings file.
## Build
@@ -27,11 +34,12 @@ plugin directory does not enable its VR panel.
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
repository root. It checks backend persistence and evaluates the built bundle
against Framey's actual host script. The fake Frame store flow is exercised by
against Framey's actual host script, including the quick action's scoped
backend call. The fake Frame store flow is exercised by
the repository's e2e suite. No worn-headset visibility or controller-input test
has passed for this sample yet.
The plugin needs no root access or system changes. Its settings remain after
the managed plugin folder is removed, following Decky's settings behavior.
For a production plugin, add migration and recovery handling for damaged
settings files, and test every UI path in a worn headset.
For a production plugin, add explicit migrations for every supported schema,
explain how users can reset settings, and test every UI path in a worn headset.
+12 -1
View File
@@ -61,10 +61,21 @@ function Content() {
}
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
}
var index = definePlugin(() => ({
var index = definePlugin(() => Object.assign({
name: 'Framey Settings Sample',
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
content: SP_JSX.jsx(Content, {}),
}, {
// Framey additions are ignored by Decky's own menu. Keep the main content
// usable there too, while showing authors the extra VR settings view.
settings: SP_JSX.jsx(Content, {}),
frameyQuickActions: [{
label: 'Read saved setting',
onClick: async () => {
const enabled = await getSetting('enabled', false);
return `Saved setting is ${enabled ? 'on' : 'off'}`;
},
}],
}));
export { index as default };
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => ({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WAAA,EAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,8CAAA,EAAA,GAAA,CAAA,QAAA,CAAA,6BAAA,CAAA,CAAA;AACA;AAEA,MAAA,QAAA,GAAA,GAAA,CAAA,QAAA;AAIA,MAAA,OAAA,GAAA,GAAA,CAAA,OAAA;AAQA,MAAA,YAAA,GAAA,CAAA,EAAA,KAAA;AACA,IAAA,OAAA,CAAA,GAAA,IAAA,KAAA;AACA,QAAA,OAAA,EAAA,CAAA,GAAA,IAAA,CAAA;AACA,IAAA,CAAA;AACA,CAAA;;ACnCA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AACA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AAEA,SAAA,OAAA,Line truncated
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the main content\n // usable there too, while showing authors the extra VR settings view.\n settings: <Content />,\n frameyQuickActions: [{\n label: 'Read saved setting',\n onClick: async () => {\n const enabled = await getSetting('enabled', false);\n return `Saved setting is ${enabled ? 'on' : 'off'}`;\n },\n }],\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WLine truncated
+4 -3
View File
@@ -16,10 +16,11 @@ class Plugin:
if key != 'enabled':
raise ValueError('Unknown setting')
try:
stored = json.loads(settings_file().read_text())
except FileNotFoundError:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return default
return stored.get(key, default)
value = stored.get(key) if isinstance(stored, dict) else None
return value if type(value) is bool else default
async def set_setting(self, key: str, value: bool):
if key != 'enabled' or type(value) is not bool:
+12 -1
View File
@@ -49,8 +49,19 @@ function Content() {
</PanelSection>;
}
export default definePlugin(() => ({
export default definePlugin(() => Object.assign({
name: 'Framey Settings Sample',
icon: <span aria-hidden="true">⚙</span>,
content: <Content />,
}, {
// Framey additions are ignored by Decky's own menu. Keep the main content
// usable there too, while showing authors the extra VR settings view.
settings: <Content />,
frameyQuickActions: [{
label: 'Read saved setting',
onClick: async () => {
const enabled = await getSetting('enabled', false);
return `Saved setting is ${enabled ? 'on' : 'off'}`;
},
}],
}));
+50 -15
View File
@@ -10,6 +10,7 @@ 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
require() # Skip the module outside the disposable fake Frame, like its peers.
@@ -26,6 +27,10 @@ def archive(version):
ARTIFACTS = {hashlib.sha256(a).hexdigest(): a for a in (archive('1.0.0'), archive('1.1.0'))}
HASHES = list(ARTIFACTS)
for plugin in OFFICIAL_PLUGINS:
data = official_archive(plugin)
assert hashlib.sha256(data).hexdigest() == plugin['sha256']
ARTIFACTS[plugin['sha256']] = data
class StoreFixture(BaseHTTPRequestHandler):
@@ -35,10 +40,15 @@ class StoreFixture(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/plugins':
body = json.dumps([{'id':123,'name':'Sample Panel','author':'Fixture',
records = [{'id':999,'name':'Sample Panel','author':'Fixture',
'description':'A harmless panel','tags':[],
'versions':[{'name':['1.0.0','1.1.0'][self.release],
'hash':HASHES[self.release]}]}]).encode()
'hash':HASHES[self.release]}]}]
records.extend({'id':plugin['id'], 'name':plugin['name'], 'author':'Official Decky store',
'description':'Pinned official archive', 'tags':[],
'versions':[{'name':plugin['version'], 'hash':plugin['sha256']}]}
for plugin in OFFICIAL_PLUGINS)
body = json.dumps(records).encode()
content_type = 'application/json'
elif self.path.startswith('/versions/') and self.path.endswith('.zip'):
body = ARTIFACTS.get(self.path.split('/')[-1][:-4])
@@ -93,41 +103,66 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertEqual(status, 403)
catalogue = ok('GET', '/api/decky/store')['plugins']
self.assertEqual((catalogue[0]['version'], catalogue[0]['sha256']), ('1.0.0', HASHES[0]))
status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':123})
status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':999})
self.assertEqual(status, 400) # Third-party code needs explicit acknowledgement.
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':123,'acknowledged':True}))
{'action':'install','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
status, _, _ = api('POST', '/api/framey/method', {'id':123,'method':'get_setting','args':[]},
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'get_setting','args':[]},
headers={'X-Frame-UI':''})
self.assertEqual(status, 403)
status, _, _ = api('POST', '/api/framey/method', {'id':123,'method':'x;alert(1)','args':[]})
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'x;alert(1)','args':[]})
self.assertEqual(status, 400)
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
self.assertTrue(ok('POST', '/api/framey/method',
{'id':123,'method':'set_setting','args':['theme','light']})['result'])
{'id':999,'method':'set_setting','args':['theme','light']})['result'])
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
StoreFixture.release = 1
job = finished(ok('POST', '/api/decky/plugin',
{'action':'update','id':123,'acknowledged':True}))
{'action':'update','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':123,'acknowledged':True}))
{'action':'remove','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'], [])
self.assertEqual(ssh('test -e ~/.local/share/frame-control/decky/plugins/sample-panel && echo yes || echo no').strip(), 'no')
status, _, _ = api('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})
{'id':999,'method':'get_setting','args':['theme','dark']})
self.assertEqual(status, 400) # No method calls while the plugin is removed.
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':123,'acknowledged':True}))
{'action':'install','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('POST', '/api/framey/method',
{'id':123,'method':'get_setting','args':['theme','dark']})['result'], 'light')
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
def test_pinned_official_api_v1_and_v2_plugins_install_and_remove(self):
self.backend()
catalog = {plugin['id']:plugin for plugin in ok('GET', '/api/decky/store')['plugins']}
for plugin in OFFICIAL_PLUGINS:
with self.subTest(plugin=plugin['name']):
self.assertEqual(catalog[plugin['id']]['sha256'], plugin['sha256'])
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':plugin['id'],'acknowledged':True}))
self.assertFalse(job.get('error'), job)
found = [item for item in ok('GET', '/api/decky')['plugins']
if item['storeId'] == plugin['id']]
self.assertEqual(len(found), 1)
self.assertEqual(found[0]['version'], plugin['version'])
installed = f"~/.local/share/frame-control/decky/plugins/{plugin['folder']}"
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['api_version'], plugin['api_version'])
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':plugin['id'],'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertFalse(any(item['storeId'] == plugin['id']
for item in ok('GET', '/api/decky')['plugins']))
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
# Pinned Decky store test archives
These are unmodified ZIPs downloaded from the official Decky store CDN on
2026-10-25. Each ZIP includes its upstream `LICENSE`. They are test data, not
plugins shipped or installed by Frame Control. The fake Frame tests serve them
from a local fixture so CI does not depend on the store being online.
| File | Official plugin | Version | Store SHA-256 |
| --- | --- | --- | --- |
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
The matching source projects are
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
[Steam Status](https://github.com/bschelst/steamstatus-decky), and
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner).
The test verifies the ZIP digest, archive policy, API v1/v2 manifest, installed
frontend bytes, managed store receipt, and removal. It does **not** run their
Python backends or prove their JavaScript panels render in the VR shell.
+17
View File
@@ -0,0 +1,17 @@
"""Pinned official Decky store archives used only in disposable test fixtures."""
from pathlib import Path
ROOT = Path(__file__).parent / 'fixtures/framey-official'
PLUGINS = (
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2,
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'},
{'id': 50, 'name': 'Storage Cleaner', 'version': '1.4.0', 'api_version': 1,
'folder': 'decky-storage-cleaner', 'sha256': '1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591'},
)
def archive(plugin):
return (ROOT / f"{plugin['id']}.zip").read_bytes()
+46 -4
View File
@@ -28,7 +28,48 @@ assert.deepEqual(jsx.children,['Saved']);
const fragments=window.SP_JSX.jsxs(window.SP_JSX.Fragment,{children:['one','two']});
assert.equal(fragments.type,React.Fragment);
assert.deepEqual(fragments.children,[['one','two']]);
const nodes=(tree,type)=>[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))];
const nodes=(tree,type)=>Array.isArray(tree)?tree.flatMap(child=>nodes(child,type)):
[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))];
// Disabled controls stay inert even if a caller invokes their handlers directly.
let changed=0;
const toggle=nodes(window.DFL.ToggleField({label:'Enabled',checked:true,disabled:true,
onChange:()=>changed++}),'input')[0];
const options=[{label:'Modes',options:[{label:'Slow',data:'slow'},{label:'Fast',data:'fast'}]}];
const dropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast',disabled:true,
onChange:()=>changed++}),'select')[0];
const button=nodes(window.DFL.ButtonItem({label:'Reload',description:'Read setting',disabled:true,
onClick:()=>changed++}),'button')[0];
const input=nodes(window.DFL.TextField({label:'Alias',value:'Old',placeholder:'New',disabled:true,
onChange:()=>changed++}),'input')[0];
const slider=nodes(window.DFL.SliderField({label:'Speed',value:2,min:0,max:10,step:2,disabled:true,
onChange:()=>changed++}),'input')[0];
const checkbox=nodes(window.DFL.DialogCheckbox({label:'Game',disabled:true,
onChange:()=>changed++}),'input')[0];
for(const control of [toggle,dropdown,button,input,slider,checkbox])assert.equal(control.props.disabled,true);
assert.equal(input.props.value,'Old');
assert.equal(slider.props.type,'range');
assert.equal(dropdown.props.value,'1');
assert.equal(nodes(dropdown,'optgroup').length,1);
toggle.props.onChange({target:{checked:false}});
dropdown.props.onChange({target:{value:'0'}});
button.props.onClick({});
input.props.onChange({target:{value:'New'}});
slider.props.onChange({target:{value:'4'}});
checkbox.props.onChange({target:{checked:true}});
assert.equal(changed,0);
let entered='';
const readyInput=nodes(window.DFL.TextField({label:'Alias',value:'Old',onChange:e=>{entered=e.target.value;}}),'input')[0];
readyInput.props.onChange({target:{value:'New'}});assert.equal(entered,'New');
let speed=0;
const readySlider=nodes(window.DFL.SliderField({label:'Speed',value:2,onChange:v=>{speed=v;}}),'input')[0];
readySlider.props.onChange({target:{value:'4'}});assert.equal(speed,4);
let selected;
const readyDropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast',
onChange:option=>{selected=option;}}),'select')[0];
readyDropdown.props.onChange({target:{value:'0'}});assert.equal(selected.data,'slow');
const readyButton=nodes(window.DFL.ButtonItem({label:'Reload',onClick:()=>changed++}),'button')[0];
assert.equal(readyButton.props.disabled,false);
readyButton.props.onClick({});assert.equal(changed,1);
let called=0;
const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{type:'title'},
frameyQuickActions:[{label:'Refresh data',onClick:async()=>{called++;return 'Fresh';}},
@@ -47,9 +88,10 @@ assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav'
// API v2 backend calls remain request-scoped and errors return to the caller.
const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test');
const reply=api.call('get_setting','mode','default');
assert.equal(messages[0].method,'get_setting');
assert.deepEqual(Array.from(messages[0].args),['mode','default']);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:messages[0].id,result:'dark'}});
const call=messages.find(message=>message.type==='framey:call');
assert.equal(call.method,'get_setting');
assert.deepEqual(Array.from(call.args),['mode','default']);
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:call.id,result:'dark'}});
assert.equal(await reply,'dark');
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
const parentSource=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
const hostSource=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8')
.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
assert.match(hostSource,/framey:cache:init/);
const values=new Map();
const localStorage={getItem:key=>values.get(key)??null,setItem:(key,value)=>values.set(key,value)};
const elements=new Map();
const el=id=>{if(!elements.has(id))elements.set(id,{id,hidden:false,removeAttribute(){},scrollIntoView(){}});
return elements.get(id);};
const parentListeners=[];
const parent=vm.createContext({document:{getElementById:el,createElement:()=>({})},
location:{hash:'',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem:()=>null,setItem(){}},localStorage,
window:{addEventListener:(type,fn)=>parentListeners.push({type,fn})},
setTimeout(){},setInterval(){},fetch(){throw Error('No network expected');},console});
vm.runInContext(parentSource,parent);
vm.runInContext('deckyState={plugins:[{storeId:14}]};openFrameyPanel(14,"Steam Status")',parent);
let session=vm.runInContext('frameySession',parent);
const onParent=parentListeners.find(x=>x.type==='message').fn;
function iframe(){
const listeners=[];const queued=[];let live=false;
const frameWindow={location:{hash:`#${session}`},React:{Fragment:'fragment',Component:class {},createElement:()=>({}),isValidElement:()=>true},
ReactDOM:{createRoot:()=>({render(){}})}};
const proxy={postMessage:message=>listeners.find(x=>x.type==='message')?.fn({source:hostParent,data:message})};
el('frameyFrame').contentWindow=proxy;
const hostParent={postMessage:message=>live?onParent({source:proxy,data:message}):queued.push(message)};
const context=vm.createContext({window:frameWindow,document:{getElementById:()=>({})},
navigator:{language:'en'},parent:hostParent,DOMException,
addEventListener:(type,fn)=>listeners.push({type,fn}),setTimeout(){},clearTimeout(){}});
vm.runInContext(hostSource,context);
for(const message of queued)onParent({source:proxy,data:message});
live=true;
const send=message=>onParent({source:proxy,data:message});
return {storage:frameWindow.localStorage,send};
}
let first=iframe();
assert.equal(first.storage.getItem('status-cache'),null);
first.storage.setItem('status-cache','{"online":true}');
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
first.storage.setItem('temporary','discard');
first.storage.removeItem('temporary');
const oldSession=session;
vm.runInContext('openFrameyPanel(14,"Steam Status")',parent);
session=vm.runInContext('frameySession',parent);
assert.notEqual(session,oldSession);
let reopened=iframe();
assert.equal(reopened.storage.getItem('status-cache'),'{"online":true}');
assert.equal(reopened.storage.length,1);
assert.equal(reopened.storage.key(0),'status-cache');
assert.throws(()=>reopened.storage.setItem('huge','x'.repeat(65537)),/Framey plugin cache is full/);
// A malformed or over-limit message from a plugin must not poison saved data.
reopened.send({type:'framey:cache:save',session,entries:{huge:'x'.repeat(65537)}});
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
reopened.send({type:'framey:cache:save',session:oldSession,entries:{poison:'yes'}});
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
reopened.storage.clear();
assert.equal(values.get('framey-plugin-cache-v1:14'),'{}');
+14 -1
View File
@@ -10,8 +10,16 @@ const React={Fragment:Symbol('fragment'),Component:class {},
createElement:(type,props,...children)=>({type,props:props||{},children}),
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
const listeners=[];
const calls=[];
const parent={postMessage(message){
if(message.type!=='framey:call')return;
calls.push(message);
listeners.find(x=>x.type==='message').handler({source:parent,
data:{type:'framey:reply',id:message.id,result:true}});
}};
const context=vm.createContext({window,document:{getElementById:()=>({})},
navigator:{language:'en'},parent:{postMessage(){}},addEventListener(){},
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(host,context);
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
@@ -23,3 +31,8 @@ const content=plugin.content.type();
assert.equal(content.type,window.DFL.PanelSection);
assert.equal(content.props.title,'Framey settings sample');
assert.equal(content.children[0][0].type,window.DFL.PanelSectionRow);
assert.equal(plugin.settings.type,plugin.content.type);
assert.equal(plugin.frameyQuickActions[0].label,'Read saved setting');
assert.equal(await plugin.frameyQuickActions[0].onClick(),'Saved setting is on');
assert.equal(calls[0].method,'get_setting');
assert.deepEqual(Array.from(calls[0].args),['enabled',false]);
+35
View File
@@ -11,6 +11,7 @@ import sys
import tempfile
import unittest
from unittest.mock import patch
import warnings
import zipfile
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
@@ -40,6 +41,15 @@ class ArchiveTests(unittest.TestCase):
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([(p['id'], p['author']) for p in frame_decky_store.catalog()], [(42, 'Panel Author')])
def test_catalog_marks_underscore_root_and_omits_ambiguous_store_ids(self):
records = [{'id': 42, 'name': 'Needs root', 'tags': ['_root'],
'versions': [{'name': '1.0', 'hash': 'a' * 64}]},
{'id': 43, 'name': 'First', 'versions': [{'name': '1.0', 'hash': 'b' * 64}]},
{'id': 43, 'name': 'Second', 'versions': [{'name': '2.0', 'hash': 'c' * 64}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen',
return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([(p['id'], p['root']) for p in frame_decky_store.catalog()], [(42, True)])
def test_accepts_hashed_non_root_archive(self):
data, digest = archive()
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
@@ -54,6 +64,22 @@ class ArchiveTests(unittest.TestCase):
with self.assertRaises(ValueError):
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
def test_rejects_zip_path_collisions_and_non_object_manifest(self):
for name in ('Example/dist/index.js', 'Example/./dist/index.js',
'Example//dist/index.js'):
with self.subTest(name=name), warnings.catch_warnings():
warnings.simplefilter('ignore', UserWarning)
data, digest = archive(extra=(name, 'replacement'))
with self.assertRaisesRegex(ValueError, 'Unsafe|duplicate'):
frame_decky_store.archive_info(data, digest, 'Example')
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w') as bundle:
bundle.writestr('Example/plugin.json', '[]')
bundle.writestr('Example/dist/index.js', ' ' * 1200)
data = stream.getvalue()
with self.assertRaisesRegex(ValueError, 'manifest'):
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux')
class StoreTransaction(unittest.TestCase):
@@ -146,6 +172,15 @@ class StoreTransaction(unittest.TestCase):
self.assertEqual(installed.returncode, 0, installed.stderr)
self.assertEqual(self.apply('remove').returncode, 0)
def test_remote_apply_rejects_colliding_zip_paths(self):
for name in ('Example/dist/index.js', 'Example/./dist/index.js'):
with self.subTest(name=name), warnings.catch_warnings():
warnings.simplefilter('ignore', UserWarning)
data, _ = archive(extra=(name, 'replacement'))
result = self.apply('install', data)
self.assertNotEqual(result.returncode, 0)
self.assertFalse(self.folder.exists())
class StoreHost(unittest.TestCase):
def test_stopped_managed_service_allows_removing_owned_plugin(self):
+45
View File
@@ -0,0 +1,45 @@
"""Pinned official Decky bundles remain valid Framey panel inputs."""
import sandbox # noqa: F401
import base64
import hashlib
import json
from pathlib import Path
import re
import sys
import tempfile
import unittest
from unittest.mock import patch
import zipfile
import io
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_decky_store
import framey_panel
from framey_official import PLUGINS, archive
class OfficialBundles(unittest.TestCase):
def test_pinned_store_archives_install_and_pack_into_framey_panel(self):
with tempfile.TemporaryDirectory() as root:
plugins = Path(root)
for plugin in PLUGINS:
with self.subTest(plugin=plugin['name']):
data = archive(plugin)
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']),
plugin['folder'])
with zipfile.ZipFile(io.BytesIO(data)) as z:
manifest = json.loads(z.read(plugin['folder'] + '/plugin.json'))
self.assertEqual(manifest['api_version'], plugin['api_version'])
z.extractall(plugins)
folder = plugins / plugin['folder']
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
with patch.object(framey_panel, 'PLUGINS', plugins):
page = framey_panel.panel(plugin['id']).decode()
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
self.assertIsNotNone(encoded)
self.assertEqual(base64.b64decode(encoded.group(1)), (folder / 'dist/index.js').read_bytes())
if __name__ == '__main__':
unittest.main()
+12 -1
View File
@@ -20,6 +20,11 @@ import server
class Panel(unittest.TestCase):
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_vr_store_browsing_and_search(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_store.mjs')],
capture_output=True, text=True, timeout=10)
@@ -53,10 +58,16 @@ class Panel(unittest.TestCase):
with self.assertRaises(FileNotFoundError):
framey_panel.panel(15)
def test_legacy_root_symlink_and_unmanaged_are_refused(self):
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
manifest = self.folder/'plugin.json'
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':0}))
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':1}))
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
(self.folder/'dist/index.js').write_text(
'// __DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit\n'
"export default () => ({content: SP_REACT.createElement('p', null, 'OK')});")
self.assertIn('framey-bundle', framey_panel.panel(14).decode())
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':['root']}))
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
+12
View File
@@ -45,6 +45,18 @@ class SamplePlugin(unittest.TestCase):
with self.assertRaises(ValueError): asyncio.run(plugin.set_setting('enabled', 'yes'))
self.assertFalse((Path(folder) / 'settings.json').exists())
def test_corrupt_or_old_setting_uses_default_and_can_be_replaced(self):
with tempfile.TemporaryDirectory() as folder:
plugin = backend(folder)
path = Path(folder) / 'settings.json'
for data in ('broken JSON', '[]', '{"enabled": "yes"}'):
with self.subTest(data=data):
path.write_text(data)
self.assertFalse(asyncio.run(plugin.get_setting('enabled', False)))
self.assertTrue(asyncio.run(plugin.get_setting('enabled', True)))
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
if __name__ == '__main__':
unittest.main()
+30 -9
View File
@@ -59,8 +59,13 @@ def catalog():
'description': str(item.get('description') or '')[:300],
'author': author[:100],
'version': latest['name'][:40], 'sha256': latest['hash'],
'root': 'root' in tags})
return result
'root': 'root' in tags or '_root' in tags})
# A repeated store ID makes a clicked row ambiguous: change() resolves by ID
# and could install a different archive than the one the user reviewed.
counts = {}
for item in result:
counts[item['id']] = counts.get(item['id'], 0) + 1
return [item for item in result if counts[item['id']] == 1]
def archive_info(data, expected_hash, expected_name):
@@ -77,13 +82,20 @@ def archive_info(data, expected_hash, expected_name):
folder = manifests[0].split('/')[0]
if not SAFE_FOLDER.fullmatch(folder):
raise ValueError('Invalid plugin folder')
seen = set()
for entry in files:
name = entry.filename
mode = entry.external_attr >> 16
if ('\\' in name or name.startswith('/') or '..' in name.split('/') or
name.split('/')[0] != folder or entry.file_size > 30 * 1024 * 1024 or
mode & 0o170000 == 0o120000):
kind = mode & 0o170000
parts = name.split('/')
if entry.is_dir() and parts[-1] == '':
parts = parts[:-1]
if ('\\' in name or name.startswith('/') or
any(part in ('', '.', '..') for part in parts) or name in seen or
parts[0] != folder or entry.file_size > 30 * 1024 * 1024 or
kind not in (0, 0o040000, 0o100000)):
raise ValueError('Unsafe plugin archive member')
seen.add(name)
if entry.is_dir():
continue
with archive.open(entry) as source:
@@ -93,6 +105,8 @@ def archive_info(data, expected_hash, expected_name):
if header[:2] == b'MZ':
raise ValueError('Plugin contains a Windows binary: ' + name)
manifest = json.loads(archive.read(manifests[0]))
if not isinstance(manifest, dict):
raise ValueError('Invalid plugin manifest')
if manifest.get('name') != expected_name:
raise ValueError('Plugin name differs from the official store')
flags = manifest.get('flags', [])
@@ -142,10 +156,17 @@ try:
files=z.infolist()
if not files or len(files)>3000 or sum(f.file_size for f in files)>150*1024*1024:
raise RuntimeError('Plugin archive too large')
if any(f.filename.split('/')[0]!=folder or f.filename.startswith('/') or
'..' in f.filename.split('/') or '\\' in f.filename or
f.file_size>30*1024*1024 or f.external_attr>>16&0o170000==0o120000 for f in files):
raise RuntimeError('Unsafe plugin archive')
seen=set()
for f in files:
parts=f.filename.split('/')
if f.is_dir() and parts[-1]=='': parts=parts[:-1]
kind=f.external_attr>>16&0o170000
if (not parts or parts[0]!=folder or f.filename.startswith('/') or
any(part in ('','.','..') for part in parts) or '\\' in f.filename or
f.filename in seen or f.file_size>30*1024*1024 or
kind not in (0,0o040000,0o100000)):
raise RuntimeError('Unsafe plugin archive')
seen.add(f.filename)
stage.mkdir(mode=0o700)
z.extractall(stage)
unpacked=stage/folder
+81 -9
View File
@@ -21,6 +21,36 @@
let currentRoute = null;
let nextId = 0;
let plugin;
// A sandboxed iframe has an opaque origin, so its native localStorage getter
// throws SecurityError. Keep the familiar synchronous API inside the iframe;
// the parent stores only this plugin's bounded snapshot under its own origin.
const cache = new Map();
const cacheLimit = 128 * 1024;
const cacheSession = window.location?.hash.slice(1) || '';
let acceptSeed;
const seeded = new Promise(resolve => {acceptSeed = resolve;});
const storage = {
get length() {return cache.size;},
key(index) {return [...cache.keys()][index] ?? null;},
getItem(key) {return cache.get(String(key)) ?? null;},
setItem(key, value) {
const name = String(key), data = String(value);
const next = new Map(cache); next.set(name, data);
if (next.size > 64 || name.length > 256 || data.length > 65536 ||
JSON.stringify(Object.fromEntries(next)).length > cacheLimit)
throw new DOMException('Framey plugin cache is full', 'QuotaExceededError');
cache.set(name, data); persistCache();
},
removeItem(key) {cache.delete(String(key)); persistCache();},
clear() {cache.clear(); persistCache();},
};
function persistCache() {
parent.postMessage({type:'framey:cache:save', session:cacheSession,
entries:Object.fromEntries(cache)}, '*');
}
Object.defineProperty(window, 'localStorage', {configurable:true, value:storage});
parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*');
setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable.
function ask(method, args) {
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
@@ -32,6 +62,17 @@
});
}
addEventListener('message', event => {
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
event.data.session === cacheSession && acceptSeed) {
const entries = event.data.entries;
if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
for (const [key, value] of Object.entries(entries))
if (key.length <= 256 && typeof value === 'string' && value.length <= 65536)
cache.set(key, value);
}
acceptSeed(); acceptSeed = null;
return;
}
if (event.source !== parent || !event.data || event.data.type !== 'framey:reply') return;
const item = pending.get(event.data.id);
if (!item) return;
@@ -46,13 +87,42 @@
const passthrough = tag => ({children, ...props}) => R.createElement(tag, props, children);
const Field = ({label, description, children}) => R.createElement('label', null,
R.createElement('strong', null, label), description && R.createElement('small', null, description), children);
const ToggleField = ({label, description, checked, onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:'checkbox', checked:!!checked, onChange:e=>onChange?.(e.target.checked)}));
const DropdownItem = ({label, description, rgOptions=[], selectedOption, onChange}) => R.createElement(Field, {label, description},
R.createElement('select', {value:String(selectedOption ?? ''), onChange:e=>onChange?.(rgOptions.find(x=>String(x.data)===e.target.value))},
rgOptions.map(x=>R.createElement('option', {key:String(x.data), value:String(x.data)}, x.label))));
const ButtonItem = ({label, children, onClick}) => R.createElement(Field, {label},
R.createElement('button', {type:'button', onClick}, children || label));
const ToggleField = ({label, description, checked, disabled, onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:'checkbox', checked:!!checked, disabled:!!disabled,
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}));
const DropdownItem = ({label, description, rgOptions=[], selectedOption, disabled, strDefaultLabel, onChange}) => {
const choices=[];
const option = entry => {
const index=choices.push(entry)-1;
return R.createElement('option', {key:index, value:String(index)}, entry.label);
};
const entries=(Array.isArray(rgOptions) ? rgOptions : []).map(entry =>
Array.isArray(entry.options) ? R.createElement('optgroup', {key:String(entry.label), label:String(entry.label)},
entry.options.map(option)) : option(entry));
const selected=choices.findIndex(entry=>Object.is(entry.data, selectedOption));
return R.createElement(Field, {label, description}, R.createElement('select', {
value:selected < 0 ? '' : String(selected), disabled:!!disabled,
onChange:e=>{const entry=choices[Number(e.target.value)];if(!disabled && entry)onChange?.(entry);}},
selected < 0 && R.createElement('option', {value:'', disabled:true}, strDefaultLabel || 'Choose an option'),
entries));
};
const ButtonItem = ({label, description, children, disabled, onClick}) => R.createElement(Field, {label, description},
R.createElement('button', {type:'button', disabled:!!disabled,
onClick:e=>{if(!disabled)onClick?.(e);}}, children || label));
const DialogCheckbox = ({label, checked, defaultChecked, disabled, onChange}) =>
R.createElement('label', null,
R.createElement('input', {type:'checkbox', checked, defaultChecked, disabled:!!disabled,
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}), label);
const TextField = ({label, description, value, placeholder, disabled, bIsPassword,
mustBeNumeric, mustBeURL, mustBeEmail, onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:bIsPassword?'password':mustBeNumeric?'number':mustBeURL?'url':mustBeEmail?'email':'text',
value, placeholder, disabled:!!disabled, onChange:e=>{if(!disabled)onChange?.(e);}}));
const SliderField = ({label, description, value, min=0, max=100, step=1, disabled, showValue,
valueSuffix='', onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:'range', value, min, max, step, disabled:!!disabled,
'aria-label':typeof label==='string'?label:undefined,
onChange:e=>{if(!disabled)onChange?.(Number(e.target.value));}}),
showValue && R.createElement('span', null, String(value) + valueSuffix));
const PanelSection = ({title, children}) => R.createElement('section', null,
title && R.createElement('h2', null, title), children);
const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null,
@@ -80,9 +150,10 @@
};
const DFL = window.DFL = {
definePlugin: init => init,
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, ButtonItem,
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem,
Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField,
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
DialogButton: passthrough('button'), ConfirmModal,
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
staticClasses: {Title:'framey-title'},
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null),
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
@@ -171,6 +242,7 @@
};
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'}));
let module;
+8 -4
View File
@@ -17,7 +17,7 @@ class PanelError(ValueError):
def local_bundle(plugin_id):
"""Read only an API v2 bundle with a store receipt; reject linked paths."""
"""Read a modern Decky bundle with a store receipt; reject linked paths."""
if type(plugin_id) is not int or plugin_id <= 0 or not PLUGINS.is_dir() or PLUGINS.is_symlink():
raise FileNotFoundError('Framey plugin unavailable')
for folder in PLUGINS.iterdir():
@@ -43,11 +43,15 @@ def local_bundle(plugin_id):
if not isinstance(data, dict):
raise FileNotFoundError('Framey plugin manifest unavailable')
flags = data.get('flags', [])
if data.get('api_version') != 2 or not isinstance(flags, list) or any(x in flags for x in ('root', '_root')):
raise FileNotFoundError('This Decky frontend is not API v2 or needs root')
version = data.get('api_version')
if 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 not entry.is_file() or entry.stat().st_size > 8*1024*1024:
raise FileNotFoundError('Framey plugin frontend unavailable')
return entry.read_bytes()
bundle = entry.read_bytes()
if version == 1 and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle:
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
return bundle
except (OSError, ValueError):
raise FileNotFoundError('Framey plugin files are invalid')
raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store')
+29 -4
View File
@@ -103,7 +103,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;}}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=null, deckyBusy=false;
let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=null, frameySession=null, deckyBusy=false;
async function refreshDecky(){try{deckyState=await api('/api/decky');
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
@@ -186,17 +186,42 @@ async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.get
byId('deckyJobCheck').onclick=checkDeckyJob;
function openFrameyPanel(id,name){
if(!(deckyState?.plugins||[]).some(p=>p.storeId===id))return;
activeFrameyId=id;byId('frameyTitle').textContent=name;
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
byId('frameyPanelStatus').textContent='Loading plugin panel…';
byId('frameyFrame').src=`/framey/panel/${id}`;
byId('frameyFrame').src=`/framey/panel/${id}#${frameySession}`;
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
byId('frameyPanelStatus').textContent='No compatible API v2 panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
}
byId('frameyClose').onclick=()=>{activeFrameyId=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
byId('frameyClose').onclick=()=>{activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
function frameyCacheRead(id){
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
if(!data||typeof data!=='object'||Array.isArray(data))return {};
const pairs=Object.entries(data);
return pairs.length<=64&&pairs.every(([key,value])=>key.length<=256&&typeof value==='string'&&value.length<=65536)&&
JSON.stringify(data).length<=128*1024?data:{};
}catch(_){return {};}
}
function frameyCacheSave(id,entries){
if(!entries||typeof entries!=='object'||Array.isArray(entries))return;
const pairs=Object.entries(entries);
if(pairs.length>64||pairs.some(([key,value])=>key.length>256||typeof value!=='string'||value.length>65536))return;
const encoded=JSON.stringify(entries);
if(encoded.length>128*1024)return;
try{localStorage.setItem(frameyCacheKey(id),encoded);}catch(_){} // Private browsing can deny storage.
}
window.addEventListener('message',async event=>{
if(activeFrameyId===null||event.source!==byId('frameyFrame').contentWindow)return;
const req=event.data;if(!req||typeof req!=='object')return;
if(req.type==='framey:cache:init'&&req.session===frameySession){
event.source.postMessage({type:'framey:cache:seed',session:frameySession,
entries:frameyCacheRead(activeFrameyId)},'*');return;
}
if(req.type==='framey:cache:save'){
if(req.session===frameySession)frameyCacheSave(activeFrameyId,req.entries);
return;
}
if(req.type==='framey:ready'){byId('frameyPanelStatus').textContent='Plugin panel ready';
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}