mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 02:01:52 +01:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee7bacc47 | ||
|
|
027bbfa701 | ||
|
|
2a9d4f886e | ||
|
|
734cd4f36c | ||
|
|
3e5349ec39 | ||
|
|
3c184f5741 | ||
|
|
93bffc21d7 | ||
|
|
8423a309ff | ||
|
|
698a3b11d9 | ||
|
|
5952bf8e9d | ||
|
|
d186dfbc60 | ||
|
|
c7e76fdd64 | ||
|
|
559eec96b3 | ||
|
|
020f93a187 | ||
|
|
69a912cc7d | ||
|
|
aa6078c11a | ||
|
|
01d1fba915 | ||
|
|
e89ff39cff | ||
|
|
4cfbd5883f | ||
|
|
33fdd9d296 | ||
|
|
6b6ec64381 | ||
|
|
1e9aa42282 | ||
|
|
8296657a45 |
No files matched your search
@@ -10,7 +10,7 @@ on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
@@ -110,6 +110,9 @@ jobs:
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Launch the built Linux AppImage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.34",
|
||||
"version": "0.4.35",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.34",
|
||||
"version": "0.4.35",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.34",
|
||||
"version": "0.4.35",
|
||||
"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.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.
|
||||
Current for Frame Control **v0.4.35**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
@@ -23,7 +23,7 @@ Current for Frame Control **v0.4.34**, 25 October 2026. “Real Frame + physical
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified. The new large read-only status screen has browser layout and packaging checks only | 25 Oct, v0.4.29 device check and v0.4.31 status screen: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [PR #220](https://github.com/saphid/frame-control/pull/220), [panel guide](headset-app.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.33 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [compatibility table](decky-frame.md) |
|
||||
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Fake Frame tests cover setting read/write through update and reinstall 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) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. v0.4.35 treats `_root` store tags as root-required and hides duplicate IDs; this has test coverage, not a live store check. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.35 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [PR #246](https://github.com/saphid/frame-control/pull/246), [compatibility table](decky-frame.md) |
|
||||
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Official Steam Status rendered in local Chromium; a session-only cache avoided its earlier storage errors. A later local browser check restored a bounded per-plugin cache after panel reopen; live Decky and offline behavior were not tested. Fake Frame tests cover setting read/write through update and reinstall and install/remove two pinned official API v2 archives plus the modern API v1 Storage Cleaner archive. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle with a settings tab and read-only backend quick action. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set. Storage Cleaner 1.4.0 showed an empty-data panel and four disabled cleanup buttons in local Chromium with a stub backend; no cleanup action was tested. A hash match, local panel and fake Frame install do not prove VR use. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.35: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [#236](https://github.com/saphid/frame-control/pull/236), [#237](https://github.com/saphid/frame-control/pull/237), [#238](https://github.com/saphid/frame-control/pull/238), [#239](https://github.com/saphid/frame-control/pull/239), [#240](https://github.com/saphid/frame-control/pull/240), [#241](https://github.com/saphid/frame-control/pull/241), [#243](https://github.com/saphid/frame-control/pull/243), [#244](https://github.com/saphid/frame-control/pull/244), [#245](https://github.com/saphid/frame-control/pull/245), [#247](https://github.com/saphid/frame-control/pull/247), [#248](https://github.com/saphid/frame-control/pull/248), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
+11
-9
@@ -44,22 +44,23 @@ For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-aut
|
||||
|
||||
### Which of these plugins can open a Framey panel?
|
||||
|
||||
The separate Framey panel accepts a Frame Control store install 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
|
||||
The separate Framey panel accepts a Frame Control store install when its
|
||||
`plugin.json` declares API v1 or v2 and it has a modern Decky frontend bundle
|
||||
in `dist/index.js`. A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
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. |
|
||||
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | Framey refuses these frontends. Audio Loader and PowerTools also request root. |
|
||||
|
||||
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
|
||||
The archive audit itself is static PC inspection. Later local host and fake
|
||||
Frame tests cover the Storage Cleaner and HLTB cases noted above; none shows
|
||||
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
|
||||
remain the only device evidence for these versions. A new store version needs a
|
||||
new check because its manifest and binaries can change.
|
||||
|
||||
@@ -78,11 +79,12 @@ change Frame Control owned plugin folders under
|
||||
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
|
||||
back an update if the service restart fails. The current VR shell does not
|
||||
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
|
||||
shows installed plugins and store actions. Its separate Framey iframe now hosts
|
||||
compatible API v2 plugin pages, settings, local routes, and panel toasts.
|
||||
shows installed plugins and store actions. Its separate Framey iframe hosts
|
||||
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
|
||||
with settings, local routes, and panel toasts where supported.
|
||||
Steam route patches and system notifications remain unavailable. The panel
|
||||
still needs a worn-headset visual and controller check. See the
|
||||
[current compatibility table](frame-plugin-author.md#framey-api-v2-compatibility).
|
||||
[current compatibility table](frame-plugin-author.md#framey-decky-api-compatibility).
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# API v1 panel check, 25 October 2026
|
||||
|
||||
The unmodified official Storage Cleaner 1.4.0 archive (store ID 50, SHA-256
|
||||
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`)
|
||||
declares `api_version: 1`, but its bundled frontend connects to Decky's modern
|
||||
frontend API. Framey admitted that bundle from a local store receipt and
|
||||
rendered it in Chromium inside an iframe with `sandbox="allow-scripts
|
||||
allow-popups"`.
|
||||
|
||||
A test parent returned empty lists and `0B` for the plugin's two list and two
|
||||
size backend calls. Chromium reported `framey:ready`, displayed Shader Cache
|
||||
and Compatibility Data sections, and showed all four cleanup buttons disabled.
|
||||
No cleanup action was invoked. The backend was a stub; the test did not run the
|
||||
plugin's Python code or connect to a headset.
|
||||
|
||||
This confirms a panel shell for this exact archive with empty data. Storage
|
||||
Cleaner may remove compatibility data and local game saves when used with a
|
||||
real backend. Its cleanup actions, game names, SteamClient app details, VR
|
||||
visibility, and controller input remain unverified on Frame. Do not infer that
|
||||
its deletion controls are safe or complete in VR from this browser check.
|
||||
@@ -29,7 +29,8 @@ before describing it as working in VR. Until a QAM bridge is verified, a Frame
|
||||
Control extension is the simplest route for a self-contained read-only VR panel.
|
||||
|
||||
Frame Control now has an experimental [Framey panel host](framey-loader.md)
|
||||
for API v2 Decky frontend bundles. It renders common settings controls in the
|
||||
for API v2 Decky frontend bundles and API v1 bundles that use Decky's modern
|
||||
frontend API initializer. It renders common settings controls in the
|
||||
separate SteamVR dashboard panel, but Steam library and store page patches
|
||||
remain unavailable. Wearer-visible use is not yet verified.
|
||||
The [settings sample](../examples/framey-settings/README.md) shows an official
|
||||
@@ -47,7 +48,7 @@ ZIP digest from the official store, install one plugin at a time, and inspect
|
||||
`journalctl --user -u frame-control-decky.service`. Remove only your own
|
||||
plugin folder when you finish. See the current [compatibility table](decky-frame.md).
|
||||
|
||||
## Framey API v2 compatibility
|
||||
## Framey Decky API compatibility
|
||||
|
||||
Framey hosts a plugin's frontend in an isolated iframe in the Frame Control
|
||||
SteamVR panel. The table describes the current implementation and its tests;
|
||||
@@ -56,15 +57,18 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| Decky API or UI feature | Framey behavior | Verified in |
|
||||
| --- | --- | --- |
|
||||
| `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test |
|
||||
| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; older API v1 bundles still show an incompatibility message | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
|
||||
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
|
||||
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
|
||||
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
|
||||
| `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check |
|
||||
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification |
|
||||
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack` | Local routes inside this plugin panel only | Host unit test |
|
||||
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
| `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
|
||||
@@ -72,8 +76,19 @@ Keep routes local to the panel and give every operation a visible result. A
|
||||
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
|
||||
Frame Control store; only paths registered by that same plugin can navigate.
|
||||
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
|
||||
An API v1 manifest does not guarantee a working Framey panel. Test the exact
|
||||
store archive and do not assume SteamClient app details, library patches, or
|
||||
destructive actions work in VR. The
|
||||
[Storage Cleaner browser check](evidence/framey-api-v1-browser-2026-10-25.md)
|
||||
used empty stub data and did not invoke its cleanup actions.
|
||||
Use the Decky Python backend for settings that must survive browser cache loss.
|
||||
The Framey `localStorage` bridge is for disposable frontend caches, including
|
||||
the cache read attempted by Steam Status 1.2.3. It does not expose another
|
||||
plugin's cache or the Frame Control UI key. A real Steam Status panel reopen
|
||||
and a worn-headset settings check are still needed.
|
||||
|
||||
HLTB for Deck 2.0.12 demonstrates the limit of the panel shim: its official
|
||||
bundle can start and produce a settings view with Framey's Steam module lookup
|
||||
stub, but its game page and context menu patches have no Steam target in the
|
||||
isolated panel. The tests do not call its HLTB network search or Python backend.
|
||||
Do not claim game length badges work in VR from this result.
|
||||
+15
-7
@@ -23,8 +23,10 @@ rejects unsafe ZIP members, path collisions, special files and root plugins,
|
||||
and swaps one managed plugin
|
||||
folder with rollback on failure. It rejects non-ARM64 ELF and Windows binaries.
|
||||
The Decky user service restarts after a file change. Framey's panel host accepts
|
||||
only API v2 bundles with `dist/index.js`; other installed plugins remain
|
||||
manageable but have no Framey panel. The store manager does not currently
|
||||
API v2 bundles and API v1 bundles using Decky's modern frontend initializer,
|
||||
with `dist/index.js`; other installed plugins remain manageable but have no
|
||||
Framey panel. API v1 admission is a compatibility attempt, not a guarantee
|
||||
that the plugin's controls work in VR. The store manager does not currently
|
||||
screen a plugin's runtime network downloads.
|
||||
|
||||
## What the compatibility host supplies
|
||||
@@ -70,10 +72,13 @@ that a panel-lifetime substitute avoids those errors while preserving the
|
||||
opaque origin. A [second browser check](evidence/framey-cache-browser-2026-10-25.md)
|
||||
tested the per-plugin cache after reopening a panel. These checks used a stub
|
||||
backend and no worn headset.
|
||||
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4
|
||||
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.
|
||||
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4,
|
||||
Steam Status 1.2.3, and Storage Cleaner 1.4.0 archives, checks their API v1/v2
|
||||
manifests and frontend bytes, and verifies they can be packed as Framey panels.
|
||||
It does not execute their actual backend code or prove their frontend controls
|
||||
work in VR. Storage Cleaner rendered an
|
||||
[empty-data panel in local Chromium](evidence/framey-api-v1-browser-2026-10-25.md).
|
||||
Its cleanup actions were not tested; they may remove local game saves.
|
||||
|
||||
The fake Frame integration test now writes a Decky setting through Framey's
|
||||
scoped backend endpoint, reads it in a new CEF request, updates the plugin,
|
||||
@@ -85,7 +90,10 @@ The in-headset **Open official store** view loads the full official catalogue.
|
||||
Search matches names, authors, and descriptions. Filters show non-root plugins
|
||||
or installed plugins, and Previous/Next buttons browse 20 results per page.
|
||||
The root label is a compatibility warning; other APIs are checked only when a
|
||||
plugin is opened. The store still needs the managed Decky user service running
|
||||
plugin is opened. Both `root` and `_root`
|
||||
store tags mark a plugin as needing root, and duplicate store IDs are omitted
|
||||
because an ID alone could select a different archive than the visible row.
|
||||
The store still needs the managed Decky user service running
|
||||
before its Install or Update buttons are enabled. If a plugin prevents Decky
|
||||
from starting, Remove remains available for Frame Control-owned plugins while
|
||||
the managed service is stopped; it does not restart the service.
|
||||
|
||||
@@ -20,6 +20,12 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
|
||||
draft by its exact version title, restores the tag association and retries
|
||||
the affected upload; it refuses ambiguous matches.
|
||||
The Linux build runs `scripts/check-appimage-launch.py` before upload. It
|
||||
starts the built x86_64 AppImage on an isolated Xvfb display, checks that
|
||||
its bundled server answers, and checks that the server stops on exit. The
|
||||
check disables telemetry and update requests, and keeps extraction in its
|
||||
temporary profile. This CI test does not test a physical display, FUSE mount
|
||||
or a headset.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
@@ -5,7 +5,12 @@ This is a small Decky API v2 plugin for authors. Its panel uses the official
|
||||
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;
|
||||
Framey settings tab exposes the same toggle, and a read-only quick action
|
||||
fetches the saved value without changing it. These two fields are Framey
|
||||
additions; Decky's own menu still uses the main panel content. The quick
|
||||
action calls the backend, so it reads the persisted setting rather than a
|
||||
browser cache. A missing, damaged, or old non-boolean value reads as the
|
||||
caller's default;
|
||||
the next save atomically replaces the sample's settings file.
|
||||
|
||||
## Build
|
||||
@@ -29,7 +34,8 @@ plugin directory does not enable its VR panel.
|
||||
|
||||
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
|
||||
repository root. It checks backend persistence and evaluates the built bundle
|
||||
against Framey's actual host script. The fake Frame store flow is exercised by
|
||||
against Framey's actual host script, including the quick action's scoped
|
||||
backend call. The fake Frame store flow is exercised by
|
||||
the repository's e2e suite. No worn-headset visibility or controller-input test
|
||||
has passed for this sample yet.
|
||||
|
||||
|
||||
+12
-1
@@ -61,10 +61,21 @@ function Content() {
|
||||
}
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
|
||||
}
|
||||
var index = definePlugin(() => ({
|
||||
var index = definePlugin(() => Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
settings: SP_JSX.jsx(Content, {}),
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
|
||||
export { index as default };
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => ({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WAAA,EAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,8CAAA,EAAA,GAAA,CAAA,QAAA,CAAA,6BAAA,CAAA,CAAA;AACA;AAEA,MAAA,QAAA,GAAA,GAAA,CAAA,QAAA;AAIA,MAAA,OAAA,GAAA,GAAA,CAAA,OAAA;AAQA,MAAA,YAAA,GAAA,CAAA,EAAA,KAAA;AACA,IAAA,OAAA,CAAA,GAAA,IAAA,KAAA;AACA,QAAA,OAAA,EAAA,CAAA,GAAA,IAAA,CAAA;AACA,IAAA,CAAA;AACA,CAAA;;ACnCA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AACA,MAAA,UAAA,GAAA,QAAA,CAAA,aAAA,CAAA;AAEA,SAAA,OAAA,Line truncated
|
||||
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the main content\n // usable there too, while showing authors the extra VR settings view.\n settings: <Content />,\n frameyQuickActions: [{\n label: 'Read saved setting',\n onClick: async () => {\n const enabled = await getSetting('enabled', false);\n return `Saved setting is ${enabled ? 'on' : 'off'}`;\n },\n }],\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WLine truncated
|
||||
@@ -49,8 +49,19 @@ function Content() {
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => ({
|
||||
export default definePlugin(() => Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: <span aria-hidden="true">⚙</span>,
|
||||
content: <Content />,
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
settings: <Content />,
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch a built Linux AppImage on Xvfb and check its bundled server.
|
||||
|
||||
This checks the packaged app without a headset or a desktop login session.
|
||||
The profile and display are temporary, and every process started by this check
|
||||
is stopped before it exits.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def server_port(profile):
|
||||
for log in (profile / "config").rglob("server.log"):
|
||||
ports = re.findall(r"--port (\d+)", log.read_text(errors="replace"))
|
||||
if ports:
|
||||
return int(ports[-1])
|
||||
return None
|
||||
|
||||
|
||||
def port_closed(port):
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def check(appimage):
|
||||
if not appimage.is_file() or not os.access(appimage, os.X_OK):
|
||||
raise RuntimeError(f"AppImage is missing or cannot run: {appimage}")
|
||||
if not shutil.which("xvfb-run"):
|
||||
raise RuntimeError("xvfb-run is required for the AppImage smoke check")
|
||||
digest = hashlib.sha256(appimage.read_bytes()).hexdigest()
|
||||
with tempfile.TemporaryDirectory(prefix="frame-appimage-smoke-") as directory:
|
||||
profile = Path(directory)
|
||||
(profile / "tmp").mkdir()
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
APPIMAGE_EXTRACT_AND_RUN="1", ELECTRON_DISABLE_SANDBOX="1",
|
||||
TMPDIR=str(profile / "tmp"),
|
||||
XDG_CONFIG_HOME=str(profile / "config"),
|
||||
XDG_CACHE_HOME=str(profile / "cache"),
|
||||
XDG_DATA_HOME=str(profile / "data"),
|
||||
FRAME_ALIAS="frame-control-appimage-smoke-no-headset",
|
||||
FRAME_CONTROL_TELEMETRY="0", FRAME_CONTROL_NO_UPDATE_CHECK="1",
|
||||
DO_NOT_TRACK="1",
|
||||
)
|
||||
output = profile / "app.log"
|
||||
port = None
|
||||
passed = False
|
||||
with output.open("w") as log:
|
||||
proc = subprocess.Popen(
|
||||
["xvfb-run", "-a", "-s", "-screen 0 1280x800x24 -nolisten tcp",
|
||||
str(appimage), "--no-sandbox"],
|
||||
cwd=profile, env=env, stdout=log, stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 60
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError(f"AppImage exited {proc.returncode} before its server answered")
|
||||
port = server_port(profile)
|
||||
if port is not None:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) as response:
|
||||
if response.status == 200 and response.headers.get("Server", "").startswith("FrameControl/1"):
|
||||
passed = True
|
||||
break
|
||||
except (OSError, urllib.error.URLError):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
if not passed:
|
||||
raise RuntimeError("AppImage server did not answer within 60 seconds")
|
||||
except BaseException:
|
||||
log.flush()
|
||||
print(output.read_text(errors="replace")[-1500:], file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.wait(timeout=10)
|
||||
if port is not None:
|
||||
for _ in range(20):
|
||||
if port_closed(port):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise RuntimeError(f"AppImage server stayed on port {port} after shutdown")
|
||||
print(f"AppImage SHA-256 {digest}: HTTP 200, FrameControl/1, server stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit("usage: check-appimage-launch.py path/to/AppImage")
|
||||
check(Path(sys.argv[1]).resolve())
|
||||
@@ -143,7 +143,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
|
||||
def test_two_pinned_official_api_v2_plugins_install_and_remove(self):
|
||||
def test_pinned_official_api_v1_and_v2_plugins_install_and_remove(self):
|
||||
self.backend()
|
||||
catalog = {plugin['id']:plugin for plugin in ok('GET', '/api/decky/store')['plugins']}
|
||||
for plugin in OFFICIAL_PLUGINS:
|
||||
@@ -160,7 +160,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
|
||||
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
|
||||
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['api_version'], 2)
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['api_version'], plugin['api_version'])
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':plugin['id'],'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+11
-5
@@ -9,10 +9,16 @@ from a local fixture so CI does not depend on the store being online.
|
||||
| --- | --- | --- | --- |
|
||||
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
|
||||
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
|
||||
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
|
||||
| `26.zip` | HLTB for Deck | 2.0.12 | `bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558` |
|
||||
|
||||
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.
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky), and
|
||||
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner), and
|
||||
[HLTB for Deck](https://github.com/morwy/hltb-for-deck).
|
||||
The test verifies the ZIP digest, archive policy, API v1/v2 manifest, installed
|
||||
frontend bytes, managed store receipt, and removal. A separate local host
|
||||
test starts HLTB's bundled frontend and builds its settings view with a stub
|
||||
Steam module lookup. It does **not** run plugin Python backends, HLTB searches,
|
||||
or prove JavaScript panels render in the VR shell.
|
||||
@@ -4,10 +4,14 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||
PLUGINS = (
|
||||
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4',
|
||||
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2,
|
||||
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
|
||||
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3',
|
||||
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
|
||||
'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'},
|
||||
{'id': 50, 'name': 'Storage Cleaner', 'version': '1.4.0', 'api_version': 1,
|
||||
'folder': 'decky-storage-cleaner', 'sha256': '1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591'},
|
||||
{'id': 26, 'name': 'HLTB for Deck', 'version': '2.0.12', 'api_version': 1,
|
||||
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ const input=nodes(window.DFL.TextField({label:'Alias',value:'Old',placeholder:'N
|
||||
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);
|
||||
const checkbox=nodes(window.DFL.DialogCheckbox({label:'Game',disabled:true,
|
||||
onChange:()=>changed++}),'input')[0];
|
||||
for(const control of [toggle,dropdown,button,input,slider,checkbox])assert.equal(control.props.disabled,true);
|
||||
assert.equal(input.props.value,'Old');
|
||||
assert.equal(slider.props.type,'range');
|
||||
assert.equal(dropdown.props.value,'1');
|
||||
@@ -53,6 +55,7 @@ dropdown.props.onChange({target:{value:'0'}});
|
||||
button.props.onClick({});
|
||||
input.props.onChange({target:{value:'New'}});
|
||||
slider.props.onChange({target:{value:'4'}});
|
||||
checkbox.props.onChange({target:{checked:true}});
|
||||
assert.equal(changed,0);
|
||||
let entered='';
|
||||
const readyInput=nodes(window.DFL.TextField({label:'Alias',value:'Old',onChange:e=>{entered=e.target.value;}}),'input')[0];
|
||||
@@ -104,4 +107,7 @@ const undo=api.routerHook.addRoute('/plugin/details',LocalRoute);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),true);
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/decky/store'),false);
|
||||
assert.equal(window.DFL.findModuleChild(()=>true),null);
|
||||
window.DFL.Navigation.CloseSideMenus();
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0);
|
||||
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
|
||||
@@ -0,0 +1,57 @@
|
||||
// Execute the pinned official HLTB frontend's entry point with Framey's API.
|
||||
// This checks module startup only; it never makes network or backend calls.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {inflateRawSync} from 'node:zlib';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const host=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const hostTest=host.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
|
||||
assert.notEqual(hostTest,host);
|
||||
const zip=readFileSync(new URL('../fixtures/framey-official/26.zip',import.meta.url));
|
||||
const eocd=zip.lastIndexOf(Buffer.from([0x50,0x4b,0x05,0x06]));
|
||||
assert.ok(eocd>0);
|
||||
let offset=zip.readUInt32LE(eocd+16),bundle;
|
||||
for(let i=0;i<zip.readUInt16LE(eocd+10);i++){
|
||||
assert.equal(zip.readUInt32LE(offset),0x02014b50);
|
||||
const nameLength=zip.readUInt16LE(offset+28),extraLength=zip.readUInt16LE(offset+30);
|
||||
const commentLength=zip.readUInt16LE(offset+32);
|
||||
const name=zip.subarray(offset+46,offset+46+nameLength).toString();
|
||||
if(name==='hltb-for-deck/dist/index.js'){
|
||||
const local=zip.readUInt32LE(offset+42);
|
||||
assert.equal(zip.readUInt32LE(local),0x04034b50);
|
||||
const start=local+30+zip.readUInt16LE(local+26)+zip.readUInt16LE(local+28);
|
||||
const compressed=zip.subarray(start,start+zip.readUInt32LE(offset+20));
|
||||
bundle=(zip.readUInt16LE(offset+10)===8?inflateRawSync(compressed):compressed).toString();
|
||||
}
|
||||
offset+=46+nameLength+extraLength+commentLength;
|
||||
}
|
||||
assert.ok(bundle);
|
||||
const official=bundle.replace(/export \{ index as default \};\s*\/\/# sourceMappingURL=index.js.map\s*$/,
|
||||
'window.__official = index;');
|
||||
assert.notEqual(official,bundle);
|
||||
const react=new Proxy({Fragment:'fragment',Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props,children}),
|
||||
createContext:value=>({Provider:()=>{},Consumer:()=>{},_currentValue:value}),
|
||||
forwardRef:fn=>fn,memo:fn=>fn,useState:value=>[value,()=>{}],
|
||||
useContext:ctx=>ctx?._currentValue,useMemo:fn=>fn(),useCallback:fn=>fn,
|
||||
useRef:value=>({current:value}),useEffect(){}},
|
||||
{get:(target,key)=>target[key]??(()=>{})});
|
||||
const window={React:react,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
|
||||
const elements=new Map();
|
||||
const context=vm.createContext({window,navigator:{language:'en'},parent:{postMessage(){}},
|
||||
document:{getElementById:id=>{if(!elements.has(id))elements.set(id,{});return elements.get(id);}},
|
||||
addEventListener(){},setTimeout:()=>1,clearTimeout(){},console,URL,Blob});
|
||||
vm.runInContext(hostTest,context);
|
||||
context.DFL=window.DFL;
|
||||
context.SP_REACT=window.SP_REACT;
|
||||
context.SP_JSX=window.SP_JSX;
|
||||
vm.runInContext(official,context,{timeout:2000});
|
||||
const plugin=window.__official();
|
||||
assert.equal(typeof window.DFL.findModuleChild,'function');
|
||||
assert.equal(window.DFL.findModuleChild(()=>{}),null); // Steam has no matching module in the VR panel.
|
||||
assert.equal(plugin.content?.type?.name,'QuickAccessView');
|
||||
const panel=plugin.content.type();
|
||||
assert.ok(panel?.type); // Read-only settings render with empty local data.
|
||||
assert.equal(typeof plugin.onDismount,'function');
|
||||
plugin.onDismount();
|
||||
@@ -10,8 +10,16 @@ const React={Fragment:Symbol('fragment'),Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
|
||||
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
|
||||
const listeners=[];
|
||||
const calls=[];
|
||||
const parent={postMessage(message){
|
||||
if(message.type!=='framey:call')return;
|
||||
calls.push(message);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,
|
||||
data:{type:'framey:reply',id:message.id,result:true}});
|
||||
}};
|
||||
const context=vm.createContext({window,document:{getElementById:()=>({})},
|
||||
navigator:{language:'en'},parent:{postMessage(){}},addEventListener(){},
|
||||
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(host,context);
|
||||
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
|
||||
@@ -23,3 +31,8 @@ const content=plugin.content.type();
|
||||
assert.equal(content.type,window.DFL.PanelSection);
|
||||
assert.equal(content.props.title,'Framey settings sample');
|
||||
assert.equal(content.children[0][0].type,window.DFL.PanelSectionRow);
|
||||
assert.equal(plugin.settings.type,plugin.content.type);
|
||||
assert.equal(plugin.frameyQuickActions[0].label,'Read saved setting');
|
||||
assert.equal(await plugin.frameyQuickActions[0].onClick(),'Saved setting is on');
|
||||
assert.equal(calls[0].method,'get_setting');
|
||||
assert.deepEqual(Array.from(calls[0].args),['enabled',false]);
|
||||
@@ -41,6 +41,15 @@ class ArchiveTests(unittest.TestCase):
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertEqual([(p['id'], p['author']) for p in frame_decky_store.catalog()], [(42, 'Panel Author')])
|
||||
|
||||
def test_catalog_marks_underscore_root_and_omits_ambiguous_store_ids(self):
|
||||
records = [{'id': 42, 'name': 'Needs root', 'tags': ['_root'],
|
||||
'versions': [{'name': '1.0', 'hash': 'a' * 64}]},
|
||||
{'id': 43, 'name': 'First', 'versions': [{'name': '1.0', 'hash': 'b' * 64}]},
|
||||
{'id': 43, 'name': 'Second', 'versions': [{'name': '2.0', 'hash': 'c' * 64}]}]
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen',
|
||||
return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertEqual([(p['id'], p['root']) for p in frame_decky_store.catalog()], [(42, True)])
|
||||
|
||||
def test_accepts_hashed_non_root_archive(self):
|
||||
data, digest = archive()
|
||||
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""The pinned store bundles remain valid API v2 Framey panel inputs."""
|
||||
"""Pinned official Decky bundles remain valid Framey panel inputs."""
|
||||
import sandbox # noqa: F401
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -19,6 +21,12 @@ from framey_official import PLUGINS, archive
|
||||
|
||||
|
||||
class OfficialBundles(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
|
||||
def test_hltb_frontend_starts_with_framey_module_stub(self):
|
||||
script = Path(__file__).parent / 'page/framey_hltb.mjs'
|
||||
result = subprocess.run(['node', str(script)], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_pinned_store_archives_install_and_pack_into_framey_panel(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
plugins = Path(root)
|
||||
@@ -30,7 +38,7 @@ class OfficialBundles(unittest.TestCase):
|
||||
plugin['folder'])
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
||||
manifest = json.loads(z.read(plugin['folder'] + '/plugin.json'))
|
||||
self.assertEqual(manifest['api_version'], 2)
|
||||
self.assertEqual(manifest['api_version'], plugin['api_version'])
|
||||
z.extractall(plugins)
|
||||
folder = plugins / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Framey hosts only store-owned API v2 frontends and scopes backend calls."""
|
||||
"""Framey hosts store-owned modern Decky frontends and scopes backend calls."""
|
||||
import sandbox # noqa: F401
|
||||
import gzip
|
||||
import http.client
|
||||
@@ -58,10 +58,16 @@ class Panel(unittest.TestCase):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.panel(15)
|
||||
|
||||
def test_legacy_root_symlink_and_unmanaged_are_refused(self):
|
||||
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
|
||||
manifest = self.folder/'plugin.json'
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':0}))
|
||||
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':1}))
|
||||
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
|
||||
(self.folder/'dist/index.js').write_text(
|
||||
'// __DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit\n'
|
||||
"export default () => ({content: SP_REACT.createElement('p', null, 'OK')});")
|
||||
self.assertIn('framey-bundle', framey_panel.panel(14).decode())
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':['root']}))
|
||||
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
|
||||
|
||||
@@ -59,8 +59,13 @@ def catalog():
|
||||
'description': str(item.get('description') or '')[:300],
|
||||
'author': author[:100],
|
||||
'version': latest['name'][:40], 'sha256': latest['hash'],
|
||||
'root': 'root' in tags})
|
||||
return result
|
||||
'root': 'root' in tags or '_root' in tags})
|
||||
# A repeated store ID makes a clicked row ambiguous: change() resolves by ID
|
||||
# and could install a different archive than the one the user reviewed.
|
||||
counts = {}
|
||||
for item in result:
|
||||
counts[item['id']] = counts.get(item['id'], 0) + 1
|
||||
return [item for item in result if counts[item['id']] == 1]
|
||||
|
||||
|
||||
def archive_info(data, expected_hash, expected_name):
|
||||
|
||||
+8
-4
@@ -109,6 +109,10 @@
|
||||
const ButtonItem = ({label, description, children, disabled, onClick}) => R.createElement(Field, {label, description},
|
||||
R.createElement('button', {type:'button', disabled:!!disabled,
|
||||
onClick:e=>{if(!disabled)onClick?.(e);}}, children || label));
|
||||
const DialogCheckbox = ({label, checked, defaultChecked, disabled, onChange}) =>
|
||||
R.createElement('label', null,
|
||||
R.createElement('input', {type:'checkbox', checked, defaultChecked, disabled:!!disabled,
|
||||
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}), label);
|
||||
const TextField = ({label, description, value, placeholder, disabled, bIsPassword,
|
||||
mustBeNumeric, mustBeURL, mustBeEmail, onChange}) => R.createElement(Field, {label, description},
|
||||
R.createElement('input', {type:bIsPassword?'password':mustBeNumeric?'number':mustBeURL?'url':mustBeEmail?'email':'text',
|
||||
@@ -147,17 +151,17 @@
|
||||
const DFL = window.DFL = {
|
||||
definePlugin: init => init,
|
||||
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem,
|
||||
Dropdown: DropdownItem, ButtonItem, TextField, SliderField,
|
||||
Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField,
|
||||
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
|
||||
DialogButton: passthrough('button'), ConfirmModal,
|
||||
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
|
||||
staticClasses: {Title:'framey-title'},
|
||||
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null),
|
||||
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), CloseSideMenus:()=>navigate(null),
|
||||
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
|
||||
showModal: element => modalRoot.render(R.createElement('section', null,
|
||||
R.createElement('button', {onClick:closeModal}, 'Close'), element)),
|
||||
// Steam app, library, and store patches have no target inside this panel.
|
||||
afterPatch: () => () => {}, createReactTreePatcher: () => () => {},
|
||||
findInReactTree: () => null, findModuleExport: () => null,
|
||||
findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null,
|
||||
appDetailsClasses: {}, appDetailsHeaderClasses: {},
|
||||
useParams: () => ({}),
|
||||
};
|
||||
|
||||
+8
-4
@@ -17,7 +17,7 @@ class PanelError(ValueError):
|
||||
|
||||
|
||||
def local_bundle(plugin_id):
|
||||
"""Read only an API v2 bundle with a store receipt; reject linked paths."""
|
||||
"""Read a modern Decky bundle with a store receipt; reject linked paths."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0 or not PLUGINS.is_dir() or PLUGINS.is_symlink():
|
||||
raise FileNotFoundError('Framey plugin unavailable')
|
||||
for folder in PLUGINS.iterdir():
|
||||
@@ -43,11 +43,15 @@ def local_bundle(plugin_id):
|
||||
if not isinstance(data, dict):
|
||||
raise FileNotFoundError('Framey plugin manifest unavailable')
|
||||
flags = data.get('flags', [])
|
||||
if data.get('api_version') != 2 or not isinstance(flags, list) or any(x in flags for x in ('root', '_root')):
|
||||
raise FileNotFoundError('This Decky frontend is not API v2 or needs root')
|
||||
version = data.get('api_version')
|
||||
if type(version) is not int or version not in (1, 2) or not isinstance(flags, list) or any(x in flags for x in ('root', '_root')):
|
||||
raise FileNotFoundError('This Decky frontend is not API v1/v2 or needs root')
|
||||
if not entry.is_file() or entry.stat().st_size > 8*1024*1024:
|
||||
raise FileNotFoundError('Framey plugin frontend unavailable')
|
||||
return entry.read_bytes()
|
||||
bundle = entry.read_bytes()
|
||||
if version == 1 and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle:
|
||||
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
|
||||
return bundle
|
||||
except (OSError, ValueError):
|
||||
raise FileNotFoundError('Framey plugin files are invalid')
|
||||
raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store')
|
||||
|
||||
Reference in new issue
Block a user