mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 06:00:26 +01:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d6bd463ce | ||
|
|
981173a1f2 | ||
|
|
c6e54041e0 | ||
|
|
91f60c26e3 | ||
|
|
d77e2bd056 | ||
|
|
2a6736a02c | ||
|
|
c8ef81ad93 | ||
|
|
e529d60554 | ||
|
|
e9273beaa1 | ||
|
|
63820d1909 | ||
|
|
1aaa3c8c06 | ||
|
|
be18ed1a8b | ||
|
|
3f4aaf539c | ||
|
|
3acc2d91e6 | ||
|
|
0867debebd | ||
|
|
e400cb1dcf | ||
|
|
1701e622dc | ||
|
|
31edf6b737 | ||
|
|
005ad9b71d | ||
|
|
aaf2668882 | ||
|
|
cfb0310e9b | ||
|
|
493ab35635 | ||
|
|
6ee7bacc47 | ||
|
|
027bbfa701 | ||
|
|
34cd35056b |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.35",
|
||||
"version": "0.4.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.35",
|
||||
"version": "0.4.36",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.35",
|
||||
"version": "0.4.36",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# What we have tested
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.31 candidate source `1255ef2`, Frame OS 0.5.6 build `20261022.6153909`: [sanitized results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.36 tag `1701e62`, Frame OS 0.5.6 build `20261022.6153909`: [release smoke evidence](evidence/v0436-release-smoke-2026-10-25.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
@@ -23,7 +23,7 @@ Current for Frame Control **v0.4.35**, 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. 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) |
|
||||
| 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) |
|
||||
|
||||
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
@@ -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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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,24 @@
|
||||
# v0.4.36 release smoke on a real Frame
|
||||
|
||||
On 25 October 2026 at 22:59 UTC, the release branch ran
|
||||
`scripts/frame-smoke.sh` from a physical Linux host against a real Steam Frame.
|
||||
The script and its Python implementation were byte-identical to the
|
||||
`v0.4.36` tag `1701e622dc1f9888560412a058fe816b19ca7e79`.
|
||||
The headset reported Frame OS 0.5.6, build `20261022.6153909`.
|
||||
|
||||
The test acquired `/tmp/frame-test.lock` with a unique owner marker before
|
||||
reading device status or changing test titles. Its first read showed SteamVR
|
||||
activity level 3 (standby), proximity raw 6.3125, and battery 99%. The test
|
||||
ran only while that lock was owned. It released only its own marker and lock
|
||||
after the final cleanup.
|
||||
|
||||
All **13 of 13** smoke steps passed: cleanup of reserved leftovers, devkit
|
||||
service read, status read, install/launch/remove for reserved ARM64, x86_64
|
||||
and Windows test titles, and final cleanup. The test did not pair a key. The
|
||||
test output was saved locally under `/tmp/frame-v0436-smoke-results/`; this
|
||||
summary omits the headset identifier and private device status fields.
|
||||
|
||||
The headset was in standby. This test does not show wearer-visible game or
|
||||
Framey pixels, controller input, live Decky settings, or plugin actions.
|
||||
See [release v0.4.36](https://github.com/saphid/frame-control/releases/tag/v0.4.36)
|
||||
for the tagged installer and checksum checks.
|
||||
@@ -57,7 +57,9 @@ 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 |
|
||||
@@ -66,7 +68,7 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `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.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
|
||||
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
@@ -75,6 +77,9 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
Keep routes local to the panel and give every operation a visible result. A
|
||||
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
|
||||
Frame Control store; only paths registered by that same plugin can navigate.
|
||||
Optional route segments must be at the end. Framey accepts local path segments
|
||||
only; query strings, external URLs, Steam library routes and duplicate parameter
|
||||
names are rejected. Removing an active route returns to the plugin panel.
|
||||
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
|
||||
An API v1 manifest does not guarantee a working Framey panel. Test the exact
|
||||
store archive and do not assume SteamClient app details, library patches, or
|
||||
|
||||
@@ -24,7 +24,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.
|
||||
@@ -103,7 +106,10 @@ The store verifies each downloaded archive and the headset applies it with a
|
||||
rollback if Decky cannot start. The panel remembers the current job ID for this
|
||||
browser session, so **Check result** can resume polling after a navigation or
|
||||
temporary connection error. Refresh the installed list before starting another
|
||||
operation after an error. If Frame Control restarts, it cannot recover the old
|
||||
operation after an error. The headset panel and desktop app send the hash of
|
||||
the version you saw; if the official store changes that version before the job
|
||||
starts, Frame Control stops before downloading or changing the headset. Refresh
|
||||
the store and review the new version before retrying. If Frame Control restarts, it cannot recover the old
|
||||
job result. The panel clears that job ID and asks you to refresh the installed
|
||||
list before making another change.
|
||||
|
||||
|
||||
+4
-2
@@ -22,8 +22,10 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
the affected upload; it refuses ambiguous matches.
|
||||
The Linux build runs `scripts/check-appimage-launch.py` before upload. It
|
||||
starts the built x86_64 AppImage on an isolated Xvfb display, checks that
|
||||
its bundled server answers, and checks that the server stops on exit. This
|
||||
CI test does not test a physical display, FUSE mount or a headset.
|
||||
its bundled server answers, and checks that the server stops on exit. The
|
||||
check disables telemetry and update requests, and keeps extraction in its
|
||||
temporary profile. This CI test does not test a physical display, FUSE mount
|
||||
or a headset.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
@@ -13,6 +13,14 @@ browser cache. A missing, damaged, or old non-boolean value reads as the
|
||||
caller's default;
|
||||
the next save atomically replaces the sample's settings file.
|
||||
|
||||
The **About this setting** button opens a local detail page at
|
||||
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
|
||||
`Navigation.Navigate`, `useParams`, `NavigateBack`, and removal of the route in
|
||||
`onDismount`. The `:topic?` segment is optional. Framey keeps this navigation
|
||||
inside the plugin panel; it does not open a Steam library page. Put optional
|
||||
segments at the end of the path and do not use query strings. This detail page
|
||||
has not been checked with headset controllers.
|
||||
|
||||
## Build
|
||||
|
||||
From this directory, run:
|
||||
@@ -35,7 +43,7 @@ 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, including the quick action's scoped
|
||||
backend call. The fake Frame store flow is exercised by
|
||||
backend call and local route registration/navigation. The fake Frame store flow is exercised by
|
||||
the repository's e2e suite. No worn-headset visibility or controller-input test
|
||||
has passed for this sample yet.
|
||||
|
||||
|
||||
+26
-17
@@ -16,6 +16,7 @@ if (api._version != API_VERSION) {
|
||||
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);
|
||||
}
|
||||
const callable = api.callable;
|
||||
const routerHook = api.routerHook;
|
||||
const toaster = api.toaster;
|
||||
const definePlugin = (fn) => {
|
||||
return (...args) => {
|
||||
@@ -59,24 +60,32 @@ function Content() {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
|
||||
}
|
||||
var index = definePlugin(() => 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'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
function Details() {
|
||||
const { topic } = DFL.useParams();
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Sample detail page", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsxs("p", { children: ["Topic: ", topic || 'overview'] }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { children: "The toggle is stored by this plugin's Decky backend in the user's settings directory." }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.NavigateBack(), children: "Back to setting" }) })] });
|
||||
}
|
||||
var index = definePlugin(() => {
|
||||
routerHook.addRoute('/framey-sample/details/:topic?', Details);
|
||||
return Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
settings: SP_JSX.jsx(Content, {}),
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
export { index as default };
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
-1
@@ -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(() => 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
|
||||
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return <PanelSection title=\"Sample detail page\">\n <PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>\n <PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => {\n routerHook.addRoute('/framey-sample/details/:topic?', Details);\n return Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the mainLine truncated
|
||||
@@ -1,5 +1,5 @@
|
||||
import {callable, definePlugin, toaster} from '@decky/api';
|
||||
import {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';
|
||||
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
|
||||
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
|
||||
@@ -45,14 +45,27 @@ function Content() {
|
||||
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>
|
||||
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => Object.assign({
|
||||
function Details() {
|
||||
const {topic} = useParams<{topic?: string}>();
|
||||
return <PanelSection title="Sample detail page">
|
||||
<PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>
|
||||
<PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute('/framey-sample/details/:topic?', Details);
|
||||
return Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: <span aria-hidden="true">⚙</span>,
|
||||
content: <Content />,
|
||||
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
@@ -64,4 +77,5 @@ export default definePlugin(() => Object.assign({
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -45,13 +45,17 @@ def check(appimage):
|
||||
digest = hashlib.sha256(appimage.read_bytes()).hexdigest()
|
||||
with tempfile.TemporaryDirectory(prefix="frame-appimage-smoke-") as directory:
|
||||
profile = Path(directory)
|
||||
(profile / "tmp").mkdir()
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
APPIMAGE_EXTRACT_AND_RUN="1", ELECTRON_DISABLE_SANDBOX="1",
|
||||
TMPDIR=str(profile / "tmp"),
|
||||
XDG_CONFIG_HOME=str(profile / "config"),
|
||||
XDG_CACHE_HOME=str(profile / "cache"),
|
||||
XDG_DATA_HOME=str(profile / "data"),
|
||||
FRAME_ALIAS="frame-control-appimage-smoke-no-headset",
|
||||
FRAME_CONTROL_TELEMETRY="0", FRAME_CONTROL_NO_UPDATE_CHECK="1",
|
||||
DO_NOT_TRACK="1",
|
||||
)
|
||||
output = profile / "app.log"
|
||||
port = None
|
||||
|
||||
@@ -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}))
|
||||
@@ -142,8 +153,13 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
stale = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':999,'sha256':HASHES[0],
|
||||
'acknowledged':True}))
|
||||
self.assertIn('version changed', stale.get('error', ''))
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
|
||||
|
||||
def test_pinned_official_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:
|
||||
@@ -160,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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+2
@@ -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),
|
||||
|
||||
@@ -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'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}),
|
||||
@@ -111,3 +111,32 @@ 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);
|
||||
function GameRoute(){return React.createElement('p',null,window.DFL.useParams().appid);}
|
||||
const removeGame=api.routerHook.addRoute('/plugin/game/:appid/:view?',GameRoute);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
|
||||
assert.equal(window.DFL.useParams().appid,'123');
|
||||
assert.equal(window.DFL.useParams().view,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,1);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true);
|
||||
assert.equal(window.DFL.useParams().view,'art');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art/extra'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123?admin=1'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game//art'),false);
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute));
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
|
||||
removeGame();
|
||||
assert.equal(window.DFL.useParams().appid,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
|
||||
@@ -36,3 +36,9 @@ assert.equal(plugin.frameyQuickActions[0].label,'Read saved setting');
|
||||
assert.equal(await plugin.frameyQuickActions[0].onClick(),'Saved setting is on');
|
||||
assert.equal(calls[0].method,'get_setting');
|
||||
assert.deepEqual(Array.from(calls[0].args),['enabled',false]);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),true);
|
||||
assert.equal(window.DFL.useParams().topic,'saved-setting');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/library/home'),false);
|
||||
plugin.onDismount();
|
||||
assert.equal(window.DFL.useParams().topic,undefined);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),false);
|
||||
@@ -48,18 +48,19 @@ el('deckySearch').value='no matching plugin'; el('deckySearch').oninput();
|
||||
assert.equal(el('deckyStoreList').textContent,'No plugins match this search.');
|
||||
|
||||
// The page polls the actual job endpoint and keeps a separate visible result.
|
||||
ctx.mockApi=async path => {
|
||||
if(path==='/api/decky/plugin')return {job:'job-1'};
|
||||
ctx.mockApi=async (path,body) => {
|
||||
if(path==='/api/decky/plugin'){assert.equal(body.sha256,'a'.repeat(64));return {job:'job-1'};}
|
||||
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
|
||||
if(path==='/api/decky')return {running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2"})',ctx);
|
||||
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
|
||||
assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2');
|
||||
assert.equal(el('deckyJobProgress').value,100);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// A stopped Decky service must still offer recovery by removing its owned plugin.
|
||||
ctx.mockApi=async path => path==='/api/decky' ?
|
||||
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
|
||||
@@ -70,17 +71,39 @@ assert.equal(el('deckyInstalled').children[0].children[2].disabled,false);
|
||||
el('deckySearch').value='';el('deckyStoreFilter').value='all';
|
||||
vm.runInContext('showDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
|
||||
ctx.mockApi=async path => {
|
||||
if(path==='/api/decky/plugin')return {job:'job-2'};
|
||||
ctx.mockApi=async (path,body) => {
|
||||
if(path==='/api/decky/plugin'){assert.equal(body.sha256,'b'.repeat(64));return {job:'job-2'};}
|
||||
if(path.startsWith('/api/job'))return {done:true,error:'archive digest mismatch'};
|
||||
if(path==='/api/decky')return {running:true,plugins:[]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2"})',ctx);
|
||||
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"b".repeat(64)})',ctx);
|
||||
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// A failed refresh must remove old install buttons. A late older response
|
||||
// must not replace the newer catalog the wearer just requested.
|
||||
ctx.mockApi=async path => {assert.equal(path,'/api/decky/store');throw Error('offline');};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('loadDeckyStore()',ctx);
|
||||
assert.match(el('deckyStoreSummary').textContent,/Store unavailable: offline/);
|
||||
assert.equal(el('deckyStoreList').children.length,0);
|
||||
let finishOld, finishNew;
|
||||
ctx.mockApi=path => {assert.equal(path,'/api/decky/store');
|
||||
return new Promise(resolve=>{if(!finishOld)finishOld=resolve;else finishNew=resolve;});};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
const old=vm.runInContext('loadDeckyStore()',ctx);
|
||||
const current=vm.runInContext('loadDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children.length,0);
|
||||
assert.equal(el('deckyStoreSummary').textContent,'Loading official Decky store…');
|
||||
finishNew({plugins:[{id:98,name:'Current',version:'2.0',description:'Latest'}]});
|
||||
await current;
|
||||
finishOld({plugins:[{id:97,name:'Stale',version:'1.0',description:'Older'}]});
|
||||
await old;
|
||||
assert.match(el('deckyStoreList').children[0].children[0].textContent,/Current/);
|
||||
assert.doesNotMatch(el('deckyStoreList').children[0].children[0].textContent,/Stale/);
|
||||
|
||||
// A saved job does not survive a server restart. Clear it instead of offering
|
||||
// Check result forever for an ID that the new server cannot know.
|
||||
session.set('frameyPluginJob',JSON.stringify({job:'old-job',action:'install',name:'Plugin 2'}));
|
||||
|
||||
@@ -183,6 +183,18 @@ class StoreTransaction(unittest.TestCase):
|
||||
|
||||
|
||||
class StoreHost(unittest.TestCase):
|
||||
def test_reviewed_archive_changed_before_install_does_not_download(self):
|
||||
state = {'managed': True, 'running': True, 'plugins': []}
|
||||
catalog = [{'id': 42, 'name': 'Example', 'root': False,
|
||||
'sha256': 'b' * 64, 'version': '2.0'}]
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
|
||||
patch.object(frame_decky_store.urllib.request, 'urlopen',
|
||||
side_effect=AssertionError('No download expected')):
|
||||
with self.assertRaisesRegex(ValueError, 'version changed'):
|
||||
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
|
||||
'install', 42, reviewed_hash='a' * 64)
|
||||
|
||||
def test_stopped_managed_service_allows_removing_owned_plugin(self):
|
||||
state = {'managed': True, 'running': False, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
|
||||
@@ -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']}))
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -193,9 +193,12 @@ finally:
|
||||
'''
|
||||
|
||||
|
||||
def change(ssh, action, plugin_id, report=lambda stage, percent=None: None):
|
||||
def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, reviewed_hash=None):
|
||||
if action not in ('install', 'update', 'remove') or type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise ValueError('Choose a valid store plugin action')
|
||||
if reviewed_hash is not None and (action == 'remove' or not isinstance(reviewed_hash, str)
|
||||
or not SHA.fullmatch(reviewed_hash)):
|
||||
raise ValueError('Invalid reviewed store version')
|
||||
report('Checking installed plugins', 5)
|
||||
state = frame_decky.status(ssh)
|
||||
if not state['managed']:
|
||||
@@ -215,6 +218,8 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None):
|
||||
plugin = next((item for item in catalog() if item['id'] == plugin_id), None)
|
||||
if not plugin:
|
||||
raise ValueError('Plugin is not in the official Decky store')
|
||||
if reviewed_hash is not None and plugin['sha256'] != reviewed_hash:
|
||||
raise ValueError('The official store version changed; refresh the store and review it again')
|
||||
if plugin['root']:
|
||||
raise ValueError('Root plugins are not supported by the Frame user service')
|
||||
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']]
|
||||
|
||||
+60
-11
@@ -26,6 +26,7 @@
|
||||
const routes = new Map();
|
||||
const routeListeners = new Set();
|
||||
let currentRoute = null;
|
||||
let currentParams = {};
|
||||
let nextId = 0;
|
||||
let plugin;
|
||||
// A sandboxed iframe has an opaque origin, so its native localStorage getter
|
||||
@@ -59,13 +60,13 @@
|
||||
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 => {
|
||||
@@ -137,21 +138,54 @@
|
||||
R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close'));
|
||||
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
|
||||
const closeModal = () => modalRoot.render(null);
|
||||
const localPath = path => typeof path === 'string' && path.length <= 120 &&
|
||||
/^\/[A-Za-z0-9/_-]+$/.test(path) && !path.includes('//');
|
||||
const routePattern = path => typeof path === 'string' && path.length <= 120 &&
|
||||
/^\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??)(?:\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??))*$/.test(path) &&
|
||||
!/\?\//.test(path) &&
|
||||
new Set([...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].map(match => match[1])).size ===
|
||||
[...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].length;
|
||||
function matchRoute(pattern, path) {
|
||||
const expected = pattern.slice(1).split('/');
|
||||
const actual = path.slice(1).split('/');
|
||||
if (actual.length > expected.length) return null;
|
||||
const params = {};
|
||||
for (let index = 0; index < expected.length; index++) {
|
||||
const segment = expected[index], value = actual[index];
|
||||
if (segment[0] === ':') {
|
||||
const optional = segment.endsWith('?'), name = segment.slice(1, optional ? -1 : undefined);
|
||||
if (value === undefined) { if (!optional) return null; }
|
||||
else params[name] = value;
|
||||
} else if (segment !== value) return null;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
function activeRoute(path) {
|
||||
if (routes.has(path)) return {component:routes.get(path), params:{}};
|
||||
for (const [pattern, component] of routes) {
|
||||
const params = matchRoute(pattern, path);
|
||||
if (params) return {component, params};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function navigate(path) {
|
||||
if (path !== null && (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || !routes.has(path))) return false;
|
||||
if (path !== null && !localPath(path)) return false;
|
||||
const match = path === null ? null : activeRoute(path);
|
||||
if (path !== null && !match) return false;
|
||||
currentRoute = path;
|
||||
currentParams = match?.params || {};
|
||||
for (const listener of routeListeners) listener(path);
|
||||
return true;
|
||||
}
|
||||
const routerHook = {
|
||||
addRoute(path, component) {
|
||||
if (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) ||
|
||||
if (!routePattern(path) ||
|
||||
(typeof component !== 'function' && !R.isValidElement(component)))
|
||||
throw Error('Framey route needs a local path and React component');
|
||||
routes.set(path, component);
|
||||
return () => {routes.delete(path); if(currentRoute === path) navigate(null);};
|
||||
return () => routerHook.removeRoute(path);
|
||||
},
|
||||
removeRoute(path) {routes.delete(path); if(currentRoute === path) navigate(null);},
|
||||
removeRoute(path) {routes.delete(path); if(currentRoute && !activeRoute(currentRoute)) navigate(null);},
|
||||
// Steam routes and global components have no equivalent in the isolated panel.
|
||||
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
|
||||
};
|
||||
@@ -170,7 +204,7 @@
|
||||
afterPatch: () => () => {}, createReactTreePatcher: () => () => {},
|
||||
findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null,
|
||||
appDetailsClasses: {}, appDetailsHeaderClasses: {},
|
||||
useParams: () => ({}),
|
||||
useParams: () => currentParams,
|
||||
};
|
||||
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
|
||||
const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => {
|
||||
@@ -228,8 +262,8 @@
|
||||
...actions.map((action, index) => R.createElement('button', {key:index, type:'button',
|
||||
disabled:running !== null, onClick:()=>runAction(action,index)}, action.label))),
|
||||
message && R.createElement('p', {role:'status'}, message),
|
||||
R.createElement('div', null, route ? (typeof routes.get(route) === 'function' ?
|
||||
R.createElement(routes.get(route)) : routes.get(route)) :
|
||||
R.createElement('div', null, route ? (typeof activeRoute(route)?.component === 'function' ?
|
||||
R.createElement(activeRoute(route).component) : activeRoute(route)?.component) :
|
||||
(view === 'settings' ? plugin.settings : plugin.content)));
|
||||
}
|
||||
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
|
||||
@@ -247,15 +281,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})));
|
||||
|
||||
+14
-6
@@ -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))
|
||||
+13
-7
@@ -103,7 +103,8 @@ 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, frameySession=null, deckyBusy=false;
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
|
||||
activeFrameyId=null, frameySession=null, deckyBusy=false;
|
||||
async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
|
||||
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
|
||||
@@ -117,10 +118,14 @@ async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
button.disabled=deckyBusy;button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
|
||||
list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.';
|
||||
showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}}
|
||||
async function loadDeckyStore(){byId('deckyStorePanel').hidden=false;
|
||||
async function loadDeckyStore(){const request=++deckyStoreRequest;byId('deckyStorePanel').hidden=false;
|
||||
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
|
||||
byId('deckyStoreSummary').textContent='Loading official Decky store…';
|
||||
try{const data=await api('/api/decky/store');deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
|
||||
catch(error){byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
|
||||
try{const data=await api('/api/decky/store');if(request!==deckyStoreRequest)return;
|
||||
deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
|
||||
catch(error){if(request!==deckyStoreRequest)return;
|
||||
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
|
||||
byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
|
||||
function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren();
|
||||
const q=byId('deckySearch').value.trim().toLowerCase();
|
||||
const filter=byId('deckyStoreFilter').value;
|
||||
@@ -169,7 +174,8 @@ async function followDeckyJob(job,action,name){
|
||||
async function deckyChange(action,plugin){if(deckyBusy||!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
|
||||
deckyBusy=true;showDeckyStore();
|
||||
deckyJobView(`${action} ${plugin.name}`,'Starting…',0);
|
||||
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,acknowledged:true});
|
||||
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
|
||||
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
|
||||
if(!started.job)throw Error('The server did not return a job ID');
|
||||
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
|
||||
await followDeckyJob(started.job,action,plugin.name);
|
||||
@@ -191,7 +197,7 @@ function openFrameyPanel(id,name){
|
||||
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('frameyPanelStatus').textContent='No compatible panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
@@ -228,7 +234,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,'*');
|
||||
});
|
||||
|
||||
+2
-1
@@ -2244,7 +2244,8 @@ function showDeckyStore() {
|
||||
}
|
||||
async function changeDeckyPlugin(action, plugin) {
|
||||
if (!$('deckyAck').checked || !confirm(`${action} ${plugin.name}? This official store plugin runs code as your Frame user.`)) return;
|
||||
try { await runJob(`${action} ${plugin.name}`, 'decky-plugin', () => api('/api/decky/plugin', {action,id:plugin.id,acknowledged:true}));
|
||||
try { await runJob(`${action} ${plugin.name}`, 'decky-plugin', () => api('/api/decky/plugin',
|
||||
{action,id:plugin.id,sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true}));
|
||||
await loadDecky(); showDeckyStore(); } catch(error) { $('deckyStoreState').textContent=error.message; }
|
||||
}
|
||||
function deckyButtons() {
|
||||
|
||||
+4
-2
@@ -571,15 +571,17 @@ def decky_plugin(body):
|
||||
raise Failure('Choose an official store plugin and action', 400)
|
||||
if body.get('acknowledged') is not True:
|
||||
raise Failure('Confirm that store plugins run code as your Frame user', 400)
|
||||
reviewed_hash = body.get('sha256')
|
||||
return start_job('Decky plugin ' + action,
|
||||
lambda report: frame_decky_store.change(ssh, action, plugin_id, report), progress=True)
|
||||
lambda report: frame_decky_store.change(ssh, action, plugin_id, report,
|
||||
reviewed_hash=reviewed_hash), progress=True)
|
||||
|
||||
|
||||
def framey_method_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'))
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'), body.get('legacy', False))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
Reference in new issue
Block a user