mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 13:01:47 +01:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1356ea4df1 | ||
|
|
c79ba7571f | ||
|
|
76dcd0c426 | ||
|
|
f668a10982 | ||
|
|
29f2f57bc0 | ||
|
|
a257ec0bf9 | ||
|
|
99504554e0 | ||
|
|
b53293eddd | ||
|
|
229f17696e | ||
|
|
f6a88f1a51 | ||
|
|
b7caa7d7d3 | ||
|
|
a61dfdd3ab | ||
|
|
840f53f032 | ||
|
|
5932b6a8a4 | ||
|
|
3f2e32db87 | ||
|
|
a6a1e0b258 | ||
|
|
db74e3be96 | ||
|
|
faf7bc6224 | ||
|
|
289a971c90 | ||
|
|
f2f6085887 | ||
|
|
d1541099b8 | ||
|
|
0440959834 | ||
|
|
482d031a18 | ||
|
|
20a5ced63d | ||
|
|
02fe664583 | ||
|
|
e2ffcd34f3 | ||
|
|
2002b9d70e | ||
|
|
9b86264813 | ||
|
|
ba97a67580 |
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.34",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.34",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.31",
|
||||
"version": "0.4.34",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.31**, 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.34**, 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.31**, 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 | 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: [PR #208](https://github.com/saphid/frame-control/pull/208), [compatibility table](decky-frame.md) |
|
||||
| Experimental Framey VR plugin panels | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium; the fake Frame exercised the store and method bridge. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, [PR #223](https://github.com/saphid/frame-control/pull/223), [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. 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 and install/remove two pinned official API v2 archives. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set; a hash match 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.34: [#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), [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.
|
||||
+28
-3
@@ -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,9 +78,11 @@ 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, but plugin pages, settings, routes,
|
||||
and Decky toasts are **not** available there yet. The panel itself still needs
|
||||
a worn-headset visual and controller check.
|
||||
shows installed plugins and store actions. Its separate Framey iframe now hosts
|
||||
compatible API v2 plugin pages, settings, local routes, and panel toasts.
|
||||
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).
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
@@ -163,6 +186,8 @@ It preserves plugin files and settings so unrelated user data is not deleted.
|
||||
Open **Official plugin store** to review a plugin, then confirm each install,
|
||||
update, or remove. Root and x86-only plugins are blocked. The manager only
|
||||
removes plugin folders that it installed; it leaves other Decky folders alone.
|
||||
The desktop app and headset panel share one lock for managed Decky changes.
|
||||
If another change is in progress, wait for it to finish and try again.
|
||||
Restart Steam or the headset to clear the frontend already injected into its
|
||||
current web helper process. No action wipes the headset.
|
||||
|
||||
|
||||
@@ -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,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 |
@@ -32,8 +32,42 @@ 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
|
||||
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 hosts a plugin's frontend in an isolated iframe in the Frame Control
|
||||
SteamVR panel. The table describes the current implementation and its tests;
|
||||
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 |
|
||||
| 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 |
|
||||
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack` | Local routes inside this plugin panel only | Host unit test |
|
||||
| `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 |
|
||||
| `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
|
||||
Keep routes local to the panel and give every operation a visible result. A
|
||||
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
|
||||
Frame Control store; only paths registered by that same plugin can navigate.
|
||||
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
|
||||
+56
-7
@@ -19,7 +19,8 @@ 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
|
||||
@@ -37,12 +38,13 @@ 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`.
|
||||
|
||||
The host currently supplies sections, rows, buttons, toggles, dropdowns,
|
||||
scroll containers, a modal, locale information, and backend calls for the
|
||||
plugin that owns the panel. Steam library/store page patches, global Steam UI
|
||||
components, Decky route hooks, file pickers, and privileged root methods have
|
||||
no VR target. A plugin that depends on them can render only part of its UI or
|
||||
show an incompatibility message. A Decky plugin's Python backend still runs as
|
||||
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
|
||||
have no VR target. A plugin that depends on them can render only part of its UI
|
||||
or show an incompatibility message. A Decky plugin's Python backend still runs as
|
||||
the headset user when Decky loads it, so review third-party code before opting
|
||||
in. The panel iframe can make HTTPS requests subject to ordinary browser CORS.
|
||||
|
||||
@@ -53,9 +55,45 @@ 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 opaque iframe. No real Decky backend
|
||||
or worn headset test was part of that check.
|
||||
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4
|
||||
and Steam Status 1.2.3 archives, checks their API 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.
|
||||
|
||||
The fake Frame integration test now writes a Decky setting through Framey's
|
||||
scoped backend endpoint, reads it in a new CEF request, updates the plugin,
|
||||
then checks it again. It also confirms removal blocks method calls and a
|
||||
reinstall can read the retained setting. This tests the bridge and fixture;
|
||||
real Decky settings persistence on a worn Frame is still unverified.
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
Install, Update, and Remove show the current job stage in the headset panel.
|
||||
The store verifies each downloaded archive and the headset applies it with a
|
||||
rollback if Decky cannot start. The panel remembers the current job ID for this
|
||||
browser session, so **Check result** can resume polling after a navigation or
|
||||
temporary connection error. Refresh the installed list before starting another
|
||||
operation after an error. 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.
|
||||
|
||||
## For plugin authors
|
||||
|
||||
Start with the [Framey settings sample](../examples/framey-settings/README.md)
|
||||
for a buildable Decky API v2 panel and Python settings backend.
|
||||
|
||||
Publish a normal [Decky store](https://plugins.deckbrew.xyz/) API v2 release
|
||||
with `plugin.json`, a single `dist/index.js` ESM frontend entry point, and a
|
||||
Python backend if needed. Give the frontend a `content` React element returned
|
||||
@@ -69,6 +107,17 @@ about Steam Deck hardware. Declare settings defaults so a fresh user service
|
||||
can render without pre-existing files. Test your ZIP with the store validator
|
||||
and the fake Frame flow before asking users to install it.
|
||||
|
||||
An API v2 plugin's normal `content` is its default VR panel. Framey also shows
|
||||
an optional `settings` React element under a **Settings** tab and an optional
|
||||
`titleView` above it. For one-tap headset controls, return
|
||||
`frameyQuickActions: [{label: 'Refresh data', onClick: refreshData}]` from the
|
||||
plugin initializer. Framey displays up to eight actions and reports a pending,
|
||||
success, or failure state. These descriptor fields are Framey additions; Decky
|
||||
ignores them. Use a plugin backend method through `@decky/api` for work needing
|
||||
persistent state, and keep a quick action safe to repeat.
|
||||
See the [API v2 compatibility table](frame-plugin-author.md#framey-api-v2-compatibility)
|
||||
for tested behavior and remaining limits.
|
||||
|
||||
The [Frame Control extension API](extensions.md) is an alternative for a
|
||||
Frame-specific panel. It needs only a local manifest, HTML, and the read-only
|
||||
`status` bridge; it does not need Decky.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,39 @@
|
||||
# Framey settings sample
|
||||
|
||||
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. 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
|
||||
|
||||
From this directory, run:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
pnpm exec tsc --noEmit
|
||||
```
|
||||
|
||||
The committed `dist/index.js` is the built frontend. For a Decky release ZIP,
|
||||
put `plugin.json`, `main.py`, and `dist/index.js` inside one top-level plugin
|
||||
folder. Publish it through the official Decky store if you want Frame Control's
|
||||
managed installer to offer it. Framey deliberately opens only plugins that the
|
||||
official store installed and marked as owned; copying this example into the
|
||||
plugin directory does not enable its VR panel.
|
||||
|
||||
## Test and adapt
|
||||
|
||||
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
|
||||
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 explicit migrations for every supported schema,
|
||||
explain how users can reset settings, and test every UI path in a worn headset.
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
const manifest = {"name":"Framey Settings Sample"};
|
||||
const API_VERSION = 2;
|
||||
const internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;
|
||||
if (!internalAPIConnection) {
|
||||
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.');
|
||||
}
|
||||
let api;
|
||||
try {
|
||||
api = internalAPIConnection.connect(API_VERSION, manifest.name);
|
||||
}
|
||||
catch {
|
||||
api = internalAPIConnection.connect(1, manifest.name);
|
||||
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);
|
||||
}
|
||||
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 toaster = api.toaster;
|
||||
const definePlugin = (fn) => {
|
||||
return (...args) => {
|
||||
return fn(...args);
|
||||
};
|
||||
};
|
||||
|
||||
const getSetting = callable('get_setting');
|
||||
const setSetting = callable('set_setting');
|
||||
function Content() {
|
||||
const [enabled, setEnabled] = SP_REACT.useState(false);
|
||||
const [busy, setBusy] = SP_REACT.useState(true);
|
||||
const [message, setMessage] = SP_REACT.useState('Loading setting…');
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const value = await getSetting('enabled', false);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
}
|
||||
catch (error) {
|
||||
setMessage(`Could not read setting: ${String(error)}`);
|
||||
}
|
||||
finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
SP_REACT.useEffect(() => { void refresh(); }, []);
|
||||
async function change(value) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSetting('enabled', value);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
toaster.toast({ title: 'Framey sample', body: 'Setting saved' });
|
||||
}
|
||||
catch (error) {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
}
|
||||
finally {
|
||||
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 }) })] });
|
||||
}
|
||||
var index = definePlugin(() => ({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
}));
|
||||
|
||||
export { index as default };
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
@@ -0,0 +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
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Small, unprivileged Decky backend for the Framey author example."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import decky
|
||||
|
||||
|
||||
def settings_file():
|
||||
# Decky provides this per-plugin path through its official Python module.
|
||||
return Path(decky.decky_SETTINGS_DIR) / 'settings.json'
|
||||
|
||||
|
||||
class Plugin:
|
||||
async def get_setting(self, key: str, default=False):
|
||||
if key != 'enabled':
|
||||
raise ValueError('Unknown setting')
|
||||
try:
|
||||
stored = json.loads(settings_file().read_text(encoding='utf-8'))
|
||||
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return 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:
|
||||
raise ValueError('Expected an enabled boolean')
|
||||
target = settings_file()
|
||||
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=target.parent,
|
||||
prefix='.settings-', delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
json.dump({'enabled': value}, output)
|
||||
output.write('\n')
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return True
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "framey-settings-sample",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rollup -c"
|
||||
},
|
||||
"dependencies": {
|
||||
"@decky/api": "^1.1.3",
|
||||
"@decky/ui": "^4.11.0",
|
||||
"react": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@decky/rollup": "^1.0.2",
|
||||
"@types/react": "^19.1.1",
|
||||
"rollup": "^4.53.3",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Framey Settings Sample",
|
||||
"author": "Frame Control contributors",
|
||||
"flags": [],
|
||||
"api_version": 2
|
||||
}
|
||||
Generated
+1239
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
import deckyPlugin from '@decky/rollup';
|
||||
|
||||
export default deckyPlugin({});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {callable, definePlugin, toaster} from '@decky/api';
|
||||
import {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
|
||||
const setSetting = callable<[key: string, value: boolean], boolean>('set_setting');
|
||||
|
||||
function Content() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [message, setMessage] = useState('Loading setting…');
|
||||
|
||||
async function refresh() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const value = await getSetting('enabled', false);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
} catch (error) {
|
||||
setMessage(`Could not read setting: ${String(error)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {void refresh();}, []);
|
||||
|
||||
async function change(value: boolean) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSetting('enabled', value);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
toaster.toast({title: 'Framey sample', body: 'Setting saved'});
|
||||
} catch (error) {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <PanelSection title="Framey settings sample">
|
||||
<PanelSectionRow>
|
||||
<ToggleField label="Example setting" description="Stored by the Decky Python backend"
|
||||
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
|
||||
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => ({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: <span aria-hidden="true">⚙</span>,
|
||||
content: <Content />,
|
||||
}));
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
+57
-10
@@ -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,29 +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':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':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':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':999,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
|
||||
def test_two_pinned_official_api_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'], 2)
|
||||
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']))
|
||||
@@ -30,8 +30,14 @@ function build(steam) {
|
||||
const deckyBackend = {async call(route, plugin, method, ...args) {
|
||||
if (route !== 'loader/call_plugin_method' || plugin !== 'Sample Panel')
|
||||
throw Error('unexpected Decky plugin route');
|
||||
if (method === 'get_setting') return args[1];
|
||||
if (method === 'set_setting') return true;
|
||||
const settings = (steam.decky_settings ||= {});
|
||||
const owned = (settings[plugin] ||= {});
|
||||
if (method === 'get_setting')
|
||||
return Object.hasOwn(owned, args[0]) ? owned[args[0]] : args[1];
|
||||
if (method === 'set_setting') {
|
||||
owned[args[0]] = args[1];
|
||||
return true;
|
||||
}
|
||||
throw Error('method unavailable in fake Decky backend');
|
||||
}};
|
||||
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+18
@@ -0,0 +1,18 @@
|
||||
# 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` |
|
||||
|
||||
The matching source projects are
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky) and
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky).
|
||||
The test verifies the ZIP digest, archive policy, API 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.
|
||||
@@ -0,0 +1,15 @@
|
||||
"""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',
|
||||
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
|
||||
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3',
|
||||
'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'},
|
||||
)
|
||||
|
||||
|
||||
def archive(plugin):
|
||||
return (ROOT / f"{plugin['id']}.zip").read_bytes()
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
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})();');
|
||||
assert.notEqual(testSource,source);
|
||||
const states=[];
|
||||
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
useState:value=>[value,next=>states.push(next)], useEffect(){},isValidElement:value=>!!value?.type};
|
||||
class Element {constructor(){this.children=[];this.textContent='';this.attributes={};}
|
||||
append(...children){this.children.push(...children);}setAttribute(key,value){this.attributes[key]=value;}
|
||||
remove(){this.removed=true;}}
|
||||
const elements=new Map();
|
||||
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
|
||||
const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)};
|
||||
const context=vm.createContext({window,document:{getElementById:id=>{
|
||||
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()},
|
||||
navigator:{language:'en'}, parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(testSource,context);
|
||||
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
|
||||
const jsx=window.SP_JSX.jsx('p',{id:'message',children:'Saved'},'message-key');
|
||||
assert.equal(jsx.type,'p');
|
||||
assert.equal(jsx.props.id,'message');
|
||||
assert.equal(jsx.props.key,'message-key');
|
||||
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)=>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];
|
||||
for(const control of [toggle,dropdown,button,input,slider])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'}});
|
||||
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';}},
|
||||
{label:'invalid'},...Array.from({length:9},(_,i)=>({label:`Action ${i}`,onClick:()=>{}}))]};
|
||||
const tree=window.__surface({plugin});
|
||||
const buttons=nodes(tree,'button');
|
||||
assert.equal(buttons.length,9); // Panel, Settings, and seven valid actions inside the first eight entries.
|
||||
assert.equal(nodes(tree,'nav')[0].props['aria-label'],'Plugin views');
|
||||
assert.equal(nodes(tree,'section')[0].props['aria-label'],'Plugin quick actions');
|
||||
assert.equal(nodes(tree,'header').length,1);
|
||||
await buttons[2].props.onClick();
|
||||
assert.equal(called,1);
|
||||
assert.deepEqual(states.slice(-3),[0,'Refresh data…','Fresh',null].slice(-3));
|
||||
assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav').length,1);
|
||||
|
||||
// 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'}});
|
||||
assert.equal(await reply,'dark');
|
||||
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
|
||||
|
||||
const toast=api.toaster.toast({title:'Saved',body:'Mode updated',duration:4000});
|
||||
const toastNode=elements.get('framey-toasts').children[0];
|
||||
assert.equal(toastNode.attributes.role,'status');
|
||||
assert.equal(toastNode.children[0].textContent,'Saved');
|
||||
assert.equal(toastNode.children[1].textContent,'Mode updated');
|
||||
toast.dismiss();assert.equal(toastNode.removed,true);
|
||||
|
||||
function LocalRoute(){return React.createElement('p',null,'Route content');}
|
||||
const undo=api.routerHook.addRoute('/plugin/details',LocalRoute);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),true);
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/decky/store'),false);
|
||||
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const host=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8')
|
||||
.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
|
||||
const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js',import.meta.url),'utf8');
|
||||
assert.match(bundle,/SP_JSX\.jsx/);
|
||||
const React={Fragment:Symbol('fragment'),Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
|
||||
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
|
||||
const context=vm.createContext({window,document:{getElementById:()=>({})},
|
||||
navigator:{language:'en'},parent:{postMessage(){}},addEventListener(){},
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(host,context);
|
||||
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
|
||||
vm.runInContext(bundle.replace(/export \{ index as default \};/, 'window.sample = index;'),context);
|
||||
const plugin=window.sample();
|
||||
assert.equal(plugin.name,'Framey Settings Sample');
|
||||
assert.equal(typeof plugin.content.type,'function');
|
||||
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);
|
||||
@@ -0,0 +1,96 @@
|
||||
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 script = html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
|
||||
class Element {
|
||||
constructor(id='') {this.id=id; this.value=''; this.textContent=''; this.children=[]; this.disabled=false; this.hidden=false;}
|
||||
append(...nodes) {this.children.push(...nodes);}
|
||||
replaceChildren(...nodes) {this.children=nodes; this.textContent='';}
|
||||
scrollIntoView() {}
|
||||
removeAttribute(name) {delete this[name];}
|
||||
}
|
||||
const elements = new Map();
|
||||
elements.set('deckyStoreFilter',Object.assign(new Element('deckyStoreFilter'),{value:'all'}));
|
||||
const document = {getElementById(id) {if(!elements.has(id)) elements.set(id,new Element(id)); return elements.get(id);},
|
||||
createElement() {return new Element();}, hidden:false};
|
||||
const session = new Map();
|
||||
const ctx = vm.createContext({document, location:{hash:'',pathname:'/'},
|
||||
sessionStorage:{getItem:key=>session.get(key)||'',setItem:(key,value)=>session.set(key,value),removeItem:key=>session.delete(key)},
|
||||
history:{replaceState(){}}, confirm:()=>true,
|
||||
window:{addEventListener(){}}, setInterval(){}, setTimeout(){},
|
||||
fetch(){throw Error('No network expected');}, console});
|
||||
vm.runInContext(script,ctx);
|
||||
const el=id=>document.getElementById(id);
|
||||
const fixtures=Array.from({length:43},(_,i)=>({id:i+1,name:`Plugin ${i+1}`,author:i===42?'Special Author':'Maker',
|
||||
description:'A panel',version:'1.0',root:i===0}));
|
||||
ctx.fixtures=fixtures;
|
||||
vm.runInContext('deckyStore=fixtures; deckyState={running:true,plugins:[{storeId:2,version:"1.0"}]}; showDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreSummary').textContent,'43 plugins found');
|
||||
assert.equal(el('deckyStoreList').children.length,20);
|
||||
assert.equal(el('deckyStorePage').textContent,'Page 1 of 3');
|
||||
el('deckyStoreNext').onclick();
|
||||
assert.equal(el('deckyStoreList').children.length,20);
|
||||
el('deckyStoreNext').onclick();
|
||||
assert.equal(el('deckyStoreList').children.length,3);
|
||||
assert.equal(el('deckyStoreNext').disabled,true);
|
||||
el('deckySearch').value='special author'; el('deckySearch').oninput();
|
||||
assert.equal(el('deckyStoreSummary').textContent,'1 plugin found');
|
||||
assert.match(el('deckyStoreList').children[0].children[0].textContent,/Plugin 43/);
|
||||
assert.equal(el('deckyStorePage').textContent,'Page 1 of 1');
|
||||
el('deckySearch').value=''; el('deckyStoreFilter').value='installed'; el('deckyStoreFilter').onchange();
|
||||
assert.equal(el('deckyStoreSummary').textContent,'1 plugin found');
|
||||
assert.match(el('deckyStoreList').children[0].children[0].textContent,/Plugin 2/);
|
||||
el('deckyStoreFilter').value='compatible'; el('deckyStoreFilter').onchange();
|
||||
assert.equal(el('deckyStoreSummary').textContent,'42 plugins found');
|
||||
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'};
|
||||
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);
|
||||
assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2');
|
||||
assert.equal(el('deckyJobProgress').value,100);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
// A stopped Decky service must still offer recovery by removing its owned plugin.
|
||||
ctx.mockApi=async path => path==='/api/decky' ?
|
||||
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
|
||||
Promise.reject(Error(`unexpected ${path}`));
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('deckyInstalled').children[0].children[2].disabled,false);
|
||||
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'};
|
||||
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);
|
||||
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// 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'}));
|
||||
ctx.mockApi=async path => {
|
||||
if(path.startsWith('/api/job'))throw Error('no such job (the app may have restarted)');
|
||||
if(path==='/api/decky')return {running:true,plugins:[]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('checkDeckyJob()',ctx);
|
||||
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
@@ -11,10 +11,12 @@ 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'))
|
||||
import frame_decky_store
|
||||
import frame_decky
|
||||
|
||||
|
||||
def archive(flags=(), extra=None):
|
||||
@@ -30,14 +32,14 @@ def archive(flags=(), extra=None):
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def test_catalog_selects_valid_visible_latest_versions(self):
|
||||
records = [{'id': 42, 'name': 'Example', 'visible': True, 'versions':
|
||||
records = [{'id': 42, 'name': 'Example', 'author': 'Panel Author', 'visible': True, 'versions':
|
||||
[{'name': '1.0', 'hash': 'a' * 64}], 'tags': []},
|
||||
{'id': 43, 'name': 'Hidden', 'visible': False, 'versions':
|
||||
[{'name': '1.0', 'hash': 'b' * 64}]},
|
||||
{'id': 44, 'name': 'Unsafe', 'visible': True, 'versions':
|
||||
[{'name': '1.0; touch /tmp/bad', 'hash': 'c' * 64}]}]
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertEqual([p['id'] for p in frame_decky_store.catalog()], [42])
|
||||
self.assertEqual([(p['id'], p['author']) for p in frame_decky_store.catalog()], [(42, 'Panel Author')])
|
||||
|
||||
def test_accepts_hashed_non_root_archive(self):
|
||||
data, digest = archive()
|
||||
@@ -53,6 +55,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):
|
||||
@@ -111,17 +129,86 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(external.exists())
|
||||
|
||||
def test_remove_while_service_is_stopped_does_not_restart_it(self):
|
||||
data, _ = archive()
|
||||
self.assertEqual(self.apply('install', data).returncode, 0)
|
||||
fake = Path(self.tmp.name) / 'systemctl'
|
||||
fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 1; fi\n'
|
||||
'echo "$*" >> "$HOME/service-calls"\nexit 0\n')
|
||||
result = self.apply('remove')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(self.folder.exists())
|
||||
self.assertEqual((Path(self.tmp.name) / 'service-calls').read_text().splitlines(),
|
||||
['--user stop frame-control-decky.service'])
|
||||
|
||||
def test_shared_lock_rejects_plugin_and_loader_changes(self):
|
||||
import fcntl
|
||||
|
||||
lock_path = Path(self.tmp.name) / '.local/share/frame-control/decky-transaction.lock'
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data, _ = archive()
|
||||
with lock_path.open('w+b') as held:
|
||||
fcntl.flock(held, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
plugin = self.apply('install', data)
|
||||
loader = subprocess.run([sys.executable, '-', 'remove', 'none', 'none', 'none'],
|
||||
input=frame_decky.APPLY, text=True, capture_output=True,
|
||||
env=self.env, timeout=15)
|
||||
self.assertNotEqual(plugin.returncode, 0)
|
||||
self.assertNotEqual(loader.returncode, 0)
|
||||
self.assertIn('Another Decky change is in progress', plugin.stderr)
|
||||
self.assertIn('Another Decky change is in progress', loader.stderr)
|
||||
self.assertFalse(self.folder.exists())
|
||||
|
||||
installed = self.apply('install', data)
|
||||
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):
|
||||
state = {'managed': True, 'running': False, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
calls = []
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog') as network:
|
||||
result = frame_decky_store.change(lambda command, **kwargs: calls.append(command) or '{}',
|
||||
'remove', 42)
|
||||
self.assertEqual(result, {})
|
||||
self.assertEqual(len(calls), 1)
|
||||
network.assert_not_called()
|
||||
|
||||
def test_renamed_store_plugin_cannot_be_installed_twice(self):
|
||||
state = {'managed': True, 'running': True, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'OldFolder', 'name': 'Old Name', 'version': '1.0'}]}
|
||||
catalog = [{'id': 42, 'name': 'New Name', 'root': False, 'sha256': 'a' * 64, 'version': '2.0'}]
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog', return_value=catalog) as network, \
|
||||
patch.object(frame_decky_store.urllib.request, 'urlopen', side_effect=AssertionError('No download expected')):
|
||||
with self.assertRaisesRegex(ValueError, 'already installed'):
|
||||
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
|
||||
'install', 42)
|
||||
network.assert_not_called()
|
||||
|
||||
def test_owned_plugin_can_be_removed_without_store_network(self):
|
||||
state = {'managed': True, 'running': True, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
def ssh(command, **kwargs):
|
||||
return '{}'
|
||||
progress = []
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog') as network:
|
||||
frame_decky_store.change(ssh, 'remove', 42)
|
||||
frame_decky_store.change(ssh, 'remove', 42, lambda stage, percent: progress.append((stage, percent)))
|
||||
network.assert_not_called()
|
||||
self.assertEqual(progress, [('Checking installed plugins', 5), ('Removing plugin on Frame', 80)])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""The pinned store bundles remain valid API v2 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'], 2)
|
||||
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()
|
||||
@@ -5,6 +5,7 @@ import http.client
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tarfile
|
||||
@@ -19,6 +20,16 @@ import server
|
||||
|
||||
|
||||
class Panel(unittest.TestCase):
|
||||
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)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_plugin_quick_actions_and_settings(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_actions.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
@@ -36,6 +47,7 @@ class Panel(unittest.TestCase):
|
||||
page = framey_panel.panel(14).decode()
|
||||
self.assertIn('framey-bundle', page)
|
||||
self.assertIn('/framey-runtime/host.js', page)
|
||||
self.assertIn('framey-toasts', page)
|
||||
self.assertNotIn('X-Frame-UI', page)
|
||||
self.assertNotIn('SP_REACT.createElement', page) # bundled code is base64, not executable HTML.
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The author example stores settings as a normal Decky user plugin."""
|
||||
import asyncio
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[1] / 'examples/framey-settings/main.py'
|
||||
|
||||
|
||||
def backend(settings_dir):
|
||||
decky = types.ModuleType('decky')
|
||||
decky.decky_SETTINGS_DIR = settings_dir
|
||||
spec = importlib.util.spec_from_file_location('framey_sample', EXAMPLE)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {'decky': decky}):
|
||||
spec.loader.exec_module(module)
|
||||
return module.Plugin()
|
||||
|
||||
|
||||
class SamplePlugin(unittest.TestCase):
|
||||
def test_frontend_bundle_loads_in_framey_host(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent / 'page/framey_sample.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_setting_survives_backend_instance_restart(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
first = backend(folder)
|
||||
self.assertFalse(asyncio.run(first.get_setting('enabled', False)))
|
||||
self.assertTrue(asyncio.run(first.set_setting('enabled', True)))
|
||||
second = backend(folder)
|
||||
self.assertTrue(asyncio.run(second.get_setting('enabled', False)))
|
||||
self.assertTrue((Path(folder) / 'settings.json').is_file())
|
||||
|
||||
def test_backend_rejects_unknown_keys_and_non_boolean_values(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
plugin = backend(folder)
|
||||
with self.assertRaises(ValueError): asyncio.run(plugin.get_setting('other'))
|
||||
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()
|
||||
+10
-1
@@ -63,7 +63,7 @@ print(json.dumps({'arch':platform.machine(), 'frameOS':os_release.get('VERSION_I
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import hashlib, json, os, pathlib, subprocess, sys
|
||||
import fcntl, hashlib, json, os, pathlib, subprocess, sys
|
||||
action, token, digest, version = sys.argv[1:]
|
||||
home = pathlib.Path.home()
|
||||
base = home / '.local/share/frame-control/decky'
|
||||
@@ -72,6 +72,7 @@ binary = base / 'PluginLoader'
|
||||
receipt = base / '.frame-control/receipt.json'
|
||||
legacy = base / 'settings/release.json'
|
||||
stage = pathlib.Path('/tmp/frame-control-decky-' + token)
|
||||
transaction_lock = None
|
||||
def systemd(*args):
|
||||
p = subprocess.run(['systemctl','--user',*args], capture_output=True, text=True, timeout=20)
|
||||
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip() or 'systemd failed')
|
||||
@@ -79,6 +80,13 @@ def owned():
|
||||
try: return json.loads((receipt if receipt.exists() else legacy).read_text()).get('managedBy') == 'Frame Control' and service.is_file()
|
||||
except (OSError, ValueError): return False
|
||||
try:
|
||||
# The desktop and headset servers are separate processes. Lock the shared
|
||||
# user-service transaction on the Frame before inspecting or changing it.
|
||||
base.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = base.parent / 'decky-transaction.lock'
|
||||
transaction_lock = os.fdopen(os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600), 'r+b')
|
||||
try: fcntl.flock(transaction_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError: raise RuntimeError('Another Decky change is in progress; try again after it finishes')
|
||||
if action == 'remove':
|
||||
if not owned(): raise RuntimeError('No Frame Control managed Decky install to remove')
|
||||
systemd('disable','--now','frame-control-decky.service')
|
||||
@@ -141,6 +149,7 @@ try:
|
||||
print(json.dumps({'message':'Decky Loader '+version+' is running as a user service. The VR menu is experimental.'}))
|
||||
finally:
|
||||
stage.unlink(missing_ok=True)
|
||||
if transaction_lock is not None: transaction_lock.close()
|
||||
'''
|
||||
|
||||
|
||||
|
||||
+55
-17
@@ -54,8 +54,10 @@ def catalog():
|
||||
if not latest:
|
||||
continue
|
||||
tags = item.get('tags') if isinstance(item.get('tags'), list) else []
|
||||
author = item.get('author') if isinstance(item.get('author'), str) else ''
|
||||
result.append({'id': item['id'], 'name': item['name'][:100],
|
||||
'description': str(item.get('description') or '')[:300],
|
||||
'author': author[:100],
|
||||
'version': latest['name'][:40], 'sha256': latest['hash'],
|
||||
'root': 'root' in tags})
|
||||
return result
|
||||
@@ -75,13 +77,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:
|
||||
@@ -91,6 +100,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', [])
|
||||
@@ -102,7 +113,7 @@ def archive_info(data, expected_hash, expected_name):
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import hashlib,json,pathlib,shutil,subprocess,sys,zipfile
|
||||
import fcntl,hashlib,json,os,pathlib,shutil,subprocess,sys,zipfile
|
||||
action,token,plugin_id,digest,folder,version = sys.argv[1:]
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
unit=pathlib.Path.home()/'.config/systemd/user/frame-control-decky.service'
|
||||
@@ -111,6 +122,7 @@ archive=pathlib.Path('/tmp/frame-control-decky-plugin-'+token)
|
||||
stage=base/('.frame-control-stage-'+token)
|
||||
backup=base/('.frame-control-old-'+token)
|
||||
marker='.frame-control-store.json'
|
||||
transaction_lock=None
|
||||
def owned():
|
||||
if target.is_symlink() or not target.is_dir(): return False
|
||||
try: return json.loads((target/marker).read_text()).get('storeId') == int(plugin_id)
|
||||
@@ -118,12 +130,19 @@ def owned():
|
||||
def service(*args):
|
||||
p=subprocess.run(['systemctl','--user',*args],capture_output=True,text=True,timeout=25)
|
||||
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'Decky service failed')
|
||||
if not unit.is_file(): raise RuntimeError('Install Frame Control managed Decky first')
|
||||
if action not in ('install','update','remove'): raise RuntimeError('Invalid plugin action')
|
||||
if action=='install' and (target.exists() or target.is_symlink()): raise RuntimeError('Plugin folder already exists')
|
||||
if action in ('update','remove') and not owned(): raise RuntimeError('Only a Frame Control store plugin can be changed')
|
||||
running=subprocess.run(['systemctl','--user','is-active','--quiet','frame-control-decky.service']).returncode==0
|
||||
try:
|
||||
# A desktop request and a headset-panel request must not change the same
|
||||
# Decky user service at once. Keep this lock until rollback/cleanup ends.
|
||||
base.parent.parent.mkdir(parents=True,exist_ok=True)
|
||||
lock_path=base.parent.parent/'decky-transaction.lock'
|
||||
transaction_lock=os.fdopen(os.open(lock_path,os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600),'r+b')
|
||||
try: fcntl.flock(transaction_lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
|
||||
except BlockingIOError: raise RuntimeError('Another Decky change is in progress; try again after it finishes')
|
||||
if not unit.is_file(): raise RuntimeError('Install Frame Control managed Decky first')
|
||||
if action not in ('install','update','remove'): raise RuntimeError('Invalid plugin action')
|
||||
if action=='install' and (target.exists() or target.is_symlink()): raise RuntimeError('Plugin folder already exists')
|
||||
if action in ('update','remove') and not owned(): raise RuntimeError('Only a Frame Control store plugin can be changed')
|
||||
running=subprocess.run(['systemctl','--user','is-active','--quiet','frame-control-decky.service']).returncode==0
|
||||
if action!='remove':
|
||||
data=archive.read_bytes()
|
||||
if not 1024<len(data)<=50*1024*1024 or hashlib.sha256(data).hexdigest()!=digest:
|
||||
@@ -132,16 +151,24 @@ 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
|
||||
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
|
||||
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
|
||||
if running: service('stop','frame-control-decky.service')
|
||||
# Stop even a failed unit before removal to cancel any pending auto-restart.
|
||||
if running or action=='remove': service('stop','frame-control-decky.service')
|
||||
try:
|
||||
if action!='install': target.rename(backup)
|
||||
if action!='remove': (stage/folder).rename(target)
|
||||
@@ -157,22 +184,29 @@ try:
|
||||
finally:
|
||||
archive.unlink(missing_ok=True)
|
||||
if stage.exists(): shutil.rmtree(stage)
|
||||
if transaction_lock is not None: transaction_lock.close()
|
||||
'''
|
||||
|
||||
|
||||
def change(ssh, action, plugin_id):
|
||||
def change(ssh, action, plugin_id, report=lambda stage, percent=None: 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')
|
||||
report('Checking installed plugins', 5)
|
||||
state = frame_decky.status(ssh)
|
||||
if not state['managed'] or not state['running']:
|
||||
raise ValueError('Start Frame Control managed Decky before changing plugins')
|
||||
if not state['managed']:
|
||||
raise ValueError('Install Frame Control managed Decky before changing plugins')
|
||||
if action != 'remove' and not state['running']:
|
||||
raise ValueError('Start Frame Control managed Decky before installing or updating plugins')
|
||||
installed = [p for p in state['plugins'] if p.get('storeId') == plugin_id]
|
||||
if action == 'install' and installed:
|
||||
raise ValueError('This store plugin is already installed; use Update or Remove')
|
||||
if action == 'remove':
|
||||
if len(installed) != 1:
|
||||
raise ValueError('Only an installed Frame Control store plugin can be removed')
|
||||
folder = installed[0]['folder']
|
||||
plugin = {'sha256': '0' * 64, 'version': 'removed'}
|
||||
else:
|
||||
report('Reading official store', 15)
|
||||
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')
|
||||
@@ -185,9 +219,11 @@ def change(ssh, action, plugin_id):
|
||||
raise ValueError('Only an installed Frame Control store plugin can be updated')
|
||||
data = None
|
||||
if action != 'remove':
|
||||
report('Downloading plugin archive', 30)
|
||||
url = _urls()[1] + plugin['sha256'] + '.zip'
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Frame-Control'}), timeout=35) as response:
|
||||
data = response.read(MAX_ZIP + 1)
|
||||
report('Verifying plugin archive', 50)
|
||||
folder = archive_info(data, plugin['sha256'], plugin['name'])
|
||||
if action == 'update' and installed[0]['folder'] != folder:
|
||||
raise ValueError('The store changed its plugin folder; remove the old version before installing')
|
||||
@@ -197,7 +233,9 @@ def change(ssh, action, plugin_id):
|
||||
remote = '/tmp/frame-control-decky-plugin-' + token
|
||||
try:
|
||||
if data is not None:
|
||||
report('Sending archive to Frame', 65)
|
||||
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
|
||||
report({'install':'Installing', 'update':'Updating', 'remove':'Removing'}[action] + ' plugin on Frame', 80)
|
||||
return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]}',
|
||||
stdin=APPLY, timeout=90))
|
||||
finally:
|
||||
|
||||
+123
-14
@@ -3,10 +3,22 @@
|
||||
'use strict';
|
||||
const R = window.SP_REACT = window.React;
|
||||
window.SP_REACTDOM = window.ReactDOM;
|
||||
const jsx = (type, props, key) => {
|
||||
const {children, ...elementProps} = props || {};
|
||||
if (key !== undefined) elementProps.key = key;
|
||||
return Object.prototype.hasOwnProperty.call(props || {}, 'children')
|
||||
? R.createElement(type, elementProps, children)
|
||||
: R.createElement(type, elementProps);
|
||||
};
|
||||
window.SP_JSX = {jsx, jsxs:jsx, Fragment:R.Fragment};
|
||||
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en']};
|
||||
const root = document.getElementById('framey-root');
|
||||
const error = document.getElementById('framey-error');
|
||||
const toastRoot = document.getElementById('framey-toasts');
|
||||
const pending = new Map();
|
||||
const routes = new Map();
|
||||
const routeListeners = new Set();
|
||||
let currentRoute = null;
|
||||
let nextId = 0;
|
||||
let plugin;
|
||||
|
||||
@@ -34,13 +46,38 @@
|
||||
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 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,
|
||||
@@ -48,13 +85,33 @@
|
||||
R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close'));
|
||||
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
|
||||
const closeModal = () => modalRoot.render(null);
|
||||
function navigate(path) {
|
||||
if (path !== null && (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || !routes.has(path))) return false;
|
||||
currentRoute = path;
|
||||
for (const listener of routeListeners) listener(path);
|
||||
return true;
|
||||
}
|
||||
const routerHook = {
|
||||
addRoute(path, component) {
|
||||
if (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(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);};
|
||||
},
|
||||
removeRoute(path) {routes.delete(path); if(currentRoute === path) navigate(null);},
|
||||
// Steam routes and global components have no equivalent in the isolated panel.
|
||||
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
|
||||
};
|
||||
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, TextField, SliderField,
|
||||
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
|
||||
DialogButton: passthrough('button'), ConfirmModal,
|
||||
staticClasses: {Title:'framey-title'},
|
||||
Navigation: {NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
|
||||
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null),
|
||||
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
|
||||
showModal: element => modalRoot.render(R.createElement('section', null,
|
||||
R.createElement('button', {onClick:closeModal}, 'Close'), element)),
|
||||
// Steam app, library, and store patches have no target inside this panel.
|
||||
@@ -64,21 +121,72 @@
|
||||
useParams: () => ({}),
|
||||
};
|
||||
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
|
||||
const noRoute = {addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{}};
|
||||
const toaster = {toast: ({title, body}) => { error.textContent = [title, body].filter(Boolean).join(': '); }};
|
||||
const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => {
|
||||
const data = {title, body};
|
||||
if (!showToast) return {data, dismiss(){}};
|
||||
const item = document.createElement('section');
|
||||
item.setAttribute('role','status');
|
||||
const heading = document.createElement('strong'); heading.textContent = String(title || 'Plugin notice');
|
||||
const detail = document.createElement('p'); detail.textContent = String(body || '');
|
||||
const close = document.createElement('button'); close.type='button'; close.textContent='Dismiss';
|
||||
const dismiss = () => {item.remove(); clearTimeout(timer);};
|
||||
close.onclick=dismiss; item.append(heading, detail, close);
|
||||
if (typeof onClick === 'function') {
|
||||
const open = document.createElement('button'); open.type='button'; open.textContent='Open';
|
||||
open.onclick=()=>{onClick(); dismiss();}; item.append(open);
|
||||
}
|
||||
toastRoot.append(item);
|
||||
const timer = setTimeout(dismiss, Math.min(30000,Math.max(3000,Number(duration)||5000)));
|
||||
return {data,dismiss};
|
||||
}};
|
||||
class PanelBoundary extends R.Component {
|
||||
constructor(props) { super(props); this.state = {failure:null}; }
|
||||
static getDerivedStateFromError(failure) { return {failure}; }
|
||||
render() { return this.state.failure ? R.createElement('p', null,
|
||||
`This Decky panel is not compatible with Framey: ${this.state.failure.message}`) : this.props.children; }
|
||||
}
|
||||
function PluginSurface({plugin}) {
|
||||
const [view, setView] = R.useState(plugin.content ? 'panel' : 'settings');
|
||||
const [route, setRoute] = R.useState(currentRoute);
|
||||
R.useEffect(() => {routeListeners.add(setRoute); return () => routeListeners.delete(setRoute);}, []);
|
||||
const [running, setRunning] = R.useState(null);
|
||||
const [message, setMessage] = R.useState('');
|
||||
const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions.slice(0, 8)
|
||||
.filter(action => action && typeof action.label === 'string' && action.label.length <= 80 &&
|
||||
typeof action.onClick === 'function') : [];
|
||||
async function runAction(action, index) {
|
||||
if (running !== null) return;
|
||||
setRunning(index); setMessage(`${action.label}…`);
|
||||
try {
|
||||
const result = await action.onClick();
|
||||
setMessage(typeof result === 'string' && result.length < 200 ? result : `${action.label} done`);
|
||||
} catch (failure) { setMessage(`${action.label} failed: ${failure.message || failure}`); }
|
||||
finally { setRunning(null); }
|
||||
}
|
||||
return R.createElement(R.Fragment, null,
|
||||
plugin.titleView && R.createElement('header', null, plugin.titleView),
|
||||
route && R.createElement('button', {type:'button', onClick:()=>navigate(null)}, 'Back to plugin'),
|
||||
plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'},
|
||||
R.createElement('button', {type:'button', 'aria-current':view === 'panel' ? 'page' : undefined,
|
||||
onClick:()=>setView('panel')}, 'Panel'),
|
||||
R.createElement('button', {type:'button', 'aria-current':view === 'settings' ? 'page' : undefined,
|
||||
onClick:()=>setView('settings')}, 'Settings')),
|
||||
actions.length > 0 && R.createElement('section', {'aria-label':'Plugin quick actions'},
|
||||
R.createElement('h2', null, 'Quick actions'),
|
||||
...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)) :
|
||||
(view === 'settings' ? plugin.settings : plugin.content)));
|
||||
}
|
||||
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
|
||||
connect: (version, name) => ({
|
||||
_version: Math.min(version, 2),
|
||||
call: (method, ...args) => ask(method, args),
|
||||
callable: method => (...args) => ask(method, args),
|
||||
addEventListener: () => {}, removeEventListener: () => {},
|
||||
routerHook:noRoute, toaster,
|
||||
routerHook, toaster,
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
executeInTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
|
||||
injectCssIntoTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
|
||||
@@ -95,8 +203,9 @@
|
||||
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();
|
||||
if (!plugin?.content) throw Error('The Decky plugin has no panel to display');
|
||||
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null, plugin.content));
|
||||
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})));
|
||||
parent.postMessage({type:'framey:ready'}, '*');
|
||||
} catch (failure) {
|
||||
error.textContent = `This Decky panel is not compatible with Framey: ${failure.message}`;
|
||||
|
||||
+2
-2
@@ -59,9 +59,9 @@ def panel(plugin_id):
|
||||
return ('''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Framey plugin</title><style>body{font:21px/1.4 system-ui;background:#0d1925;color:#f3f7fa;margin:0;padding:20px}
|
||||
button,select,input{font:inherit;min-height:52px;background:#23618c;color:#fff;border:1px solid #72baf0;border-radius:9px;padding:8px}
|
||||
button:focus-visible,select:focus-visible,input:focus-visible{outline:4px solid #f8d370}section{padding:12px;margin:12px 0;background:#1b3042;border-radius:10px}
|
||||
button:focus-visible,select:focus-visible,input:focus-visible{outline:4px solid #f8d370}button{margin:4px 8px 4px 0}button:disabled{opacity:.55}section{padding:12px;margin:12px 0;background:#1b3042;border-radius:10px}
|
||||
label{display:grid;gap:6px;margin:10px 0}small{color:#b9c9d6}</style>
|
||||
<p id="framey-error" role="status"></p><div id="framey-root"></div><div id="framey-modal"></div>
|
||||
<p id="framey-error" role="status"></p><div id="framey-toasts" aria-live="polite"></div><div id="framey-root"></div><div id="framey-modal"></div>
|
||||
<script id="framey-bundle" type="application/json">''' + encoded + '''</script>
|
||||
<script src="/framey-runtime/react.js"></script><script src="/framey-runtime/react-dom.js"></script>
|
||||
<script src="/framey-runtime/host.js"></script></html>''').encode()
|
||||
|
||||
+77
-19
@@ -46,7 +46,13 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
|
||||
<h2>Decky plugins</h2><p>Manage official store plugins here. Decky's Quick Access Menu is absent from this VR shell. Framey can show compatible plugin panels and settings below; Steam library and store page patches are unavailable.</p>
|
||||
<div class="row"><button id="deckyRefresh" class="secondary">Refresh plugins</button><button id="deckyStoreLoad" class="secondary">Open official store</button></div>
|
||||
<p id="deckyState" role="status"></p><div id="deckyInstalled" class="stack"></div>
|
||||
<div id="deckyStorePanel" hidden><label>Find a plugin <input id="deckySearch" type="search" placeholder="Search Decky store"></label><div id="deckyStoreList" class="stack"></div></div>
|
||||
<div id="deckyJobPanel" class="block" hidden style="margin:14px 0"><strong id="deckyJobTitle"></strong>
|
||||
<p id="deckyJobStatus" role="status" aria-live="polite"></p><progress id="deckyJobProgress" max="100"></progress>
|
||||
<div class="row"><button id="deckyJobCheck" class="secondary" hidden>Check result</button></div></div>
|
||||
<div id="deckyStorePanel" hidden><div class="row" style="margin:16px 0"><label>Find a plugin <input id="deckySearch" type="search" placeholder="Name, author or description"></label>
|
||||
<label>Show <select id="deckyStoreFilter"><option value="all">All plugins</option><option value="compatible">Without root requirement</option><option value="installed">Installed plugins</option></select></label></div>
|
||||
<p id="deckyStoreSummary" role="status" aria-live="polite"></p><div id="deckyStoreList" class="stack"></div>
|
||||
<div class="row" style="margin-top:14px"><button id="deckyStorePrevious" class="secondary">Previous</button><span id="deckyStorePage"></span><button id="deckyStoreNext" class="secondary">Next</button></div></div>
|
||||
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button></div>
|
||||
<p id="frameyPanelStatus" role="status">Loading plugin panel…</p>
|
||||
<iframe id="frameyFrame" title="Framey Decky plugin panel" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer"></iframe></div>
|
||||
@@ -97,40 +103,87 @@ 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=[],activeFrameyId=null;
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=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.':'Decky is not running. Install it from Frame Control on your computer.';
|
||||
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
|
||||
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
|
||||
'Decky is not running. Install it from Frame Control on your computer.';
|
||||
const list=byId('deckyInstalled');list.replaceChildren();
|
||||
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
|
||||
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
|
||||
if(plugin.storeId){const open=document.createElement('button');open.className='secondary';open.textContent='Open panel';
|
||||
open.onclick=()=>openFrameyPanel(plugin.storeId,plugin.name);row.append(open);
|
||||
const button=document.createElement('button');button.className='danger';button.textContent='Remove';
|
||||
button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
|
||||
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(){try{const data=await api('/api/decky/store');deckyStore=data.plugins;
|
||||
byId('deckyStorePanel').hidden=false;showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}}
|
||||
async function loadDeckyStore(){byId('deckyStorePanel').hidden=false;
|
||||
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}`;}}
|
||||
function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren();
|
||||
const q=byId('deckySearch').value.trim().toLowerCase();
|
||||
for(const plugin of deckyStore.filter(p=>!q||`${p.name} ${p.description}`.toLowerCase().includes(q)).slice(0,30)){
|
||||
const filter=byId('deckyStoreFilter').value;
|
||||
const found=deckyStore.filter(p=>(!q||`${p.name} ${p.author||''} ${p.description}`.toLowerCase().includes(q))&&
|
||||
(filter==='all'||(filter==='compatible'&&!p.root)||(filter==='installed'&&(deckyState?.plugins||[]).some(x=>x.storeId===p.id))));
|
||||
const size=20, pages=Math.max(1,Math.ceil(found.length/size));
|
||||
deckyStoreIndex=Math.min(deckyStoreIndex,pages-1);
|
||||
byId('deckyStoreSummary').textContent=`${found.length} plugin${found.length===1?'':'s'} found`;
|
||||
byId('deckyStorePage').textContent=`Page ${deckyStoreIndex+1} of ${pages}`;
|
||||
byId('deckyStorePrevious').disabled=deckyStoreIndex===0;
|
||||
byId('deckyStoreNext').disabled=deckyStoreIndex>=pages-1;
|
||||
if(!found.length)list.textContent='No plugins match this search.';
|
||||
for(const plugin of found.slice(deckyStoreIndex*size,(deckyStoreIndex+1)*size)){
|
||||
const row=document.createElement('div');row.className='block game';
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version} · ${plugin.description}`;
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version}${plugin.author?' · '+plugin.author:''} · ${plugin.description}`;
|
||||
const button=document.createElement('button');button.textContent='Install';
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===plugin.id);
|
||||
if(installed)button.textContent=installed.version===plugin.version?'Reinstall':'Update';
|
||||
button.disabled=!deckyState?.running||plugin.root;
|
||||
button.disabled=deckyBusy||!deckyState?.running||plugin.root;
|
||||
if(plugin.root)label.textContent+=' · Needs root; not supported on Frame';
|
||||
button.onclick=()=>deckyChange(installed?'update':'install',plugin);row.append(label,button);list.append(row);}}
|
||||
async function deckyChange(action,plugin){if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
|
||||
byId('deckyState').textContent=`${action} ${plugin.name}…`;
|
||||
try{const job=await api('/api/decky/plugin',{action,id:plugin.id,acknowledged:true});
|
||||
if(job.job){let completed=false;for(let n=0;n<180;n++){await new Promise(resolve=>setTimeout(resolve,1000));
|
||||
const progress=await api(`/api/job?id=${encodeURIComponent(job.job)}`);
|
||||
byId('deckyState').textContent=progress.stage||progress.message||`${action} ${plugin.name}…`;
|
||||
if(progress.done){if(progress.error)throw Error(progress.error);completed=true;break;}}
|
||||
if(!completed)throw Error('The operation is still running. Refresh plugins to check its result.');}
|
||||
await refreshDecky();}catch(error){byId('deckyState').textContent=error.message;}}
|
||||
function deckyJobView(title,message,percent,failed=false){
|
||||
byId('deckyJobPanel').hidden=false;byId('deckyJobTitle').textContent=title;
|
||||
byId('deckyJobStatus').textContent=message;
|
||||
const bar=byId('deckyJobProgress');if(Number.isFinite(percent))bar.value=percent;else bar.removeAttribute('value');
|
||||
byId('deckyJobCheck').hidden=!failed||!sessionStorage.getItem('frameyPluginJob');
|
||||
}
|
||||
async function followDeckyJob(job,action,name){
|
||||
for(let n=0;n<180;n++){
|
||||
let progress;
|
||||
try{progress=await api(`/api/job?id=${encodeURIComponent(job)}`);}
|
||||
catch(error){
|
||||
if(/no such job/i.test(error.message)){
|
||||
sessionStorage.removeItem('frameyPluginJob');
|
||||
throw Error('The app restarted and lost this job result. Refresh installed plugins to see what changed.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if(progress.done){if(progress.error){sessionStorage.removeItem('frameyPluginJob');throw Error(progress.error);}
|
||||
deckyJobView(`${action} ${name}`,progress.message||`${action} complete`,100);return;}
|
||||
deckyJobView(`${action} ${name}`,progress.stage||'Waiting for Frame…',progress.percent);
|
||||
await new Promise(resolve=>setTimeout(resolve,1000));
|
||||
}
|
||||
throw Error('Still working. Use Check result to reconnect to this job.');
|
||||
}
|
||||
async function deckyChange(action,plugin){if(deckyBusy||!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
|
||||
deckyBusy=true;showDeckyStore();
|
||||
deckyJobView(`${action} ${plugin.name}`,'Starting…',0);
|
||||
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,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);
|
||||
sessionStorage.removeItem('frameyPluginJob');
|
||||
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}`,null,true);}
|
||||
finally{deckyBusy=false;await refreshDecky();}
|
||||
}
|
||||
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
|
||||
if(!saved||typeof saved.job!=='string'){sessionStorage.removeItem('frameyPluginJob');return;}
|
||||
deckyBusy=true;byId('deckyJobCheck').hidden=true;
|
||||
try{await followDeckyJob(saved.job,saved.action,saved.name);sessionStorage.removeItem('frameyPluginJob');}
|
||||
catch(error){deckyJobView(`${saved.action} ${saved.name}`,`Error: ${error.message}`,null,true);}
|
||||
finally{deckyBusy=false;await refreshDecky();}}
|
||||
byId('deckyJobCheck').onclick=checkDeckyJob;
|
||||
function openFrameyPanel(id,name){
|
||||
if(!(deckyState?.plugins||[]).some(p=>p.storeId===id))return;
|
||||
activeFrameyId=id;byId('frameyTitle').textContent=name;
|
||||
@@ -183,7 +236,11 @@ byId('statusScreenClose').onclick=()=>{byId('statusScreen').hidden=true;byId('st
|
||||
byId('statusScreenRefresh').onclick=refresh;
|
||||
window.addEventListener('keydown',event=>{if(event.key==='Escape'&&!byId('statusScreen').hidden)byId('statusScreenClose').click();});
|
||||
byId('gamesRefresh').onclick=refreshGames;byId('record').onclick=toggleRecord;byId('extensionsRefresh').onclick=refreshExtensions;
|
||||
byId('deckyRefresh').onclick=refreshDecky;byId('deckyStoreLoad').onclick=loadDeckyStore;byId('deckySearch').oninput=showDeckyStore;
|
||||
byId('deckyRefresh').onclick=refreshDecky;byId('deckyStoreLoad').onclick=loadDeckyStore;
|
||||
byId('deckySearch').oninput=()=>{deckyStoreIndex=0;showDeckyStore();};
|
||||
byId('deckyStoreFilter').onchange=()=>{deckyStoreIndex=0;showDeckyStore();};
|
||||
byId('deckyStorePrevious').onclick=()=>{deckyStoreIndex--;showDeckyStore();};
|
||||
byId('deckyStoreNext').onclick=()=>{deckyStoreIndex++;showDeckyStore();};
|
||||
byId('extensionClose').onclick=()=>{activeExtension=null;byId('extensionFrame').removeAttribute('src');byId('extensionPanel').hidden=true;};
|
||||
window.addEventListener('message',event=>{if(!activeExtension||event.source!==byId('extensionFrame').contentWindow)return;
|
||||
const request=event.data;if(!request||request.type!=='frame-control:request'||!Number.isSafeInteger(request.id))return;
|
||||
@@ -193,5 +250,6 @@ window.addEventListener('message',event=>{if(!activeExtension||event.source!==by
|
||||
battery:{percent:latestStatus?.battery?.percent??null},steamvr:!!latestStatus?.services?.steamvr};
|
||||
else return;event.source.postMessage({type:'frame-control:response',id:request.id,value},'*');});
|
||||
refresh();refreshGames();refreshRecord();refreshExtensions();refreshDecky();
|
||||
checkDeckyJob();
|
||||
setInterval(()=>{if(!document.hidden){refresh();refreshRecord();}},15000);
|
||||
</script></body></html>
|
||||
+3
-3
@@ -2202,7 +2202,7 @@ async function loadDecky() {
|
||||
const row=document.createElement('div');row.className='hint';
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''}`;
|
||||
const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove plugin';
|
||||
remove.disabled=!d.running||!$('deckyAck').checked;
|
||||
remove.disabled=!d.managed||!$('deckyAck').checked;
|
||||
remove.onclick=()=>changeDeckyPlugin('remove',{id:plugin.storeId,name:plugin.name});
|
||||
row.append(label,remove);managedList.append(row);}
|
||||
deckyButtons();
|
||||
@@ -2236,7 +2236,7 @@ function showDeckyStore() {
|
||||
button.onclick=()=>changeDeckyPlugin(entry?'update':'install',plugin);
|
||||
row.append(button);
|
||||
if(entry){const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove';
|
||||
remove.disabled=!deckyInfo?.managed || !deckyInfo?.running || !$('deckyAck').checked;
|
||||
remove.disabled=!deckyInfo?.managed || !$('deckyAck').checked;
|
||||
remove.onclick=()=>changeDeckyPlugin('remove',plugin);row.append(remove);}
|
||||
}
|
||||
list.append(row);
|
||||
@@ -2255,7 +2255,7 @@ function deckyButtons() {
|
||||
$('deckyRemove').disabled = !ack || !deckyInfo?.managed;
|
||||
}
|
||||
$('deckyAck').onchange = () => { deckyButtons(); showDeckyStore();
|
||||
for(const button of $('deckyManagedList').querySelectorAll('button'))button.disabled=!$('deckyAck').checked||!deckyInfo?.running; };
|
||||
for(const button of $('deckyManagedList').querySelectorAll('button'))button.disabled=!$('deckyAck').checked||!deckyInfo?.managed; };
|
||||
$('deckyRefresh').onclick = loadDecky;
|
||||
$('deckyStoreRefresh').onclick = loadDeckyStore;
|
||||
$('deckyStoreSearch').oninput = showDeckyStore;
|
||||
|
||||
+1
-1
@@ -572,7 +572,7 @@ def decky_plugin(body):
|
||||
if body.get('acknowledged') is not True:
|
||||
raise Failure('Confirm that store plugins run code as your Frame user', 400)
|
||||
return start_job('Decky plugin ' + action,
|
||||
lambda: frame_decky_store.change(ssh, action, plugin_id))
|
||||
lambda report: frame_decky_store.change(ssh, action, plugin_id, report), progress=True)
|
||||
|
||||
|
||||
def framey_method_action(body):
|
||||
|
||||
Reference in new issue
Block a user