mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 07:01:01 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd2e2d60db | ||
|
|
bc885e87d0 |
No files matched your search
@@ -10,7 +10,7 @@ on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
@@ -110,9 +110,6 @@ 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.
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
existing files in the old flat folder are left in place.
|
||||
[Copying guide](docs/recording.md).
|
||||
|
||||
- Keep Steam clip exports separate for each headset, Steam account and nested
|
||||
recording session. Manual export and automatic copying use the same folders;
|
||||
existing flat exports stay untouched and do not mark unrelated clips saved.
|
||||
[Recording guide](docs/recording.md#steam-clips).
|
||||
|
||||
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
|
||||
Highlight exact PnP instances and known state changes after a dropout;
|
||||
incomplete inventories stay unknown. A Forget button clears the comparison.
|
||||
@@ -230,8 +225,3 @@
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
|
||||
- Keep failed automatic screenshot and clip copies visible after three attempts.
|
||||
**Resume** and **Copy everything now** allow another try without restarting;
|
||||
headset switches cannot use another headset's list or saved-file history.
|
||||
[Copying guide](docs/recording.md).
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.36",
|
||||
"version": "0.4.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.36",
|
||||
"version": "0.4.30",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.36",
|
||||
"version": "0.4.30",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -55,8 +55,7 @@
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json",
|
||||
"framey-compatibility.json"
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
Current for Frame Control **v0.4.30**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.36 tag `1701e62`, Frame OS 0.5.6 build `20261022.6153909`: [release smoke evidence](evidence/v0436-release-smoke-2026-10-25.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30.0.2 on a Linux host: 187.367 s, 720p30 H.264/AAC stereo, 5,621 frames, no encoder skips; room passthrough appeared in the recording | No | Synthetic tone was measured, not real game audio or audible listening. Default audio led by about 0.43 s in this setup; a measured +480 ms OBS offset removed residual skew at test resolution. Source-image latency was 1.431 s in one trial; other hosts and long sessions are unverified | 25 Oct, v0.4.30 package plus PR #219 runtime: [validation](evidence/obs-validation-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | Windows 11 VM helper fixture passed, but a native bridge to the real Frame sent no packets | In-app OBS controls remain hidden; native Windows OBS and firewall path are unverified | 25 Oct, PR #219: [validation](evidence/obs-validation-2026-10-25.md), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30 on a Linux host; H.264 video and a synthetic 440 Hz output tone reached a saved recording | No | Real game audio, audible monitoring, perceptual A/V sync and awake headset view | 25 Oct, PR #174 head, shipped in v0.4.22: [OBS log](evidence/obs-media-source-real-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | No OBS/Frame VM end-to-end test | App Start/Stop path has automated Windows checks only | 25 Oct, v0.4.23: [Windows PR #179](https://github.com/saphid/frame-control/pull/179), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
@@ -22,8 +21,7 @@ Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical
|
||||
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified. The new large read-only status screen has browser layout and packaging checks only | 25 Oct, v0.4.29 device check and v0.4.31 status screen: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [PR #220](https://github.com/saphid/frame-control/pull/220), [panel guide](headset-app.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. v0.4.35 treats `_root` store tags as root-required and hides duplicate IDs; this has test coverage, not a live store check. v0.4.36 stops a store install or update when the catalog hash changed after review; this was tested with a fake Frame, not with a live store change on the headset. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.36 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [PR #246](https://github.com/saphid/frame-control/pull/246), [PR #253](https://github.com/saphid/frame-control/pull/253), [compatibility table](decky-frame.md) |
|
||||
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Official Steam Status rendered in local Chromium; a session-only cache avoided its earlier storage errors. A later local browser check restored a bounded per-plugin cache after panel reopen; live Decky and offline behavior were not tested. Fake Frame tests cover setting read/write through update and reinstall and install/remove two pinned official API v2 archives plus the modern API v1 Storage Cleaner archive. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle with a settings tab and read-only backend quick action. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set. Storage Cleaner 1.4.0 showed an empty-data panel and four disabled cleanup buttons in local Chromium with a stub backend; no cleanup action was tested. HLTB for Deck 2.0.12 built a shallow settings view and Bluetooth 3.1.1 built its initial panel in local frontend tests without effects or backend calls. Their badges, search, pairing and Bluetooth changes remain unverified. A hash match, local panel and fake Frame install do not prove VR use. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.36: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [#236](https://github.com/saphid/frame-control/pull/236), [#237](https://github.com/saphid/frame-control/pull/237), [#238](https://github.com/saphid/frame-control/pull/238), [#239](https://github.com/saphid/frame-control/pull/239), [#240](https://github.com/saphid/frame-control/pull/240), [#241](https://github.com/saphid/frame-control/pull/241), [#243](https://github.com/saphid/frame-control/pull/243), [#244](https://github.com/saphid/frame-control/pull/244), [#245](https://github.com/saphid/frame-control/pull/245), [#247](https://github.com/saphid/frame-control/pull/247), [#248](https://github.com/saphid/frame-control/pull/248), [#251](https://github.com/saphid/frame-control/pull/251), [#252](https://github.com/saphid/frame-control/pull/252), [#253](https://github.com/saphid/frame-control/pull/253), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
|
||||
| Experimental Frame Control SteamVR panel | 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 | 25 Oct, v0.4.29: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [panel guide](headset-app.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; ProtonDB Badges loaded in Decky backend and Steam shared UI registry | No | Decky and plugin UI in the VR shell, wearer-visible badges, controller input and other plugins remain unverified | 25 Oct, v0.4.29: [PR #195](https://github.com/saphid/frame-control/pull/195), [PR #202](https://github.com/saphid/frame-control/pull/202), [headset findings](decky-frame.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.
|
||||
+3
-44
@@ -42,39 +42,6 @@ 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 with a supported
|
||||
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
|
||||
A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
|
||||
catalogue. All 11 had a frontend bundle, but their manifests differed:
|
||||
|
||||
| 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 | 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 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
|
||||
|
||||
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
|
||||
has each store ID, exact version, SHA-256, manifest field, and bundle check.
|
||||
The archive audit itself is static PC inspection. Later local host and fake
|
||||
Frame tests cover the Storage Cleaner, HLTB, CSS Loader and SteamGridDB cases noted above; none shows
|
||||
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
|
||||
remain the only device evidence for these versions. A new store version needs a
|
||||
new check because its manifest and binaries can change.
|
||||
|
||||
Pinned CSS Loader 2.1.3 and SteamGridDB 1.8.1 archives now pass the fake Frame
|
||||
managed install/remove flow. CSS Loader's `plugin.json` has no `api_version` and
|
||||
its older IIFE bundle passes the Framey packaging check added in
|
||||
[#261](https://github.com/saphid/frame-control/pull/261). The fixture does not
|
||||
render its theme controls. SteamGridDB declares API v1 and its bundle passes Framey's packaging
|
||||
check, but it looks up Steam library modules while loading and patches game
|
||||
context menus. Those modules and menus do not exist in Framey's isolated
|
||||
iframe, so its artwork picker cannot be called a usable VR panel. Framey does
|
||||
not apply its Steam library changes. These checks did not run CSS themes or
|
||||
modify game artwork on the real Frame.
|
||||
|
||||
The new store transaction was also exercised on the same real Frame:
|
||||
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
|
||||
store update → store remove → managed loader remove**. Status reported the
|
||||
@@ -90,12 +57,9 @@ change Frame Control owned plugin folders under
|
||||
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
|
||||
back an update if the service restart fails. The current VR shell does not
|
||||
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
|
||||
shows installed plugins and store actions. Its separate Framey iframe 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-decky-api-compatibility).
|
||||
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.
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
@@ -178,9 +142,6 @@ on Steam Deck hardware may fail even if the loader runs. Frame Control lists
|
||||
plugins already present in its managed folder and the upstream `~/homebrew`
|
||||
folder. The opt-in store manager installs compatible archives only into its
|
||||
own folder. It cannot make Steam Deck QAM plugin pages visible in Frame VR.
|
||||
The experimental [Framey panel host](framey-loader.md) can render compatible
|
||||
plugin settings inside Frame Control's separate SteamVR dashboard panel.
|
||||
The real headset still needs a worn-user visibility and controller check.
|
||||
|
||||
The latest tested release was
|
||||
[Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0),
|
||||
@@ -199,8 +160,6 @@ 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.
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
# Auto-sync retry and headset-selection reliability
|
||||
|
||||
Date: 25 October 2026. Base: `d79efb4`, Frame Control v0.4.30.
|
||||
Issue: [#214](https://github.com/saphid/frame-control/issues/214).
|
||||
Environment: Linux x86_64, Python 3.12.3, Node 24.15.0.
|
||||
|
||||
## Reproduced problems
|
||||
|
||||
The existing 19 auto-sync tests passed on the base revision. Added regressions
|
||||
failed before the implementation: an exhausted clip lost its error, explicit
|
||||
retry did not recover failed screenshots or clips, and switches around listing
|
||||
or copying continued the old pass. The original callback interface also had no
|
||||
way to carry the tick's captured device into the server list reservation.
|
||||
|
||||
The final fake-source tests exercise these boundaries:
|
||||
|
||||
- Fail screenshots or clips three times, keep checking without further copy
|
||||
attempts, and retain the source error plus an explicit retry instruction.
|
||||
- Recover the source and use either Resume or Copy everything now without
|
||||
restarting. Successful copies stay deduplicated; Resume retains the ledger
|
||||
even after a local file is deleted. Copy everything now retains its documented
|
||||
ability to restore deleted files and include older media.
|
||||
- Reset an exhausted budget while the source still fails: another three
|
||||
attempts are allowed, rather than unlimited background attempts.
|
||||
- Save an exhausted item by hand and clear its stale failure on the next list.
|
||||
- Switch after tick captures A, during a list, after screenshot bookkeeping,
|
||||
between media kinds, before a transfer, or before transfer bookkeeping.
|
||||
Interrupted passes do not mark the other headset's IDs or continue copying.
|
||||
- Switch to B exactly at callback entry through the production `_sync_list`
|
||||
adapter. `working(A)` refuses before calling the source list, leaving both
|
||||
ledgers and copy callbacks untouched. The same production reservation blocks
|
||||
a device switch during a list and rejects stale screenshot/clip transfers.
|
||||
- Execute the production Settings renderer in Node: Resume is available for
|
||||
copy errors and disk-full pauses, and hidden for a healthy state.
|
||||
|
||||
## Implementation boundary
|
||||
|
||||
Each list callback now receives the captured device, like the copy callbacks.
|
||||
The existing server `working(meant)` mechanism checks that intended headset
|
||||
under `_work_lock` while reserving work; `devices_post` uses the same lock to
|
||||
refuse switches during that work. The worker also checks for interruption
|
||||
around lists and transfers. No new device lock or clip destination logic is
|
||||
introduced. The server diff is confined to `_sync_list`.
|
||||
|
||||
The failed-attempt and error maps are in-memory and reset on explicit retry or
|
||||
restart. The successful-copy ledger and settings persistence retain their
|
||||
existing format. Disk-full pauses, offline backoff, clip settling and the
|
||||
three-attempt limit remain covered by the original tests.
|
||||
|
||||
## Checks
|
||||
|
||||
- `python3 -m unittest discover -s tests -p test_autosync.py -v`: 33 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_shot_copy.py -v`: 2 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_clips.py -v`: 34 passed,
|
||||
including local ffmpeg coverage.
|
||||
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
|
||||
36 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_page_scripts.py -v`: 5 passed.
|
||||
- `node --test site/test/*.test.mjs`: 2 test files passed.
|
||||
- `python3 -m py_compile ui/frame_autosync.py ui/server.py tests/test_autosync.py`
|
||||
and `git diff --check`: passed.
|
||||
|
||||
- `python3 -m unittest discover -s tests -v`: 1,525 tests run in 184.876
|
||||
seconds, passed with 16 skips. Ran with sandbox escalation for loopback
|
||||
sockets; test isolation still used throwaway app/SSH data and mocked headset
|
||||
transport. Final output:
|
||||
|
||||
```text
|
||||
Ran 1525 tests in 184.876s
|
||||
OK (skipped=16)
|
||||
```
|
||||
|
||||
## Limits and handoff
|
||||
|
||||
All new regressions use fake sources and mocked transport; they do not prove
|
||||
real-headset transfer or UI rendering. No headset was accessed: its shared test
|
||||
lock was held by another task. The Docker fake-Frame end-to-end suite and
|
||||
Windows/macOS runs were not performed locally. The coordinator owns review and
|
||||
merge; this task does not release, change PR #113, or publish announcements.
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Bluetooth 3.1.1 compatibility fixture, 25 October 2026
|
||||
|
||||
The unmodified official Decky store archive for Bluetooth 3.1.1 (store ID 18,
|
||||
SHA-256 `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5`)
|
||||
passes Frame Control's archive rules. The fake Frame e2e harness installed it
|
||||
through the store API, verified the managed receipt and frontend bytes, then
|
||||
removed it. An earlier real Frame check recorded the backend's `Bluetooth
|
||||
plugin loaded` line while SteamVR was in standby.
|
||||
|
||||
In a local Node host test, its official frontend starts and builds an initial
|
||||
panel tree. Framey supplies empty Steam token maps and readable English
|
||||
fallbacks for the three Bluetooth labels that lack plugin-owned fallbacks.
|
||||
The test leaves React effects and click
|
||||
handlers inert and sends no backend calls. It does not inspect paired devices,
|
||||
change Bluetooth state, exercise a controller, or prove the panel is visible in
|
||||
VR. No worn-headset check has passed for this plugin.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Framey loader evidence, 25 October 2026
|
||||
|
||||
## Local browser and fake Frame
|
||||
|
||||
The official Decky store returned 116 validated entries. ProtonDB Badges was
|
||||
entry 14, release 1.3.4, SHA-256
|
||||
`90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358`.
|
||||
Its published ZIP passed Frame Control's store manifest and path validator. The current
|
||||
`dist/index.js` bundle was loaded in an opaque-origin iframe in local Chromium
|
||||
with the Framey React/DFL shim. The [browser screenshot](framey-browser-2026-10-25.png)
|
||||
shows the plugin's settings panel. A pointer click on **Enable Badge on
|
||||
Library** issued `set_setting('settings', …)` through the iframe's request
|
||||
bridge. The local test parent returned a stub backend reply; it did not use a
|
||||
real headset or Decky backend. A separate fake Frame CEF fixture returned
|
||||
`get_setting` and `set_setting` responses through Frame Control's CDP bridge;
|
||||
that fixture does not model the real Decky process.
|
||||
|
||||
The disposable fake Frame end-to-end harness passed the store catalogue,
|
||||
acknowledgement and HTTP auth checks, hash-verified install, update, and
|
||||
removal through the real Frame Control HTTP server and SSH transaction. The
|
||||
full fake Frame suite passed 48 tests; the full unit suite passed 1,559 tests
|
||||
(16 skipped). Unit tests covered unsafe archives,
|
||||
unmanaged-folder preservation, asset isolation, backend-call scope,
|
||||
and safe aborts of the worn-headset check.
|
||||
|
||||
## Real headset status
|
||||
|
||||
Read-only SSH on Frame OS 0.5.6 found battery at 98–99%. SteamVR activity was
|
||||
initially 3 (standby), briefly 0, then 3 again. During the active probe the
|
||||
shared `/tmp/frame-test.lock` belonged to `obs-aacapv3evh-20261025`. It later
|
||||
became free after SteamVR returned to standby. Framey did not acquire, replace,
|
||||
or remove it. The active probe found no installed Frame Control VR panel or
|
||||
Framey plugin; the scripted worn check exited before acquiring a lock or
|
||||
altering the headset. There is **no** wearer-visible VR
|
||||
menu screenshot, controller-input proof, or live settings-persistence result
|
||||
from this round.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
@@ -1,19 +0,0 @@
|
||||
# Framey cache check, 25 October 2026
|
||||
|
||||
The official Steam Status 1.2.3 ZIP pinned in
|
||||
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
|
||||
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
|
||||
allow-popups"`, so its native storage remained unavailable. The new Framey
|
||||
storage facade accepted a set/get probe without a `SecurityError`. Steam
|
||||
Status rendered and issued four `get_settings` calls to a stub parent backend.
|
||||
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
|
||||
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
|
||||
snapshot and the plugin wrote the same cache key again. The browser log had
|
||||
zero `SecurityError` or `localStorage` errors.
|
||||
|
||||
The parent was a local test page with in-memory Decky backend replies. One
|
||||
public status fetch also failed in the test browser, so this check does not
|
||||
establish offline correctness or the freshness of Steam Status data. The
|
||||
per-plugin save/reopen protocol, stale panel session rejection, and cache
|
||||
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
|
||||
live Decky settings, VR visibility, and controller input remain unverified.
|
||||
@@ -1,418 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:12:47.296778+00:00",
|
||||
"method": "Local Chromium actual Framey headset page and opaque official plugin iframe; production method/event bridge relayed by SSH to real ARM64 Decky 3.3.0 in a private removable user service. Test relay restricts RPC methods and timer durations. Backend code is unmodified, no fake plugin replies. Only file paths to private install and outer status/data APIs differ. Not worn VR.",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"hostSourceSha256": {
|
||||
"framey-host.js": "3bad52546c0b78c20e6479c91899f1f3864b74687c6fce37e776abededfb5f1c",
|
||||
"framey-events.js": "89046f4c89bdab0f578b910170a044d5ada540db0f7b71d6a370c68b4b008c3f",
|
||||
"framey_panel.py": "8ca4b3ba5f0d51c51bb3601f9ccd24c4af397fbf8f63c383a3042e1ae3f7b39b",
|
||||
"headset.html": "ebaf1711639a929ec7287c01dbbbe8eb08f000b852fdc877f31c9d99e5fe69fc"
|
||||
},
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"powerSettingsChanged": false,
|
||||
"plugins": [
|
||||
{
|
||||
"id": 19,
|
||||
"name": "Notebook",
|
||||
"version": "0.1.5",
|
||||
"sha256": "c35b09de4bba4b4157fbe84cb7b0d20fc3924f78fa3c48ae17686b4314c1cc0c",
|
||||
"calls": [
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 0,
|
||||
"empty": true,
|
||||
"data": ""
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "savePage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"timestamp": 1792973680303
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [
|
||||
{
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [],
|
||||
"checks": {
|
||||
"pointerStrokeSaved": true,
|
||||
"sameStrokeLoadedAfterBackendRestart": true,
|
||||
"defaultAppId": 0,
|
||||
"existingNotesTouched": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312",
|
||||
"calls": [
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
300
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "cancel_timer",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
5
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
294.9945251941681
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
289.99201130867004
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
284.9877219200134
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
-0.005679607391357422
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"event": "simple_timer_event",
|
||||
"args": [
|
||||
"Your session has ended!",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"uiStart300Seconds": true,
|
||||
"uiCancel": true,
|
||||
"shortTimer5Seconds": true,
|
||||
"completionToastVisible": true,
|
||||
"recentTimersAndSubtleModeSurvivedRestart": true,
|
||||
"subscriptionsAfterPanelClose": 0
|
||||
},
|
||||
"upstreamError": "Simple Timer 1.0.0 _main after restarting with an expired timer raises AttributeError: 'Plugin' object has no attribute 'timer_task' (main.py:94,129). Frontend read requests still load recent timers and subtle mode."
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"serviceActive": false,
|
||||
"independentlyRechecked": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
"regressions": {
|
||||
"unitTests": 1606,
|
||||
"unitSkips": 16,
|
||||
"browserPlugins": 116,
|
||||
"browserReady": 96,
|
||||
"browserFailed": 20,
|
||||
"fakeFrameInstalled": 60,
|
||||
"fakeFrameRejected": 56
|
||||
},
|
||||
"limitations": [
|
||||
"Timer sound asset and suspend action unsupported; no suspend/power action run.",
|
||||
"Closing a Framey panel removes its event channel; it is not a global background notification service.",
|
||||
"Notebook default notebook only (appid 0): no per-game auto-selection or background artwork.",
|
||||
"No worn VR, physical controller, keyboard, or optical check."
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
# Legacy Decky frontend bridge
|
||||
|
||||
The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures)
|
||||
has no `api_version` field and returns its initializer from a Decky IIFE.
|
||||
Framey now wraps that expression in an ES module inside the existing
|
||||
opaque-origin iframe. It passes a legacy `serverAPI` to the initializer.
|
||||
|
||||
Local Chromium ran the unchanged official frontend, including React effects,
|
||||
with all backend calls rejected and network access blocked. It emitted
|
||||
`framey:ready` without a JavaScript startup error during the observation window.
|
||||
This proves initialization, not playback, MPRIS access, or a useful populated UI.
|
||||
|
||||
The fake Frame installed, packed, checked the frontend bytes and removed this
|
||||
exact archive. Its synthetic Sample Panel also read a legacy keyword argument,
|
||||
wrote a setting, and read that setting through the modern API. This exercised
|
||||
the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was
|
||||
checked against Decky's `handle_plugin_method_call_legacy`: it calls with a
|
||||
keyword object and returns `{success, result}`. Invalid argument shapes and
|
||||
unmanaged plugin IDs are rejected.
|
||||
|
||||
Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'`
|
||||
(21 passed), `node tests/page/framey_actions.mjs`, and
|
||||
`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or
|
||||
headset state was changed by these checks. Real Frame backend behavior,
|
||||
controller input, playback and worn VR use remain unverified.
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"timeUtc": "2026-10-25T23:35:22.022586+00:00",
|
||||
"method": "Real Frame ARM64 Decky 3.3.0, isolated removable user service; explicit read-only plugin methods only",
|
||||
"plugins": [
|
||||
{
|
||||
"id": 14,
|
||||
"version": "1.3.4",
|
||||
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
|
||||
"method": "get_setting",
|
||||
"result": {
|
||||
"result": "default"
|
||||
},
|
||||
"errors": [
|
||||
"Transient CEF reconnect during Decky frontend injection; succeeded on retry."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 123,
|
||||
"version": "1.2.3",
|
||||
"sha256": "9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e",
|
||||
"method": "get_settings",
|
||||
"result": {
|
||||
"keys": [
|
||||
"enable_notification_antiflood",
|
||||
"enable_notifications",
|
||||
"gateway_api_key",
|
||||
"gateway_url",
|
||||
"refresh_interval_seconds",
|
||||
"show_history",
|
||||
"show_regions",
|
||||
"show_trending_games"
|
||||
],
|
||||
"defaultRefreshInterval": 120
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"service": {
|
||||
"created": true
|
||||
},
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"passed": true,
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"lockReleased": true,
|
||||
"cleanupErrors": []
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
# Framey session cache browser check, 25 October 2026
|
||||
|
||||
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
|
||||
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
|
||||
used a per-panel memory substitute. It did **not** change the iframe's sandbox
|
||||
flags or give plugin code Frame Control's UI key.
|
||||
|
||||
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
|
||||
from `tests/fixtures/framey-official/123.zip` (SHA-256
|
||||
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
|
||||
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
|
||||
four `get_settings` calls. That reply was a stub, not the Decky backend.
|
||||
|
||||
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
|
||||
The parent could not read the iframe document (`contentDocument` was null).
|
||||
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
|
||||
Managers and Players Online. Its public status request returned HTTP 200.
|
||||
No cache `SecurityError` appeared in the captured console, while the
|
||||
same archive had logged that error with the v0.4.34 host.
|
||||
|
||||
Node host tests also checked item updates, removal, clear, key order, string
|
||||
conversion, quota rejection, and preservation of an existing item after a
|
||||
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
|
||||
values **in memory**. Closing or reloading the panel loses this cache. This
|
||||
check did not test offline behavior, persistent plugin settings, a live Decky
|
||||
backend, SteamVR visibility, controller input, or headset performance.
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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.
|
Before Width: | Height: | Size: 69 KiB |
@@ -1,107 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:29:00Z",
|
||||
"scope": "Exact archived frontends only. Production Framey host with bounded SteamClient JSON cache, image fallback and inert Steam subscriptions/context. Opaque iframe; all backend calls and network denied. No headset or plugin backends used in this follow-up.",
|
||||
"baseHostCommit": "52a0974",
|
||||
"before": {
|
||||
"ready": 96,
|
||||
"failed": 20
|
||||
},
|
||||
"after": {
|
||||
"ready": 98,
|
||||
"failed": 18
|
||||
},
|
||||
"regressions": [],
|
||||
"observations": [
|
||||
{
|
||||
"id": 13,
|
||||
"name": "Pause Games",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"load_settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"name": "Tailscale Control",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_tailscale_state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_CurrentUser')",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_mapApps')",
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"fetch_playtime_information",
|
||||
"statistics_for_last_two_weeks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 120,
|
||||
"name": "Reroll",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_setting",
|
||||
"version"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tailscaleFocusedCheck": {
|
||||
"rendered": [
|
||||
"Settings",
|
||||
"Toggle Tailscale",
|
||||
"Exit Node IP",
|
||||
"Toggle LAN Access",
|
||||
"Advanced Settings",
|
||||
"Device Status",
|
||||
"Tailscale Setup"
|
||||
],
|
||||
"assetError": "Hardcoded localhost:1337 QR SVG blocked by image CSP",
|
||||
"actions": "Inspected only; no toggles, exit nodes, installs or connection actions"
|
||||
},
|
||||
"pauseGamesFocusedCheck": {
|
||||
"rendered": [
|
||||
"Pause before Suspend",
|
||||
"Pause on focus loss"
|
||||
],
|
||||
"actions": "Inspected only; no process signaling or suspend actions"
|
||||
},
|
||||
"storageChecks": [
|
||||
"Per-plugin cache survives panel reopen",
|
||||
"Missing key rejects Not found",
|
||||
"JSON serialization and cache quota enforced",
|
||||
"Malformed and stale-session cache updates rejected"
|
||||
],
|
||||
"unverified": [
|
||||
"Real backends for these plugins",
|
||||
"Tailscale network state/actions",
|
||||
"Live games and pause/resume",
|
||||
"Worn VR/controller/keyboard"
|
||||
]
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
# Framey unattended VR evidence, 25 October 2026 UTC
|
||||
|
||||
The committed runner exited **0** on the real Frame, using the existing
|
||||
`ssh lxso2 'ssh frame …'` route from lxso1. No person wore the headset.
|
||||
Frame OS **0.5.6**, build **20261022.6153909**, ARM64, SteamVR **2.18.2**,
|
||||
Decky **3.3.0**. The run began at **23:27:34 UTC**; the Frame's own clock/logs
|
||||
use UTC+11 and therefore show 26 October.
|
||||
|
||||
```sh
|
||||
python3 scripts/framey-vr-check.py --via lxso2 --exercise --output /tmp/framey-vr-verified
|
||||
```
|
||||
|
||||
[Full machine result](result.json) records source commit
|
||||
`cb9ad1b6cb7302008783d5f7fe42fa77a4dd43af`, bundle digest, before/after
|
||||
SteamVR listings, power settings, input observations, and private image hashes.
|
||||
The checked app code includes the sample settings-path and iframe-reopen fixes
|
||||
and main through `d77e2bd`. This is source-bundle testing, not a newly published
|
||||
release/AppImage test. The sample has a random test name and store-shaped local
|
||||
receipt; it is not presented as an official catalogue listing.
|
||||
|
||||
| Check | Observed | Scope |
|
||||
| --- | --- | --- |
|
||||
| Overlay registration and visibility | `IVROverlay_027.IsOverlayVisible=true` | Actual Frame Control gamescope overlay, not a browser fixture |
|
||||
| Transform | type **5**, DashboardTab; absolute getter returns **18**, WrongTransformType | Dashboard owns placement; no transform/dock setters used |
|
||||
| Texture queries | main size 0×0; image read **24**, InvalidTexture | Direct texture refresh not established; compositor capture used instead |
|
||||
| Stereo compositor | Before/after SHA-256 differ; final image visibly contains Framey tabs, quick action and saved result | Actual `IVRScreenshots_001` production screenshot path |
|
||||
| OBS mirror | Current run's random name and “Saved setting is on” appear after draining 60 frames | `/dev/video99` pixels; OBS app itself was not launched in this task |
|
||||
| OpenVR click | Move/down/up reaches intended control; iframe pointer event is trusted | `IVROverlayView_003.PostOverlayEvent`, no DOM `.click()` |
|
||||
| Live setting | off → on; real backend file `{"enabled":true}`; fresh iframe reads on | Actual Framey HTTP → Steam CEF → Decky websocket → Python backend; no stubs |
|
||||
| Tabs/quick action | Settings/Panel clicked; “Saved setting is on” returned | Actual shipped host controls and sample action |
|
||||
| Slider | 10 → **50** | Native OpenVR click on actual DFL slider, diagnostic UI-only value |
|
||||
| Text | **framey-ci** | OpenVR pointer focus, then CDP `Input.insertText`; not SteamVR keyboard proof |
|
||||
| Smooth scroll attempt | `scrollBefore=0`, `scrollAfter=0` | No scrolling established by this attempt; do not count it as a pass |
|
||||
| Battery | **99% → 99%** | Probed only under the owned lock |
|
||||
| Power restoration | original pause **false**, timeout **5.000000**, exact values read back | Temporary values false/120; no idle-suspend timer or proximity changes |
|
||||
| Cleanup | `cleanupErrors=[]`, `powerRecoveryRemoved=true`, `lockReleased=true` | Temporary managed services, sample/settings/profile, timer and restore script removed |
|
||||
|
||||
`IsDashboardVisible` and `IsActiveDashboardOverlay` are false while the panel is
|
||||
visible in these captures: this is an existing placed/docked panel. They cannot
|
||||
be used alone as a Framey visibility verdict. Activity changes from standby 3
|
||||
to active 0 without wearing the headset, so activity alone is not worn proof.
|
||||
Native OpenVR events also produce trusted pointers, so `event.isTrusted` alone
|
||||
is not physical-controller proof. The worn script now requires explicit
|
||||
`--worn-confirmed` and reports trusted pointer input with that narrower name.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
These are inset panel crops from the raw captures, with all other pixels
|
||||
replaced by solid black. No UI content was synthesized. [Image provenance](images.json)
|
||||
contains source hashes, mask polygons, crops and published hashes. Raw images
|
||||
remain private under `/tmp/framey-vr-verified` on lxso1. They are not committed.
|
||||
|
||||
## Bugs found and fixed
|
||||
|
||||
1. The author sample used `decky.decky_SETTINGS_DIR`, absent from real Decky
|
||||
3.3.0. The actual iframe displayed its backend `AttributeError`. Decky's
|
||||
[pinned module](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/plugin/imports/decky.py)
|
||||
exports `DECKY_PLUGIN_SETTINGS_DIR`. The sample, docs and Python test fixture
|
||||
now use that name. The old fixture invented the same wrong attribute and
|
||||
masked the failure. The live fixed backend save/reopen is in `result.json`.
|
||||
2. Reopening the same plugin changed only `#session`, so Chromium retained the
|
||||
old document/host/cache session. It could retain an error and eventually
|
||||
show a false “No compatible panel loaded” message. A fresh query parameter
|
||||
forces a new document while keeping the fragment session. The cache
|
||||
regression now checks document URLs, rather than manually assuming a new
|
||||
iframe. The real reopen with the saved setting passed.
|
||||
|
||||
## Methods tried and limits
|
||||
|
||||
Read-only `vrcmd` stats/overlay listings, the shipped OpenVR ABI, compositor
|
||||
logs and actual CEF/Chromium targets were inspected first. A simple
|
||||
`--handlewakeup` initially produced the Frame Control status panel in a stereo
|
||||
screenshot. The full test uses a bounded screen timeout so it stays active.
|
||||
Decky's service startup reloads Steam's web helper. During exploratory service
|
||||
recreation, SteamVR logged `UpdateInputFocus: Failed to give focus to panel`
|
||||
and returned a hidden, zero-sized main overlay despite a successful focus
|
||||
request. Retagging only the test browser's `STEAM_GAME` property recovered it;
|
||||
no Steam, SteamVR, gamescope or headset restart was needed. The final run did
|
||||
not need this recovery. The runner records it when used.
|
||||
|
||||
The first mirror image was demonstrably stale: it contained a previous test's
|
||||
random plugin name while the contemporaneous stereo image contained the current
|
||||
one. Reading 60 frames before selecting the mirror image fixed that observation;
|
||||
the final two published images contain the same current run name.
|
||||
|
||||
The diagnostic itself initially failed its cleanup check because an idle
|
||||
systemd watchdog service was already unloaded. It restored power and removed
|
||||
the test services, retained the lock, then the exact owned remainder was cleaned
|
||||
up. Cleanup now checks `LoadState` and tolerates an already-unloaded transient
|
||||
unit. Subsequent full runs, including the committed run here, exited 0 with no
|
||||
cleanup errors. A four-minute power-recovery timer was created and cancelled;
|
||||
forced host death or network loss was not fault injected.
|
||||
|
||||
A null driver or virtual tracked controller was not needed for pointer delivery
|
||||
and was not installed. No root/system files, tracking/proximity configuration,
|
||||
Steam global binaries, or spatial transforms were edited. PR #113 was untouched.
|
||||
This check does not establish every official plugin, sustained frame rate,
|
||||
backend persistence across Steam/Decky reboot, physical controller tracking or
|
||||
haptics, a functioning virtual keyboard, native scrolling, comfort or legibility
|
||||
through the optics. Follow the [three-minute worn checklist](../../framey-vr-check.md#three-minute-worn-checklist).
|
||||
|
||||
## Local checks
|
||||
|
||||
- Full Python suite after integrating current main and worn-confirmation gate:
|
||||
**1,595 tests, 16 skipped, OK**. The final added scroll/mirror helpers also
|
||||
passed the **24 focused Framey tests** and the real-device run above.
|
||||
- App and website test commands passed under **Node 24.22.1**, matching CI's
|
||||
Node major. System Node 22 cancelled unrelated Meta sign-in timer tests;
|
||||
those files were not changed. Node 24 was used for final app verification.
|
||||
- `git diff --check` and Python compilation passed. Hosted CI status belongs to
|
||||
the PR; local checks do not substitute for its macOS/Windows/ARM64 jobs.
|
||||
|
||||
A separate [locked cleanup audit](cleanup-verification.json) confirms both test
|
||||
services inactive, no panel bundle or Decky binary, no test plugins/profiles or
|
||||
remaining timers, power restored to false/5, battery 99→99, and its own lock
|
||||
released. It also removed the stale DevTools discovery file created during the
|
||||
earlier exploratory use of the original browser profile.
|
||||
@@ -1,2 +0,0 @@
|
||||
{"batteryBefore": [99], "headsetService": {"code": 4, "out": "inactive", "err": ""}, "deckyService": {"code": 4, "out": "inactive", "err": ""}, "panelBundleExists": false, "deckyBinaryExists": false, "testPluginFolders": [], "testProfiles": [], "timers": {"code": 0, "out": "", "err": ""}, "pause": {"code": 0, "out": "power.pauseCompositorOnStandby=false", "err": ""}, "timeout": {"code": 0, "out": "power.turnOffScreensTimeout=5.000000", "err": ""}, "removedOwnedStaleDevToolsDiscovery": true, "batteryAfter": [99], "lockReleased": true}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
[
|
||||
{
|
||||
"source": "stereo-private.png",
|
||||
"sourceSha256": "25944a3f3ffe0c217ef25cbc3f5e08a104f9d8901075b755c928ec5049a4cb3c",
|
||||
"published": "stereo-left-panel-redacted.png",
|
||||
"publishedSha256": "0de6fbf0f31d0587c8ba91817406acf455b8adb10d343bd56daa605f9a3051c7",
|
||||
"maskPolygon": [
|
||||
[
|
||||
50,
|
||||
435
|
||||
],
|
||||
[
|
||||
910,
|
||||
365
|
||||
],
|
||||
[
|
||||
940,
|
||||
920
|
||||
],
|
||||
[
|
||||
700,
|
||||
910
|
||||
],
|
||||
[
|
||||
350,
|
||||
945
|
||||
],
|
||||
[
|
||||
90,
|
||||
1000
|
||||
]
|
||||
],
|
||||
"crop": [
|
||||
50,
|
||||
365,
|
||||
941,
|
||||
1001
|
||||
],
|
||||
"background": "solid black; only inset panel pixels retained"
|
||||
},
|
||||
{
|
||||
"source": "mirror-private.png",
|
||||
"sourceSha256": "44d193943ca3164269feeea38f5cbfb0d58cefb79d6c0e8231eab4a5069985b8",
|
||||
"published": "mirror-panel-redacted.png",
|
||||
"publishedSha256": "86fa296a36171efca6a9aa627ec3589496df73f3c03f8f9428454c411a904a77",
|
||||
"maskPolygon": [
|
||||
[
|
||||
455,
|
||||
445
|
||||
],
|
||||
[
|
||||
1420,
|
||||
390
|
||||
],
|
||||
[
|
||||
1470,
|
||||
940
|
||||
],
|
||||
[
|
||||
1150,
|
||||
910
|
||||
],
|
||||
[
|
||||
850,
|
||||
940
|
||||
],
|
||||
[
|
||||
495,
|
||||
1020
|
||||
]
|
||||
],
|
||||
"crop": [
|
||||
455,
|
||||
390,
|
||||
1471,
|
||||
1021
|
||||
],
|
||||
"background": "solid black; only inset panel pixels retained"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB |
@@ -1,189 +0,0 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"time": "2026-10-25T23:27:34.130334+00:00",
|
||||
"controllerHardwareVerified": false,
|
||||
"wornVisualComfortVerified": false,
|
||||
"sourceCommit": "cb9ad1b6cb7302008783d5f7fe42fa77a4dd43af",
|
||||
"before": {
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"dashboardVisible": false,
|
||||
"stats": [
|
||||
{
|
||||
"activity_level": 3,
|
||||
"operation": "status",
|
||||
"version": "2.18.2",
|
||||
"version_timestamp": 1791331621
|
||||
}
|
||||
],
|
||||
"overlays": "---- OVERLAYS ---- \n'system.HeadsetView' -- 'Headset View', 2048x1280 not_visible VROverlayType_Basic \n'system.toast' -- 'Toast', not_visible VROverlayType_Basic \n'system.cursor.ibeam' -- 'system.cursor.ibeam', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000d00000002\n'system.pointer' -- 'system.pointer', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000e00000003\n'system.pointer.secondary' -- 'system.pointer.secondary', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000f00000004\n'system.standalone' -- 'Standalone Overlay Manager', not_visible VROverlayType_Basic \n'valve.steam.desktopgame.0' -- 'Gamescope', not_visible VROverlayType_Dashboard_Main \n flags=0x29A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000001100000006\n'valve.steam.desktopgame.0.thumb' -- 'Gamescope (Thumb)', 256x256 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x0000001200000007\n'valve.steam.desktopgame.0.layer1' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001300000008\n'valve.steam.desktopgame.0.layer2' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001400000009\n'valve.steam.desktopgame.0.layer3' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000150000000a\n'valve.steam.desktopgame.0.layer4' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000160000000b\n'valve.steam.desktopgame.0.layer5' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000170000000c\n'valve.steam.desktopgame.0.layer6' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000180000000d\n'valve.steam.desktopgame.0.layer7' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000190000000e\n'valve.steam.desktopgame.0.layer8' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001a0000000f\n'gamescope.gamescope-0.cursor.0' -- 'Mouse Cursor', not_visible VROverlayType_Basic \n flags=0x60000000 overlayhandle=0x0000001b00000010\n'system.systemui' -- 'systemui', 1860x2048 not_visible VROverlayType_Basic \n flags=0x08230008 -dash, +interactive, +smoothscroll, +premult, +clickstab overlayhandle=0x0000001c00000011\n'system.vrwebhelper.controllerbinding' -- 'controllerbinding', 2400x1350 not_visible VROverlayType_Dashboard_Main \n flags=0x09228108 -dash, +scrollwheel, +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000003800000012\n'system.vrwebhelper.controllerbinding.thumb' -- 'controllerbinding (Thumb)', 16x16 not_visible VROverlayType_Dashboard_Thumbnail \n'valve.steam.gamepadui.main' -- 'SteamVR', 0x0 not_visible VROverlayType_Dashboard_Main \n flags=0x69A28000 +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a400000014\n'valve.steam.gamepadui.main.thumb' -- 'Webhelper (Thumb)', 300x300 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x000001a500000015\n'valve.steam.browserview_MainBrowser_2' -- 'data:text/html,<body><%2Fbody>', 0x0 not_visible VROverlayType_Subview \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a600000016\n'valve.steam.gamepadui.keyboard' -- 'SteamVR - Keyboard', 0x0 not_visible VROverlayType_Basic \n flags=0x31A30000 +interactive, +smoothscroll, +premult, +keyboard, +2cursor overlayhandle=0x000001a700000017\n'valve.steam.gamepadui.bar' -- 'SteamVR - valve.steam.gamepadui.bar (85370000)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a800000018\n'valve.steam.gamepadui.barpopup.85370001' -- 'SteamVR - valve.steam.gamepadui.barpopup.85370001 (85370001)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a900000019\n'valve.steam.gamepadui.tooltip.85370002' -- 'SteamVR - valve.steam.gamepadui.tooltip.85370002 (85370002)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001aa0000001a\n'valve.steam.gamepadui.volumelevel' -- 'SteamVR - valve.steam.gamepadui.volumelevel (85370003)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001ab0000001b\n'valve.steam.gamepadui.floatingfooterLine truncated
|
||||
"pause": "power.pauseCompositorOnStandby=false",
|
||||
"timeout": "power.turnOffScreensTimeout=5.000000"
|
||||
},
|
||||
"powerRecovery": {
|
||||
"timer": "framey-vr-5e068da08ef73637-restore.timer",
|
||||
"script": "/tmp/framey-vr-5e068da08ef73637-restore.py",
|
||||
"originalPause": "false",
|
||||
"originalTimeout": "5.000000"
|
||||
},
|
||||
"deckyInstall": {
|
||||
"message": "Decky Loader v3.3.0 is running as a user service. The VR menu is experimental."
|
||||
},
|
||||
"panelInstall": {
|
||||
"message": "Installed Frame Control on the headset. Asked SteamVR to show Frame Control. Select its panel in the dashboard if it is hidden.",
|
||||
"version": "4968ad070054d005",
|
||||
"overlay": "valve.steam.desktopgame.2001947813"
|
||||
},
|
||||
"overlay": {
|
||||
"state": {
|
||||
"interface": "IVROverlay_027",
|
||||
"visible": true,
|
||||
"dashboardVisible": false,
|
||||
"activeDashboard": false,
|
||||
"texture": {
|
||||
"error": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"transform": {
|
||||
"error": 18,
|
||||
"typeError": 0,
|
||||
"type": 5,
|
||||
"origin": 0,
|
||||
"matrix": [
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
"error": 0,
|
||||
"method": 1,
|
||||
"scaleError": 0,
|
||||
"scale": [
|
||||
1920.0,
|
||||
1080.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"imageRead": {
|
||||
"error": 24,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
}
|
||||
},
|
||||
"beforeStereoSha256": "5d0635d02e44d7cf2d5c249e72ea01c5cd161996427c1012cfd1c42fa5aa5751",
|
||||
"input": {
|
||||
"clickTransport": "IVROverlayView_003.PostOverlayEvent",
|
||||
"textTransport": "CDP Input.insertText after OpenVR focus",
|
||||
"controllerHardwareVerified": false,
|
||||
"initialText": "PanelSettings\nQuick actions\nRead saved setting\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: off",
|
||||
"savedText": "Framey sample\n\nSetting saved\n\nDismiss\nPanelSettings\nQuick actions\nRead saved setting\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: on",
|
||||
"pointerEvents": [
|
||||
{
|
||||
"trusted": true,
|
||||
"x": 594.6666870117188,
|
||||
"y": 280.0625
|
||||
}
|
||||
],
|
||||
"trustedOverlayPointer": true,
|
||||
"diskSetting": {
|
||||
"enabled": true
|
||||
},
|
||||
"reopenedText": "PanelSettings\nQuick actions\nRead saved setting\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: on",
|
||||
"reopenedChecked": true,
|
||||
"quickActionText": "PanelSettings\nQuick actions\nRead saved setting\n\nSaved setting is on\n\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: on",
|
||||
"sliderValue": "50",
|
||||
"textValue": "framey-ci",
|
||||
"scrollBefore": 0,
|
||||
"scrollAfter": 0,
|
||||
"nativeScrollMoved": false,
|
||||
"finalOverlay": {
|
||||
"interface": "IVROverlay_027",
|
||||
"visible": true,
|
||||
"dashboardVisible": false,
|
||||
"activeDashboard": false,
|
||||
"texture": {
|
||||
"error": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"transform": {
|
||||
"error": 18,
|
||||
"typeError": 0,
|
||||
"type": 5,
|
||||
"origin": 0,
|
||||
"matrix": [
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
"error": 0,
|
||||
"method": 1,
|
||||
"scaleError": 0,
|
||||
"scale": [
|
||||
1920.0,
|
||||
1080.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"passed": true
|
||||
},
|
||||
"stereoSha256": "25944a3f3ffe0c217ef25cbc3f5e08a104f9d8901075b755c928ec5049a4cb3c",
|
||||
"compositorChanged": true,
|
||||
"mirrorSha256": "44d193943ca3164269feeea38f5cbfb0d58cefb79d6c0e8231eab4a5069985b8",
|
||||
"passed": true,
|
||||
"after": {
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"dashboardVisible": false,
|
||||
"stats": [
|
||||
{
|
||||
"activity_level": 0,
|
||||
"operation": "status",
|
||||
"version": "2.18.2",
|
||||
"version_timestamp": 1791331621
|
||||
}
|
||||
],
|
||||
"overlays": "---- OVERLAYS ---- \n'system.HeadsetView' -- 'Headset View', 2048x1280 not_visible VROverlayType_Basic \n'system.toast' -- 'Toast', not_visible VROverlayType_Basic \n'system.cursor.ibeam' -- 'system.cursor.ibeam', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000d00000002\n'system.pointer' -- 'system.pointer', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000e00000003\n'system.pointer.secondary' -- 'system.pointer.secondary', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000f00000004\n'system.standalone' -- 'Standalone Overlay Manager', not_visible VROverlayType_Basic \n'valve.steam.desktopgame.0' -- 'Gamescope', not_visible VROverlayType_Dashboard_Main \n flags=0x29A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000001100000006\n'valve.steam.desktopgame.0.thumb' -- 'Gamescope (Thumb)', 256x256 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x0000001200000007\n'valve.steam.desktopgame.0.layer1' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001300000008\n'valve.steam.desktopgame.0.layer2' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001400000009\n'valve.steam.desktopgame.0.layer3' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000150000000a\n'valve.steam.desktopgame.0.layer4' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000160000000b\n'valve.steam.desktopgame.0.layer5' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000170000000c\n'valve.steam.desktopgame.0.layer6' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000180000000d\n'valve.steam.desktopgame.0.layer7' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000190000000e\n'valve.steam.desktopgame.0.layer8' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001a0000000f\n'gamescope.gamescope-0.cursor.0' -- 'Mouse Cursor', not_visible VROverlayType_Basic \n flags=0x60000000 overlayhandle=0x0000001b00000010\n'system.systemui' -- 'systemui', 1860x2048 not_visible VROverlayType_Basic \n flags=0x08230008 -dash, +interactive, +smoothscroll, +premult, +clickstab overlayhandle=0x0000001c00000011\n'system.vrwebhelper.controllerbinding' -- 'controllerbinding', 2400x1350 not_visible VROverlayType_Dashboard_Main \n flags=0x09228108 -dash, +scrollwheel, +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000003800000012\n'system.vrwebhelper.controllerbinding.thumb' -- 'controllerbinding (Thumb)', 16x16 not_visible VROverlayType_Dashboard_Thumbnail \n'valve.steam.gamepadui.main' -- 'SteamVR', 0x0 not_visible VROverlayType_Dashboard_Main \n flags=0x69A28000 +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001c600000014\n'valve.steam.gamepadui.main.thumb' -- 'Webhelper (Thumb)', 300x300 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x000001c700000015\n'valve.steam.browserview_MainBrowser_2' -- 'data:text/html,<body><%2Fbody>', 0x0 not_visible VROverlayType_Subview \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001c800000016\n'valve.steam.gamepadui.keyboard' -- 'SteamVR - Keyboard', 0x0 not_visible VROverlayType_Basic \n flags=0x31A30000 +interactive, +smoothscroll, +premult, +keyboard, +2cursor overlayhandle=0x000001c900000017\n'valve.steam.gamepadui.bar' -- 'SteamVR - valve.steam.gamepadui.bar (85430000)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001ca00000018\n'valve.steam.gamepadui.barpopup.85430001' -- 'SteamVR - valve.steam.gamepadui.barpopup.85430001 (85430001)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cb00000019\n'valve.steam.gamepadui.tooltip.85430002' -- 'SteamVR - valve.steam.gamepadui.tooltip.85430002 (85430002)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cc0000001a\n'valve.steam.gamepadui.volumelevel' -- 'SteamVR - valve.steam.gamepadui.volumelevel (85430003)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cd0000001b\n'valve.steam.gamepadui.floatingfooterLine truncated
|
||||
"pause": "power.pauseCompositorOnStandby=false",
|
||||
"timeout": "power.turnOffScreensTimeout=5.000000"
|
||||
},
|
||||
"powerRecoveryRemoved": true,
|
||||
"lockReleased": true,
|
||||
"cleanupErrors": []
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 107 KiB |
@@ -1,91 +0,0 @@
|
||||
# Round 2 retry, 25 October 2026
|
||||
|
||||
The provider retry completed four further fixes, each in an isolated child
|
||||
worktree with its own issue, branch, PR, tests, docs and Announcement draft.
|
||||
The coordinator reviewed each patch, independently ran focused tests, verified
|
||||
its exact head and complete CI, merged it, then read back GitHub's merged state.
|
||||
PR #113 was untouched. No social announcements were posted.
|
||||
|
||||
## Merged fixes
|
||||
|
||||
| Change | PR | Merge commit | Coordinator focused tests | Final CI |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Skip linked backup ancestors and recheck before packing | [#215](https://github.com/saphid/frame-control/pull/215) | `da34d94` | 34 passed | 11 checks passed at `8cc03c8` |
|
||||
| Clean orphan smoke metadata/shortcuts; POSIX runner without zsh | [#216](https://github.com/saphid/frame-control/pull/216) | `d5fa8fe` | 17 passed | 10 checks passed at `a5daa4a` |
|
||||
| Separate Steam clips by headset/account/full session ID | [#217](https://github.com/saphid/frame-control/pull/217) | `1255ef2` | 62 passed | 10 checks passed at `7dfe777` |
|
||||
| Retry failed automatic copies and bind listings to the captured headset | [#218](https://github.com/saphid/frame-control/pull/218) | `fdd60d3` | 33 passed | 10 checks passed at `af2bb17` |
|
||||
|
||||
Two initial CI fixture failures were fixed before merging: macOS temporary-path
|
||||
aliases needed a resolved working directory in the wrapper test; Windows
|
||||
needed explicit UTF-8 when reading the Settings test's HTML fixture.
|
||||
Concurrent main changes #208/#209/#210 were preserved. Changelog conflicts
|
||||
retained both entries. No history was rewritten.
|
||||
|
||||
## Integrated validation
|
||||
|
||||
At main `1255ef2`, after all four merges:
|
||||
|
||||
- `python3 -m unittest discover -s tests`: 1,559 tests, 16 skipped, passed in
|
||||
157.335 seconds. Loopback networking was enabled for local HTTP fixtures.
|
||||
- `node --test app/test/*.test.js site/test/*.test.mjs`: 59 tests passed.
|
||||
- Worker backup validation also passed both Docker recovery-kit E2E tests in
|
||||
an isolated Compose project, then removed its containers, volumes and network.
|
||||
- Local proof logs: `/tmp/frame-round2-integrated-tests.log` and
|
||||
`/tmp/frame-round2-integrated-node.log`. Individual focused logs use
|
||||
`/tmp/frame-round2-{backup,smoke,clips,autosync}-review.log`.
|
||||
|
||||
## Real Frame smoke
|
||||
|
||||
The current host is lxso2; its existing direct `ssh frame` alias works.
|
||||
Another OBS task initially held `/tmp/frame-test.lock`, so only read-only
|
||||
checks were done until it released the lock. This coordinator then acquired it
|
||||
atomically and labeled it `frame-round2-vdw4yh1xv8-20261025`.
|
||||
|
||||
The merged `scripts/frame-smoke.sh` ran directly without zsh at 06:38:37 UTC.
|
||||
All 13 steps passed on SteamOS 0.5.6, BUILD_ID `20261022.6153909`. The ARM64
|
||||
and Proton titles ran. The x86-64 Linux title reported its runtime missing,
|
||||
an explicitly accepted smoke outcome rather than a successful execution.
|
||||
Installs/removals succeeded; the Windows title's remove
|
||||
step stopped five test-owned PIDs. Both final process sweeps were empty.
|
||||
Independent readback found no reserved title folders or metadata, SteamVR's
|
||||
PID was unchanged, and the three observed gamescope focus properties matched
|
||||
before/after. Battery went from 99% Charging to 100% Full. Only this task's
|
||||
lock was removed, and its absence was verified.
|
||||
|
||||
[Sanitized structured proof](round2-smoke-2026-10-25.json). Raw local results:
|
||||
`/tmp/frame-round2-smoke-results/2026-10-25T063837Z-20261022.6153909.json`;
|
||||
console log `/tmp/frame-round2-headset-smoke.log`; state readbacks
|
||||
`/tmp/frame-round2-smoke-before.txt` and `/tmp/frame-round2-smoke-after.txt`.
|
||||
|
||||
The headset was unworn. Visible rendering/controller input was not verified.
|
||||
No pairing, password/security change, reboot or factory reset was performed.
|
||||
The new backup/clip/auto-sync paths have local fixtures and cross-platform CI;
|
||||
this smoke does not establish a new real-data backup/clip/UI test. Synthetic
|
||||
orphan metadata was not seeded on the real device. Backup path checks retain
|
||||
a documented race with concurrent filesystem changes.
|
||||
|
||||
## Prior round-two work reconciled
|
||||
|
||||
The failed coordinator checkout was still at v0.4.7. It was first updated to
|
||||
main `d79efb4`, already v0.4.30. Earlier PRs had completed clipboard testing
|
||||
(#124), original smoke process cleanup (#126), Windows VM capture (#129),
|
||||
battery health (#125), setup doctor (#127), and read-only storage (#128).
|
||||
Later evidence already covered real clip export (#147/#149/#154/#156) and
|
||||
Return to Game Mode (#143/#157). These remain earlier evidence, not tests
|
||||
performed by this retry. Physical Windows GPU/input and current physical
|
||||
macOS checks remain incomplete; see the current support matrix.
|
||||
|
||||
Decision: fix reproduced data/cleanup/retry defects rather than automate game
|
||||
moves without a verified transaction API or promise unverified phone-background
|
||||
sync. Prepare v0.4.31; publication still requires its Linux AppImage install test.
|
||||
|
||||
## Delegation provenance
|
||||
|
||||
All implementations completed. Task IDs use prefix
|
||||
`node:delegated-task:command%3Amcp%3Ad9bc35d8-0e9c-42c2-b798-25bd8af94d81%3Adelegate-task%3A`
|
||||
followed by one of:
|
||||
|
||||
- `frame-r2-retry-smoke-orphans-20261025-r1`
|
||||
- `frame-r2-backup-symlink-20261025-r1`
|
||||
- `frame-r2-clip-collision-20261025-r1`
|
||||
- `frame-r2-autosync-retry-20261025-r1`
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
"source_commit": "1255ef2bbf2e2e5fd86f8b984b592bed56f20780",
|
||||
"started": "2026-10-25T06:38:37Z",
|
||||
"os_release": {
|
||||
"VERSION_ID": "0.5.6",
|
||||
"VARIANT_ID": "vr",
|
||||
"BUILD_ID": "20261022.6153909"
|
||||
},
|
||||
"passed": 13,
|
||||
"failed": 0,
|
||||
"paired": false,
|
||||
"devkit_utils_there_before": true,
|
||||
"battery_before": {
|
||||
"capacity": 99,
|
||||
"statuses": [
|
||||
"Charging",
|
||||
"Charging"
|
||||
]
|
||||
},
|
||||
"battery_after": {
|
||||
"capacity": 100,
|
||||
"statuses": [
|
||||
"Full",
|
||||
"Full"
|
||||
]
|
||||
},
|
||||
"steamvr_pid_before": 124586,
|
||||
"steamvr_pid_after": 124586,
|
||||
"observed_focus_properties_unchanged": true,
|
||||
"reserved_files_and_processes_absent_after": true,
|
||||
"own_shared_lock_released": true,
|
||||
"headset_worn": false,
|
||||
"steps": [
|
||||
{
|
||||
"step": "cleanup: leftovers from earlier runs",
|
||||
"ok": true,
|
||||
"seconds": 7.8,
|
||||
"stopped_process_counts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "devkit service: properties.json",
|
||||
"ok": true,
|
||||
"seconds": 0.0
|
||||
},
|
||||
{
|
||||
"step": "status",
|
||||
"ok": true,
|
||||
"seconds": 0.6
|
||||
},
|
||||
{
|
||||
"step": "arm64: install",
|
||||
"ok": true,
|
||||
"seconds": 7.7
|
||||
},
|
||||
{
|
||||
"step": "arm64: launch",
|
||||
"ok": true,
|
||||
"seconds": 3.3,
|
||||
"outcome": "running"
|
||||
},
|
||||
{
|
||||
"step": "arm64: remove",
|
||||
"ok": true,
|
||||
"seconds": 6.4,
|
||||
"stopped_process_counts": [
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "x86_64: install",
|
||||
"ok": true,
|
||||
"seconds": 7.6
|
||||
},
|
||||
{
|
||||
"step": "x86_64: launch",
|
||||
"ok": true,
|
||||
"seconds": 3.2,
|
||||
"outcome": "runtime missing"
|
||||
},
|
||||
{
|
||||
"step": "x86_64: remove",
|
||||
"ok": true,
|
||||
"seconds": 6.2,
|
||||
"stopped_process_counts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "exe: install",
|
||||
"ok": true,
|
||||
"seconds": 7.5
|
||||
},
|
||||
{
|
||||
"step": "exe: launch",
|
||||
"ok": true,
|
||||
"seconds": 3.3,
|
||||
"outcome": "running"
|
||||
},
|
||||
{
|
||||
"step": "exe: remove",
|
||||
"ok": true,
|
||||
"seconds": 6.5,
|
||||
"stopped_process_counts": [
|
||||
5,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "cleanup: everything this run installed",
|
||||
"ok": true,
|
||||
"seconds": 7.4,
|
||||
"stopped_process_counts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
# Steam clip export collision fixtures, 25 October 2026
|
||||
|
||||
Base: `d79efb4` (Frame Control v0.4.30). Issue:
|
||||
[#212](https://github.com/saphid/frame-control/issues/212).
|
||||
Host: Linux 6.8.0-139-generic, Python 3.12.3, ffmpeg 6.1.1.
|
||||
|
||||
## Baseline reproduction
|
||||
|
||||
Loaded the unmodified `ui/frame_clips.py` from `d79efb4` into a temporary
|
||||
Python module and exported three fake sources into a temporary directory:
|
||||
|
||||
```
|
||||
1/clips/clip_620/video/bg
|
||||
2/clips/clip_620/video/bg
|
||||
1/clips/clip_620/video/other
|
||||
```
|
||||
|
||||
Each source's payload was its own ID. All three exports returned the same
|
||||
destination, `clip_620.mp4`; listing marked all three saved. The final file
|
||||
contained only the last source ID. No SSH or headset was used.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
`tests/test_clips.py` exports five IDs on each of two fake headsets, including
|
||||
equal clip names across accounts, nested sessions with equal leaf names,
|
||||
different leaf names under one clip and the background/clip distinction.
|
||||
All ten payloads survive at distinct destinations. Listing marks only the
|
||||
identities already exported. A pre-existing flat MP4 retains its original
|
||||
bytes and does not mark any identity saved. Normal export still returns
|
||||
`message` and `file`, removes its remote temporary export and clears local
|
||||
staging; a failed re-export preserves the completed file.
|
||||
|
||||
`tests/test_clip_copy.py` exercises the server's list and manual export
|
||||
functions and its actual auto-sync callbacks with fake transport. A manual
|
||||
copy is recognised by auto-sync, all other account/session identities copy,
|
||||
and switching to a second headset copies its own sources without overwriting
|
||||
the first. A delayed manual job refuses a changed headset with status 409.
|
||||
Both local phone mode and local headset runtime reject Steam clip list,
|
||||
export and auto-sync requests with status 400. The separate `Recorder` path
|
||||
is unchanged.
|
||||
|
||||
## Validation
|
||||
|
||||
- `PYTHONPATH=tests python3 -m unittest test_clips test_clip_copy test_autosync test_shot_copy test_headset_actions -v`
|
||||
— 66 tests passed in 17.288 seconds, no skips. Includes real ffmpeg/ffprobe
|
||||
export of a generated 65-second DASH fixture and recording stop/cleanup.
|
||||
- `PYTHONPATH=tests python3 -m unittest test_server -v`
|
||||
— 44 tests passed in 12.315 seconds, no skips.
|
||||
- `node --test site/test/*.test.mjs` — 2 tests passed.
|
||||
- `python3 -m unittest discover -s tests -v` — 1,518 tests run in
|
||||
181.025 seconds; OK, 16 skipped for unavailable platform/tool prerequisites.
|
||||
- `python3 -m py_compile ui/frame_clips.py ui/server.py tests/test_clips.py tests/test_clip_copy.py`
|
||||
and `git diff --check` — passed.
|
||||
|
||||
The server and full Python suites require localhost sockets and were run
|
||||
outside the network sandbox. Tests import `tests/sandbox.py` to isolate app
|
||||
data, SSH settings, home directories and service URLs.
|
||||
|
||||
## Limits and upgrade behaviour
|
||||
|
||||
No real headset was accessed: the shared device lock is held by other work.
|
||||
This is Linux fixture and server coverage, not a fresh packaged-app or
|
||||
Windows/macOS verification. Existing real-headset export evidence in the
|
||||
recording guide predates this destination change.
|
||||
|
||||
Old flat exports cannot prove headset/account/session identity and are left
|
||||
untouched. Previously remembered auto-sync IDs retain their existing
|
||||
watermark/history behaviour; **Copy everything now** or manual **Export**
|
||||
creates an identified copy. No release, version bump, merge or social post
|
||||
is included. PR #113 is outside this task.
|
||||
@@ -1,24 +0,0 @@
|
||||
# v0.4.36 release smoke on a real Frame
|
||||
|
||||
On 25 October 2026 at 22:59 UTC, the release branch ran
|
||||
`scripts/frame-smoke.sh` from a physical Linux host against a real Steam Frame.
|
||||
The script and its Python implementation were byte-identical to the
|
||||
`v0.4.36` tag `1701e622dc1f9888560412a058fe816b19ca7e79`.
|
||||
The headset reported Frame OS 0.5.6, build `20261022.6153909`.
|
||||
|
||||
The test acquired `/tmp/frame-test.lock` with a unique owner marker before
|
||||
reading device status or changing test titles. Its first read showed SteamVR
|
||||
activity level 3 (standby), proximity raw 6.3125, and battery 99%. The test
|
||||
ran only while that lock was owned. It released only its own marker and lock
|
||||
after the final cleanup.
|
||||
|
||||
All **13 of 13** smoke steps passed: cleanup of reserved leftovers, devkit
|
||||
service read, status read, install/launch/remove for reserved ARM64, x86_64
|
||||
and Windows test titles, and final cleanup. The test did not pair a key. The
|
||||
test output was saved locally under `/tmp/frame-v0436-smoke-results/`; this
|
||||
summary omits the headset identifier and private device status fields.
|
||||
|
||||
The headset was in standby. This test does not show wearer-visible game or
|
||||
Framey pixels, controller input, live Decky settings, or plugin actions.
|
||||
See [release v0.4.36](https://github.com/saphid/frame-control/releases/tag/v0.4.36)
|
||||
for the tagged installer and checksum checks.
|
||||
@@ -26,78 +26,9 @@ The current Frame SteamVR shell does not expose Decky's Steam Deck Quick Access
|
||||
Menu. A backend `Loaded` log only proves backend startup. Test any frontend
|
||||
page, setting, notification, route, and controller input while wearing a Frame
|
||||
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 and API v1 bundles that use Decky's modern
|
||||
frontend API initializer. It renders common settings controls in the
|
||||
separate SteamVR dashboard panel, but Steam library and store page patches
|
||||
remain unavailable. Wearer-visible use is not yet verified.
|
||||
The [settings sample](../examples/framey-settings/README.md) shows an official
|
||||
Decky JSX build, backend persistence, a toggle and a local toast. Framey offers
|
||||
only official-store-owned plugins in its VR panel.
|
||||
For persistent backend settings, use Decky's `decky.DECKY_PLUGIN_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.
|
||||
Control extension is the practical route for a visible read-only VR panel.
|
||||
|
||||
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 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;
|
||||
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; recognized legacy IIFE bundles also use the legacy bridge | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
|
||||
| Legacy Decky IIFE frontend, with no API manifest field | Wraps the returned initializer as an ES module inside the same opaque iframe; passes `serverAPI` and preserves the legacy title | MusicControl 1.1.6 exact official archive, local Chromium startup with backend methods blocked; fake Frame installation/pack/removal |
|
||||
| Legacy `serverAPI.callPluginMethod(method, kwargs)` | Uses Decky's `loader/call_legacy_plugin_method` RPC, preserving keyword arguments and `{success, result}` replies | Fake Frame HTTP/CDP read/write with a synthetic backend; no official plugin backend action or worn VR result |
|
||||
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
|
||||
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
|
||||
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
|
||||
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
|
||||
| `SteamClient` subscriptions and `DFL.Router.MainRunningApp` | Explicit inert registration methods return disposable handles; no events or attached running game. Mutating Steam/power methods are unavailable. | Host contract tests; these are safe fallbacks, not live Steam functionality |
|
||||
| `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check |
|
||||
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e; real ARM64 Notebook save/reopen across backend restart and Simple Timer settings/recents persistence via local Chromium. No worn-VR claim. |
|
||||
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
|
||||
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
|
||||
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
|
||||
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
| `fetchNoCors`, 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.
|
||||
Optional route segments must be at the end. Framey accepts local path segments
|
||||
only; query strings, external URLs, Steam library routes and duplicate parameter
|
||||
names are rejected. Removing an active route returns to the plugin panel.
|
||||
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
|
||||
An API v1 manifest does not guarantee a working Framey panel. Test the exact
|
||||
store archive and do not assume SteamClient app details, library patches, or
|
||||
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.
|
||||
@@ -1,311 +0,0 @@
|
||||
# Official Decky store compatibility with Framey
|
||||
|
||||
Snapshot: 2026-10-25T23:19:48.854504+00:00; 116 current visible official plugins from https://plugins.deckbrew.xyz/plugins.
|
||||
|
||||
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
|
||||
|
||||
**Works: 1 · Partial: 44 · Broken: 60 · N/A: 11.**
|
||||
|
||||
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
|
||||
- **Partial** means a limited frontend/settings surface initialized or rendered. Backend-dependent functionality remains unverified; it is not an end-to-end success claim.
|
||||
- **Broken** means a concrete archive, frontend or Steam dependency blocks the audited version. An artificial backend/network denial alone is not evidence that the real backend is broken.
|
||||
- **N/A** means the plugin targets hardware or a display/UI surface that Framey does not provide. The store disables installation for these versions.
|
||||
|
||||
## Verification limits
|
||||
|
||||
All exact archives were hash-checked and statically inspected. The local Chromium host used Framey commit `779f9c5`, React 18.3.1 and an opaque `allow-scripts` iframe. Each frontend was attempted for 1.2 seconds, including React effects. All plugin backend calls, fetches and websockets were denied. Some plugins attempt mutating actions on mount; none reached a backend. Initialization (`ready`) does not guarantee React rendered, and delayed errors/actions may occur after the observation window. The JSON preserves initializer errors, capped console observations and attempted method names.
|
||||
|
||||
The optional full-catalog fake-Frame audit exercises the real HTTP/SSH installer, receipt, frontend bytes and removal, or expected rejection. It uses a private disposable Compose project with all plugin backend execution stubbed. It checks archive transport and safeguards, not Python/native execution. Run it with:
|
||||
|
||||
```sh
|
||||
python3 scripts/framey-audit-store.py --cache /tmp/framey-archives --output /tmp/framey-observations.json
|
||||
python3 scripts/framey-audit-fake.py --cache /tmp/framey-archives --output /tmp/framey-fake/results.json
|
||||
python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives
|
||||
```
|
||||
|
||||
Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.
|
||||
|
||||
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
|
||||
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. All other official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md).
|
||||
|
||||
## Every current plugin
|
||||
|
||||
The API column shows manifest / actual bundled frontend API. Many manifests say v1 while their bundle connects API2. Missing manifest API is labelled legacy; an unrecognized bundle is unknown. Globals and hardware are static signals, not exhaustive dependencies.
|
||||
|
||||
| Plugin / version | Manifest / bundle API | Status | Reason | Verified by |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1: XR Gaming 1.6.2 | 1 / 2 | N/A | Not supported on Frame: Requires supported external XR glasses and the Breezy/XRLinuxDriver stack; it does not target Steam Frame displays. | static archive, local browser, fake-Frame |
|
||||
| 7: CSS Loader 2.1.3 | legacy / legacy | partial | Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. | static archive, local browser, fake-Frame |
|
||||
| 10: vibrantDeck 2.0.1 | legacy / legacy | N/A | Not supported on Frame: Changes X11 gamescope color properties, not the Frame SteamVR compositor. | static archive, local browser, fake-Frame |
|
||||
| 11: Fantastic 0.5.1 | legacy / legacy | N/A | Not supported on Frame: Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. | static archive, local browser, fake-Frame |
|
||||
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Legacy media controls initialize; player discovery and playback are unverified. | static archive, local browser, fake-Frame |
|
||||
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings render locally with inert system-key subscriptions and empty detached Steam context. Live-game discovery, process pause/resume and suspend integration are unsupported/unverified; no backend actions ran. | static archive, local browser, fake-Frame |
|
||||
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: Cannot read properties of null (reading 'm_GamepadUIAudioStore'). | static archive, local browser, fake-Frame |
|
||||
| 18: Bluetooth 3.1.1 | 1 / 2 | partial | Controls render with fixture replies; actual Bluetooth discovery, labels and connections remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 19: Notebook 0.1.5 | legacy / legacy | works | Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Per-game auto-selection and background artwork unavailable; worn-VR input unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 21: PowerTools 2.0.3 | legacy / legacy | N/A | Not supported on Frame: Targets AMD Ryzen power controls; Frame uses an ARM64 SoC. | static archive, local browser, fake-Frame |
|
||||
| 22: Memory Deck 0.1.8 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: memory-deck/bin/libscanmem.so.1.0.0. | static archive, local browser, fake-Frame |
|
||||
| 23: Animation Changer 1.3.2 | legacy / legacy | partial | Initializer succeeds; forced backend failure prevents loading animations. Boot-video changes untested. | static archive, local browser, fake-Frame |
|
||||
| 24: RadiYo! 1.3 | legacy / legacy | partial | Initializer succeeds; radio discovery and playback remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 26: HLTB for Deck 2.0.12 | 1 / 2 | partial | Settings render without the prior IndexedDB SecurityError; Steam library badges have no patch target, and live lookup is unverified. | static archive, local browser, fake-Frame |
|
||||
| 27: AutoSuspend 2.2.0 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of null (reading '6'). | static archive, local browser, fake-Frame |
|
||||
| 28: Emuchievements 2.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: Emuchievements/bin/hash. | static archive, local browser, fake-Frame |
|
||||
| 30: TunnelDeck 1.0.4 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 35: MoonDeck 1.13.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: moondeck/python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so. Frontend initializes; native CPython wheels and executables in the published archive are x86_64. | static archive, local browser, fake-Frame |
|
||||
| 36: SteamGridDB 1.8.1 | 1 / 2 | broken | Frontend audit failed: DFL.createModuleMapping is not a function. | static archive, local browser, fake-Frame |
|
||||
| 37: Discord Status 1.5.0 | 1 / 2 | broken | RunningApps and appStore are absent; Discord activity integration fails after initialization. | static archive, local browser, fake-Frame |
|
||||
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | partial | Initializer succeeds; user PulseAudio/PipeWire mixer backend and volume changes unverified. | static archive, local browser, fake-Frame |
|
||||
| 40: Controller Tools 2.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: ControllerTools/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 43: Tailscale Control 0.1.3 | legacy / legacy | partial | Settings render locally after the SuspensefulImage fallback. Tailscale daemon/state, connection and exit-node changes unverified; bundled localhost QR asset remains unavailable. No network settings were changed. | static archive, local browser, fake-Frame |
|
||||
| 44: DeckMTP 1.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: DeckMTP/bin/umtprd. | static archive, local browser, fake-Frame |
|
||||
| 46: Steamback 1.0.0-1 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of undefined (reading 'm_CurrentUser'). Steam account/app context is unavailable. | static archive, local browser, fake-Frame |
|
||||
| 48: Quick Launch 1.2.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
|
||||
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | Empty-data panel rendered; cleanup was deliberately not run because it can remove local data. | static archive, local browser, fake-Frame |
|
||||
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'type'). | static archive, local browser, fake-Frame |
|
||||
| 53: Shotty 0.1.0 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of undefined (reading 'allApps'). | static archive, local browser, fake-Frame |
|
||||
| 56: MangoPeel 0.0.7 | 1 / 2 | partial | Initializer succeeds; gamescope/MangoHud actions and backend are unverified. | static archive, local browser, fake-Frame |
|
||||
| 58: Volume Boost 0.0.7 | legacy / legacy | partial | Initializer succeeds; PulseAudio/PipeWire volume backend is unverified. | static archive, local browser, fake-Frame |
|
||||
| 59: Web Browser 1.4.3 | legacy / legacy | broken | Frontend audit failed: deckyFrontendLib.getReactRoot is not a function. | static archive, local browser, fake-Frame |
|
||||
| 60: DeckyFileServer 1.2.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend. | static archive, local browser, fake-Frame |
|
||||
| 61: TabMaster 2.15.1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio'). | static archive, local browser, fake-Frame |
|
||||
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
|
||||
| 63: Free Loader 1.6.0 | 1 / 2 | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
|
||||
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. | static archive, local browser, fake-Frame |
|
||||
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
|
||||
| 66: PlayTime 3.2.5 | 1 / 2 | broken | Private SteamClient JSON cache now works; initialization still fails on missing Steam appStore.m_mapApps. Live app data and MobX game observations are unavailable; backend actions were denied. | static archive, local browser, fake-Frame |
|
||||
| 67: Battery Tracker 0.2.0 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 68: Wine Cellar 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-wine-cellar/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 70: MicroSDeck 0.11.2 | legacy / 2 | broken | Plugin contains a non-ARM64 Linux binary: MicroSDeck/bin/backend. | static archive, local browser, fake-Frame |
|
||||
| 72: ScreenshotUploader 0.0.3 | legacy / legacy | partial | Initializer succeeds; screenshot upload requires external credentials/network and was not run. | static archive, local browser, fake-Frame |
|
||||
| 73: EmuDecky 1.0.2-1 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: EmuDecky/bin/hello. | static archive, local browser, fake-Frame |
|
||||
| 74: KDE Connect 0.1.0-1 | legacy / legacy | partial | Initializer succeeds; KDE Connect installation/pairing/file transfers unverified. | static archive, local browser, fake-Frame |
|
||||
| 75: Reshadeck 0.2.0 | legacy / legacy | partial | Initializer succeeds; ReShade deployment into Proton games is unverified. | static archive, local browser, fake-Frame |
|
||||
| 76: CheatDeck 1.1.6 | 1 / 2 | broken | Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
|
||||
| 77: BT Wake Control 1.2.0 | 1 / 2 | partial | Initializer succeeds; backend uses DMI board paths and Bluetooth wake permissions, unverified on ARM64 Frame. | static archive, local browser, fake-Frame |
|
||||
| 78: Brightness Bar 1.0.4 | legacy / legacy | N/A | Not supported on Frame: Targets Steam/QAM brightness shortcuts and a global Steam overlay; neither is attached to the Framey panel. | static archive, local browser, fake-Frame |
|
||||
| 79: Decky Notifications 1.0.1 | legacy / legacy | partial | Initializer succeeds; device/account setup and notification delivery unverified. | static archive, local browser, fake-Frame |
|
||||
| 80: MagicBlack 2.0.2 | 1 / 2 | N/A | Not supported on Frame: Targets a global Steam Deck screen-black overlay; the isolated panel cannot black out the Frame compositor. | static archive, local browser, fake-Frame |
|
||||
| 81: IsThereAnyDeal for Deck 1.1.1 | legacy / legacy | broken | Country lookup calls missing SteamClient.User.GetIPCountry; deal loading fails after initialization. | static archive, local browser, fake-Frame |
|
||||
| 82: MagicPods 2.0.21 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: MagicPodsDecky/bin/magicpodscore. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 84: Junk-Store 1.1.13 | legacy / legacy | broken | Route registration succeeds; rendering fails on a missing Steam ContainerBackground component. | static archive, local browser, fake-Frame |
|
||||
| 85: Decky-Undervolt 1.0.8 | 1 / 2 | N/A | Not supported on Frame: RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. | static archive, local browser, fake-Frame |
|
||||
| 87: ScreenSaver 1.1.2 | legacy / legacy | partial | Initializer succeeds; global screen overlay and idle/suspend integration have no verified VR target. | static archive, local browser, fake-Frame |
|
||||
| 88: steamdeck-input-disabler 1.0.2 | legacy / legacy | N/A | Not supported on Frame: Targets the built-in Steam Deck USB gamepad; Frame has different controllers. | static archive, local browser, fake-Frame |
|
||||
| 89: DeckySpy 0.6.7 | legacy / legacy | partial | Initializer succeeds; account tokens and external service integration were not exercised. | static archive, local browser, fake-Frame |
|
||||
| 90: Picture in Picture 1.0.0 | 1 / 2 | partial | Initializer succeeds; global picture-in-picture injection cannot escape the isolated panel. | static archive, local browser, fake-Frame |
|
||||
| 91: PlayCount 1.7 | 1 / 2 | partial | Initializer succeeds; Steam game-page play-count patch has no supported VR target. | static archive, local browser, fake-Frame |
|
||||
| 92: Crosshair 1.1.0 | 1 / 2 | partial | Initializer succeeds; global crosshair injection has no supported compositor target. | static archive, local browser, fake-Frame |
|
||||
| 93: Deck Settings 1.2.6 | 1 / 2 | broken | Initializer succeeds; game selection fails because SteamClient.InstallFolder.GetInstallFolders is unavailable. | static archive, local browser, fake-Frame |
|
||||
| 94: Decky DoT DNS 0.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 95: Decky-SpoofDPI 0.12.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-spoof-dpi/bin/spoofdpi. | static archive, local browser, fake-Frame |
|
||||
| 96: Simple Timer 1.0.0 | 1 / 2 | partial | Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 97: Decky-Framegen 0.11.15 | 1 / 2 | broken | Plugin archive size or SHA-256 did not match the store. | static archive, local browser, fake-Frame |
|
||||
| 98: Decky-Lookup 0.1.0 | 1 / 2 | partial | Initializer succeeds; game-page context actions and live lookups remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 99: Decky Zerotier 0.3.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 100: Virtual Surround Sound 0.0.5 | 1 / 2 | partial | Initializer succeeds; avatar proxy is unavailable and live audio-routing backend unverified. | static archive, local browser, fake-Frame |
|
||||
| 101: XIVOmega 1.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 102: DeckSP 1.1.3 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio'). | static archive, local browser, fake-Frame |
|
||||
| 103: Decky Ludusavi 1.2.2 | 1 / 2 | partial | Initializer succeeds; Flatpak Ludusavi discovery and backup/restore unverified. | static archive, local browser, fake-Frame |
|
||||
| 104: Game Sync 1.0.4 | 1 / 2 | partial | Backend get_config is intentionally rejected by the audit before initialization completes; actual backend compatibility is unknown. | static archive, local browser, fake-Frame |
|
||||
| 105: WiFi Locker 1.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 106: LetMeReShade 1.8 | 1 / 2 | partial | Initializer succeeds; Deck-model assumptions, shader downloads and game modifications unverified. | static archive, local browser, fake-Frame |
|
||||
| 107: Decky Sunshine 2025.10.27-dddf365 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 108: HueSync 3.8.0 | 1 / 2 | N/A | Not supported on Frame: Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. | static archive, local browser, fake-Frame |
|
||||
| 109: MuraDeck 2.1.4 | 1 / 2 | N/A | Not supported on Frame: Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. | static archive, local browser, fake-Frame |
|
||||
| 110: Dimmer Deck 0.0.4 | 1 / 2 | N/A | Not supported on Frame: Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. | static archive, local browser, fake-Frame |
|
||||
| 111: Music Control 1.1.7 | legacy / legacy | partial | Panel initializer succeeds with backend/network blocked; actual controls and backend remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 112: Better Keyboard 0.3.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: window.SteamClient.UI.GetUIMode is not a function. | static archive, local browser, fake-Frame |
|
||||
| 113: Decky LSFG-VK 0.12.2 | 1 / 2 | partial | Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. | static archive, local browser, fake-Frame |
|
||||
| 114: Network Sentinel 1.0.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 115: Decky Clash 1.3.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 116: Muon 0.2.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 117: DeckyParty File Manager 1.0.0 | legacy / legacy | partial | Initializer succeeds; privileged file operations and backend are unverified. No file changes were run. | static archive, local browser, fake-Frame |
|
||||
| 118: Speed Test 1.0.2 | 1 / 2 | partial | Initializer succeeds; automatic bandwidth tests were blocked by the audit network policy. | static archive, local browser, fake-Frame |
|
||||
| 119: Decky Clipboard 1.0.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-clipboard/bin/xclip. | static archive, local browser, fake-Frame |
|
||||
| 120: Reroll 0.2.1 | 1 / 2 | broken | Frontend now initializes with inert game-action subscriptions, but the archive contains x86_64 memscan.so and requests root. No process-memory access was run. | static archive, local browser, fake-Frame |
|
||||
| 121: DeckPass 1.1.1 | 1 / 2 | partial | Initializer succeeds; KeePass Flatpak discovery and secrets integration were not exercised. | static archive, local browser, fake-Frame |
|
||||
| 123: Steam Status 1.2.3 | 2 / 2 | partial | Settings/public status rendered locally; cache errors fixed. Read-only get_settings RPC returned actual Decky 3.3.0 defaults on ARM64 Frame. Persistence, live backend diagnostics and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 124: AlarMe 1.6.3 | 1 / 2 | partial | Initializer succeeds; timers, reminders, suspend inhibition and audio are unverified. | static archive, local browser, fake-Frame |
|
||||
| 125: Decky Localsend 0.4.5 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core. | static archive, local browser, fake-Frame |
|
||||
| 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
|
||||
| 127: Decky Translator 0.9.0 | 1 / 2 | partial | Initializer succeeds; x86_64 download and hidraw/controller assumptions need an ARM64 audit. OCR/translation not exercised. | static archive, local browser, fake-Frame |
|
||||
| 128: Quick User Switcher 1.0.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 129: ShadowDeck 1.1.11 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 130: Games Prefix Manager 1.0.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 131: Download All 1.1.0 | 1 / 2 | broken | Initializer succeeds; Steam download registration is absent, so the primary action is unavailable. | static archive, local browser, fake-Frame |
|
||||
| 132: SuggestMe 1.5.3 | 1 / 2 | partial | Initializer succeeds; recommendation backend and Steam library data unverified. | static archive, local browser, fake-Frame |
|
||||
| 133: Non-Steam Badges 0.1.0 | 1 / 2 | partial | Initializer succeeds; Non-Steam game badges have no supported Steam page injection target. | static archive, local browser, fake-Frame |
|
||||
| 134: Deck Progress Tracker 1.3.6 | 1 / 2 | partial | Initializer succeeds; Steam game/library statistics and page patches unverified. | static archive, local browser, fake-Frame |
|
||||
| 135: Deck Shelves 2.0.0 | 1 / unknown | broken | Frontend audit failed: Failed to resolve module specifier "./chunks/pluginApi-B8sV7970.js". Invalid relative url or base scheme isn't hierarchical.. | static archive, local browser, fake-Frame |
|
||||
| 136: Decky-Clipper 1.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: Decky-Clipper/bin/gstreamer-1.0/libgstproxy.so. | static archive, local browser, fake-Frame |
|
||||
| 137: Decky Proton Launch 0.9.0 | 1 / 2 | partial | Initializer succeeds; Proton wrapper installation and launch changes were not run. | static archive, local browser, fake-Frame |
|
||||
| 138: Docky 1.4.13 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 139: Output Manager 1.4.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 140: IPv6 Enabler Plugin 0.0.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 141: DeckyClocker 1.0.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 142: MuteOnSleep 1.0.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 143: Desktop Apps 0.1.0 | 1 / 2 | partial | Initializer succeeds; Flatpak app discovery and Steam shortcut changes unverified. | static archive, local browser, fake-Frame |
|
||||
| 144: Mixer 0.1.0 | 1 / 2 | partial | Initializer succeeds; actual audio mixer backend and volume changes unverified. | static archive, local browser, fake-Frame |
|
||||
| 145: VARTA 1.0.21 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
|
||||
## API, UI and backend inventory
|
||||
|
||||
DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stores and router/toaster/QAM signals are listed separately. No signal means the static scan did not detect one, not that no dependency exists. Native binaries are classified by ELF machine or Windows MZ header; Python files and source links are preserved in the JSON.
|
||||
|
||||
| Plugin | SteamClient methods | DFL / @decky/ui exports | Other Steam globals / UI surfaces | Backend | Hardware / OS signals |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| XR Gaming | none detected | ButtonItem, ConfirmModal, DialogButton, DialogButtonPrimary, DialogButtonSecondary, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, Spinner, TextField, ToggleField, definePlugin, gamepadDialogClasses, showModal, staticClasses | window.SP_REACT | Python | Requires supported external XR glasses and the Breezy/XRLinuxDriver stack; it does not target Steam Frame displays. |
|
||||
| CSS Loader | none detected | ButtonItem, ColorPickerModal, ConfirmModal, DialogButton, DialogCheckbox, Dropdown, DropdownItem, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SidebarNavigation, SliderField, Tabs, TextField, Toggle, ToggleField, classMap, definePlugin, findModuleChild, gamepadDialogClasses, gamepadSliderClasses, getGamepadNavigationTrees, replacePatch, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| vibrantDeck | none detected | PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, window.SP_REACT | Python | Changes X11 gamescope color properties, not the Frame SteamVR compositor. |
|
||||
| Fantastic | none detected | Field, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT, QAM/global tabs | Python + native payload; x86_64 | Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. |
|
||||
| MusicControl | none detected | ButtonItem, DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Pause Games | Apps, Apps.RegisterForGameActionTaskChange, GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, System.UI.RegisterForFocusChangeEvents, System.UI.RegisterForSystemKeyEvents | Button, Field, Focusable, Marquee, PanelSection, PanelSectionRow, Router, Toggle, ToggleField, beforePatch, sleep | Router, window.SP_REACT, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| ProtonDB Badges | none detected | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, Navigation, PanelSection, PanelSectionRow, ScrollPanel, ScrollPanelGroup, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleExport, showModal, staticClasses, useParams | SteamUIStore, appStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Audio Loader | GameSessions.RegisterForAppLifetimeNotifications, d.ts | ButtonItem, DialogButton, Dropdown, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, afterPatch, beforePatch, definePlugin, findModuleChild, gamepadDialogClasses, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Bluetooth | none detected | DialogButton, Field, Focusable, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | none detected | Python | /sys/class/power_supply |
|
||||
| Notebook | System.UI.RegisterForFocusChangeEvents | DialogButton, Dropdown, Marquee, PanelSection, PanelSectionRow, Router, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| PowerTools | Apps.RegisterForGameActionEnd, Apps.RegisterForGameActionStart, GameSessions.RegisterForAppLifetimeNotifications, User.GetLoginUsers, User.RegisterForCurrentUserChanges | ButtonItem, DialogButton, Dropdown, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, QuickAccessTab, Router, SliderField, Spinner, TextField, ToggleField, definePlugin, showModal, staticClasses | Router, appStore, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam stores | Python + native payload; x86_64 | Targets AMD Ryzen power controls; Frame uses an ARM64 SoC. |
|
||||
| Memory Deck | none detected | ButtonItem, DialogButton, DropdownItem, Focusable, PanelSection, PanelSectionRow, Router, SteamSpinner, definePlugin, gamepadDialogClasses, joinClassNames, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python + native payload; x86_64 | gamescope |
|
||||
| Animation Changer | none detected | ButtonItem, DialogButton, Dropdown, DropdownItem, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, Spinner, Tabs, TextField, ToggleField, definePlugin, findModule, findSP, showModal, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| RadiYo! | none detected | ConfirmModal, DialogButton, Dropdown, Focusable, ModalRoot, Navigation, PanelSection, QuickAccessTab, Router, SliderField, Spinner, TextField, definePlugin, showModal, staticClasses | Router, window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| HLTB for Deck | none detected | ButtonItem, DialogButtonPrimary, DropdownItem, MenuItem, Navigation, PanelSection, PanelSectionRow, SliderField, SteamSpinner, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, definePlugin, fakeRenderComponent, findInReactTree, findInTree, findModuleChild, removeHookStubs, staticClasses | routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| AutoSuspend | Downloads.RegisterForDownloadItems, Input.RegisterForControllerInputMessages, Settings.RegisterForSettingsChanges, System, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, System.ShutdownPC, URL.ExecuteSteamURL, User.GetLoginUsers, User.RegisterForShutdownDone | ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DropdownItem, Field, Focusable, Navigation, SidebarNavigation, SliderField, TextField, ToggleField, definePlugin, findModuleChild, showModal, sleep, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Emuchievements | Apps.RegisterForAppDetails, GameSessions.RegisterForAppLifetimeNotifications, User.RegisterForLoginStateChange | ButtonItem, Dropdown, Field, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarItem, ProgressBarWithInfo, Router, SidebarNavigation, TextField, ToggleField, afterPatch, beforePatch, callOriginal, definePlugin, findInTree, findModuleChild, replacePatch, sleep, staticClasses | Router, appDetailsStore, appStore, collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| TunnelDeck | none detected | ButtonItem, Field, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | systemctl |
|
||||
| AutoFlatpaks | InstallFolder.GetInstallFolders | ConfirmModal, DialogButton, Dropdown, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, ScrollPanel, SteamSpinner, Tabs, TextField, Toggle, ToggleField, definePlugin, findModuleChild, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | flatpak; x86_64 |
|
||||
| MoonDeck | Apps.AddShortcut, Apps.CancelGameAction, Apps.GetResolutionOverrideForApp, Apps.RegisterForAppDetails, Apps.RegisterForGameActionEnd, Apps.RegisterForGameActionStart, Apps.RegisterForGameActionTaskChange, Apps.RemoveShortcut, Apps.RunGame, Apps.SetAppLaunchOptions, Apps.SetAppResolutionOverride, Apps.SetShortcutName, Apps.SetThirdPartyControllerConfiguration, Apps.TerminateApp, Apps.ToggleOverrideResolutionForInternalDisplay, GameSessions.RegisterForAppLifetimeNotifications, System.Audio.GetDevices, System.DisplayManager.RegisterForStateChanges, User.RegisterForLoginStateChange, User.StartRestart | Button, ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogButtonPrimary, DialogButtonSecondary, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, Menu, MenuItem, ModalRoot, NavEntryPositionPreferences, Navigation, PanelSection, PanelSectionRow, SidebarNavigation, Spinner, TextField, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, basicAppDetailsSectionStylerClasses, createReactTreePatcher, findInReactTree, findModuleExport, findSP, joinClassNames, playSectionClasses, showContextMenu, showModal, sleep, staticClasses | appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | /dev/input; /sys/socket.h; amd64; flatpak; x86_64 |
|
||||
| SteamGridDB | Apps.ClearCustomArtworkForApp, Apps.RegisterForAppDetails, Apps.SetCustomArtworkForApp, Apps.SetShortcutName, User.StartRestart | ConfirmModal, ControlsList, DialogBody, DialogBodyText, DialogButton, DialogButtonPrimary, DialogCheckbox, DialogControlsSection, DialogControlsSectionHeader, DialogFooter, DialogHeader, Dropdown, Field, Focusable, IconsModule, Marquee, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBar, ReorderableList, SliderField, Spinner, Tabs, TextField, ToggleField, afterPatch, appDetailsHeaderClasses, callOriginal, createModuleMapping, definePlugin, fakeRenderComponent, findClassModule, findInReactTree, findInTree, findModule, findModuleByExport, findModuleExport, findSP, joinClassNames, quickAccessMenuClasses, replacePatch, showContextMenu, showModal, useParams, wrapReactType | appDetailsStore, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Discord Status | Apps.RegisterForAppDetails, Apps.RunGame, GameSessions.RegisterForAppLifetimeNotifications, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | ButtonItem, DropdownItem, Field, PanelSection, PanelSectionRow, Router, Spinner, sleep, staticClasses | Router, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Volume Mixer | none detected | DialogButton, Focusable, PanelSection, PanelSectionRow, SliderField, definePlugin, staticClasses | window.SP_REACT | Python | sudo |
|
||||
| Controller Tools | none detected | ButtonItem, PanelSection, PanelSectionRow, ToggleField, definePlugin, gamepadDialogClasses, joinClassNames, staticClasses | toaster, window.SP_REACT, toasts | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Tailscale Control | none detected | ButtonItem, ConfirmModal, DropdownItem, Field, Focusable, PanelSection, PanelSectionRow, SuspensefulImage, TextField, ToggleField, definePlugin, showModal, staticClasses | window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| DeckMTP | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Steamback | GameSessions.RegisterForAppLifetimeNotifications, InstallFolder.GetInstallFolders | ButtonItem, ConfirmModal, Navigation, PanelSection, PanelSectionRow, Router, definePlugin, showModal, staticClasses | Router, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Quick Launch | Apps.AddShortcut, Apps.RunGame, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Apps.SpecifyCompatTool | ButtonItem, ConfirmModal, DialogButton, Dropdown, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, ToggleField, definePlugin, showModal, staticClasses | appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | flatpak |
|
||||
| Decky Cloud Save | GameSessions.RegisterForAppLifetimeNotifications, Settings, Settings.GetCurrentLanguage | ButtonItem, ConfirmModal, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField, definePlugin, showContextMenu, showModal, sleep, staticClasses | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Storage Cleaner | Apps.RegisterForAppDetails | ButtonItem, ConfirmModal, DialogCheckbox, PanelSection, PanelSectionRow, definePlugin, showModal, staticClasses | window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Game Theme Music | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SliderField, SteamSpinner, Tabs, TextField, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, fakeRenderComponent, findInReactTree, findInTree, findModuleChild, showContextMenu, showModal, staticClasses, useParams | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Shotty | GameSessions.RegisterForScreenshotNotification | ButtonItem, PanelSection, PanelSectionRow, Router, definePlugin, staticClasses | Router, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | gamescope |
|
||||
| MangoPeel | none detected | ButtonItem, ConfirmModal, DropdownItem, Field, Focusable, Marquee, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, definePlugin, findModuleChild, showModal, sleep, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Volume Boost | none detected | PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | pactl |
|
||||
| Web Browser | GameSessions.RegisterForAppLifetimeNotifications, Input.ControllerKeyboardSetKeyState, Overlay.RegisterForOverlayActivated, System, System.RegisterForOnResumeFromSuspend | ButtonItem, ConfirmModal, DialogBodyText, DialogButton, DialogButtonSecondary, DialogSubHeader, DropdownItem, Field, Focusable, GamepadButton, Marquee, Menu, MenuGroup, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SimpleModal, SteamSpinner, Tabs, TextField, ToggleField, afterPatch, definePlugin, findClassModule, findInReactTree, findModuleChild, gamepadContextMenuClasses, gamepadDialogClasses, gamepadTabbedPageClasses, getFocusNavController, getReactRoot, quickAccessControlsClasses, replacePatch, showContextMenu, showModal, sleep, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| DeckyFileServer | none detected | ButtonItem, DialogBody, DialogButton, DialogFooter, DialogHeader, DialogSubHeader, DropdownItem, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, Toggle, ToggleField, definePlugin, showModal, staticClasses | window.SP_REACT | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| TabMaster | Apps.GetStoreTagLocalization, GameSessions.RegisterForAppLifetimeNotifications, InstallFolder.RegisterForInstallFolderChanges, System, System.RegisterForOnResumeFromSuspend, System.RestartPC, User.RegisterForLoginStateChange | Button, ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogCheckbox, DialogControlsSection, DialogHeader, Dropdown, DropdownItem, Field, Focusable, GamepadButton, Marquee, Menu, MenuGroup, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, ReorderableList, ScrollPanelGroup, SidebarNavigation, SliderField, TextField, ToggleField, achievementClasses, afterPatch, findInReactTree, findInTree, findModuleExport, gamepadContextMenuClasses, gamepadDialogClasses, gamepadSliderClasses, gamepadTabbedPageClasses, joinClassNames, mainMenuAppRunningClasses, quickAccessControlsClasses, quickAccessMenuClasses, replacePatch, scrollPanelClasses, showContextMenu, showModal, sleep, wrapReactType | DeckyPluginLoader, appStore, collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| TS3 QuickAccess | none detected | DialogBody, DialogButton, DialogFooter, DialogHeader, Dropdown, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, SliderField, TextField, definePlugin, showModal | none detected | Python + native payload; x86_64 | flatpak |
|
||||
| Free Loader | none detected | ButtonItem, ConfirmModal, DialogButton, Focusable, Navigation, PanelSection, PanelSectionRow, QuickAccessTab, SliderField, SteamSpinner, ToggleField, showModal | DeckyPluginLoader, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Syncthing | none detected | DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBarItem, QuickAccessTab, Router, SidebarNavigation, Spinner, SteamSpinner, TextField, Toggle, definePlugin, showModal, sleep, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python + native payload; x86_64 | flatpak; gamescope |
|
||||
| Decky Terminal | none detected | DialogButton, Dropdown, Field, Focusable, GamepadButton, PanelSection, Router, SidebarNavigation, SteamSpinner, TextField, ToggleField, definePlugin, staticClasses, useParams | Router, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| PlayTime | Apps.RegisterForAppDetails, Input.RegisterForControllerInputMessages, Storage.GetJSON, Storage.SetObject, System, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, User, User.RegisterForLoginStateChange, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | ButtonItem, ConfirmModal, DialogButton, Dropdown, Field, Focusable, Menu, MenuGroup, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SidebarNavigation, Tabs, TextField, afterPatch, definePlugin, findClass, findSP, showContextMenu, showModal, staticClasses, useParams | SteamUIStore, appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Battery Tracker | none detected | PanelSection, PanelSectionRow, Router, SliderField, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | /sys/class/power_supply/BAT1/capacity; /sys/class/power_supply/BAT1/current_now; /sys/class/power_supply/BAT1/status; /sys/class/power_supply/BAT1/voltage_now |
|
||||
| Wine Cellar | Apps.GetAvailableCompatTools, Apps.SpecifyCompatTool, InstallFolder.GetInstallFolders, Settings.GetGlobalCompatTools, User.StartRestart | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Dropdown, Field, Focusable, GamepadButton, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBar, Router, ScrollPanelGroup, SidebarNavigation, TextField, definePlugin, showContextMenu, showModal, staticClasses | Router, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| MicroSDeck | none detected | ConfirmModal, DialogButton, DialogCheckbox, Field, Focusable, Menu, MenuItem, ModalPosition, Navigation, PanelSection, ReorderableList, ScrollPanelGroup, SideMenu, SidebarNavigation, SimpleModal, TextField, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, findInReactTree, findModule, findModuleChild, gamepadDialogClasses, quickAccessControlsClasses, showContextMenu, showModal, staticClasses | collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| ScreenshotUploader | GameSessions.RegisterForScreenshotNotification, Screenshots.UploadLocalScreenshot | DropdownItem, Field, PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| EmuDecky | none detected | ButtonItem, DialogButton, Dropdown, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SteamSpinner, ToggleField, definePlugin, showContextMenu, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| KDE Connect | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Reshadeck | Input.RegisterForControllerStateChanges, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest | ButtonItem, Dropdown, PanelSection, PanelSectionRow, definePlugin, staticClasses | toaster, window.SP_REACT, toasts | Python | gamescope |
|
||||
| CheatDeck | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions | DialogBodyText, DialogButton, DialogHeader, Dropdown, Field, Focusable, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, SidebarNavigation, TextField, ToggleField, afterPatch, definePlugin, fakeRenderComponent, findInReactTree, findInTree, findModuleByExport, showModal, staticClasses, useParams | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| BT Wake Control | none detected | ButtonItem, IconsModule, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | toaster, window.SP_REACT, toasts | Python | /sys/devices/virtual/dmi/id/board_name; /sys/devices/virtual/dmi/id/board_vendor; bluetoothctl |
|
||||
| Brightness Bar | System.Display.RegisterForBrightnessChanges | ButtonItem, ColorPickerModal, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup | Python | Targets Steam/QAM brightness shortcuts and a global Steam overlay; neither is attached to the Framey panel. |
|
||||
| Decky Notifications | none detected | ButtonItem, Menu, MenuItem, PanelSection, PanelSectionRow, definePlugin, showContextMenu, staticClasses | DeckyPluginLoader, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| MagicBlack | System.Audio.GetDevices, System.Audio.SetDeviceVolume | DialogButton, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, findModuleChild, findModuleExport, gamepadDialogClasses, joinClassNames, quickAccessMenuClasses, showModal, staticClasses | SteamUIStore, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | Targets a global Steam Deck screen-black overlay; the isolated panel cannot black out the Frame compositor. |
|
||||
| IsThereAnyDeal for Deck | User.GetIPCountry | DialogButton, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| MagicPods | Input.RegisterForControllerStateChanges, System.Audio.GetDevices, System.Audio.RegisterForDeviceVolumeChanged, System.Audio.SetDeviceVolume, System.Bluetooth, System.Bluetooth.RegisterForStateChanges, System.Bluetooth.SetEnabled | ButtonItem, DialogButton, Field, Focusable, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, ToggleField, afterPatch, definePlugin, findClassModule, findModuleChild, findModuleDetailsByExport, findSP, gamepadDialogClasses, gamepadSliderClasses, getGamepadNavigationTrees, joinClassNames, quickAccessMenuClasses, showContextMenu, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Junk-Store | Apps.AddShortcut, Apps.GetAvailableCompatTools, Apps.RemoveShortcut, Apps.RunGame, Apps.SetAppLaunchOptions, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Apps.SetShortcutStartDir, Apps.SpecifyCompatTool, GameSessions.RegisterForAppLifetimeNotifications, Input.RegisterForControllerInputMessages, Installs.OpenInstallWizard | Button, ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogLabel, Dropdown, Field, Focusable, GamepadButton, Marquee, Menu, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBar, ScrollPanelGroup, SidebarNavigation, SliderField, Spinner, SteamSpinner, Tabs, TextField, ToggleField, afterPatch, definePlugin, findClassModule, gamepadDialogClasses, gamepadTabbedPageClasses, joinClassNames, quickAccessControlsClasses, quickAccessMenuClasses, scrollPanelClasses, showContextMenu, showModal, sleep, staticClasses, useParams | Router, SteamUIStore, appDetailsStore, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | sudo |
|
||||
| Decky-Undervolt | GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend | ButtonItem, DialogButton, DropdownItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, TextField, ToggleField, definePlugin | Router, appStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python + native payload; x86_64 | RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. |
|
||||
| ScreenSaver | Input.RegisterForControllerInputMessages, Input.RegisterForControllerStateChanges, Settings.SetSetting, System.RegisterForOnSuspendRequest, System.UpdateSettings, User.RegisterForPrepareForSystemSuspendProgress | PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| steamdeck-input-disabler | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | Targets the built-in Steam Deck USB gamepad; Frame has different controllers. |
|
||||
| DeckySpy | System.RegisterForOnResumeFromSuspend | ButtonItem, Field, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Picture in Picture | none detected | ButtonItem, ConfirmModal, DropdownItem, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, findModuleChild, getGamepadNavigationTrees, quickAccessMenuClasses, showModal | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| PlayCount | none detected | Button, ButtonItem, DialogBody, DialogButton, DialogControlsSection, DialogHeader, DropdownItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, Tabs, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Crosshair | none detected | ButtonItem, PanelSection, PanelSectionRow, staticClasses | window.SP_REACT, QAM/global tabs | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Deck Settings | InstallFolder.GetInstallFolders, System.GetSystemInfo | ButtonItem, ConfirmModal, DialogBody, DialogButton, Dropdown, Field, Focusable, GamepadButton, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, ScrollPanelGroup, SliderField, Spinner, TextField, ToggleField, gamepadDialogClasses, scrollPanelClasses, showModal, staticClasses | Router, collectionStore, toaster, window.SP_REACT, toasts, QAM/global tabs, Steam stores | Python | /sys/class/dmi/id/sys_vendor; /sys/class/dmi/id/sys_vendor, |
|
||||
| Decky DoT DNS | none detected | ButtonItem, Dropdown, PanelSection, PanelSectionRow, TextField, ToggleField, staticClasses | window.SP_REACT | Python | systemctl |
|
||||
| Decky-SpoofDPI | User.StartRestart | ButtonItem, PanelSection, PanelSectionRow, TextField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Simple Timer | none detected | Button, ButtonItem, ConfirmModal, Focusable, PanelSection, PanelSectionRow, ToggleField, findModuleChild, showModal, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky-Framegen | none detected | ButtonItem, ConfirmModal, PanelSection, PanelSectionRow, showModal | toaster, window.SP_REACT, toasts | Python + native payload; Windows | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky-Lookup | none detected | ButtonItem, PanelSection, PanelSectionRow, Router, staticClasses | Router, window.SP_REACT | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky Zerotier | none detected | ConfirmModal, DialogBody, DialogButton, DialogSubHeader, Field, Focusable, PanelSection, PanelSectionRow, TextField, ToggleField, showModal | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Virtual Surround Sound | none detected | ConfirmModal, DialogButton, Dropdown, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, showModal, staticClasses | Router, window.SP_REACT, QAM/global tabs | Python | pactl |
|
||||
| XIVOmega | none detected | ButtonItem, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, Spinner, TextField, ToggleField, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | /sys/net/ethertypes.h; /sys/net/if_pflog.h; /sys/net/pfvar.h; /sys/socket.h.auto.html; /sys/sys/socket.h; sudo; x86_64 |
|
||||
| DeckSP | Input.ControllerKeyboardSetKeyState, System, System.Audio.GetDevices, System.Audio.RegisterForDeviceAdded, UI.RegisterForUIModeChanged, User.RegisterForShutdownStart | ButtonItem, ConfirmModal, DialogButton, Dropdown, DropdownItem, EUIMode, Field, Focusable, GamepadButton, Marquee, Menu, MenuGroup, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SimpleModal, SliderField, TextField, ToggleField, findModuleExport, gamepadContextMenuClasses, gamepadDialogClasses, quickAccessControlsClasses, quickAccessMenuClasses, scrollPanelClasses, showContextMenu, showModal, sleep | SteamUIStore, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | flatpak; systemctl |
|
||||
| Decky Ludusavi | Apps.GetLaunchOptionsForApp, GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, DialogButton, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField, showModal | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python | flatpak |
|
||||
| Game Sync | GameSessions.RegisterForAppLifetimeNotifications, GameSessions.RegisterForScreenshotNotification, Screenshots.DeleteLocalScreenshot | ButtonItem, ConfirmModal, DialogButton, Field, Focusable, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, ReorderableList, SidebarNavigation, TextField, ToggleField, afterPatch, fakeRenderComponent, findInReactTree, findInTree, findModuleByExport, showContextMenu, showModal, sleep, useParams | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | amd64 |
|
||||
| WiFi Locker | none detected | ButtonItem, Field, PanelSection, PanelSectionRow, staticClasses | toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| LetMeReShade | Apps.SetAppLaunchOptions | ButtonItem, ConfirmModal, DropdownItem, PanelSection, PanelSectionRow, ToggleField, showModal | window.SP_REACT | Python | /sys/devices/virtual/dmi/id/board_name; /sys/devices/virtual/dmi/id/product_name; /sys/devices/virtual/dmi/id/sys_vendor |
|
||||
| Decky Sunshine | none detected | ButtonItem, DialogButton, DialogButtonPrimary, Field, ModalRoot, PanelSection, PanelSectionRow, Spinner, TextField, definePlugin, showModal | window.SP_REACT | Python | flatpak |
|
||||
| HueSync | Settings.GetCurrentLanguage, System.GetSystemInfo, System.RegisterForBatteryStateChanges, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | DialogButton, DropdownItem, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, definePlugin, findModuleChild, gamepadSliderClasses, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. |
|
||||
| MuraDeck | System.Display.RegisterForBrightnessChanges | ButtonItem, ConfirmModal, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SideMenu, SidebarNavigation, SliderField, ToggleField, quickAccessControlsClasses, showModal | SteamUIStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python | Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. |
|
||||
| Dimmer Deck | Settings.GetCurrentLanguage, System.Display.RegisterForBrightnessChanges, System.Display.SetBrightness | PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts, Steam stores | Python | Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. |
|
||||
| Music Control | none detected | ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Better Keyboard | Settings.GetCurrentLanguage, UI.GetUIMode, UI.RegisterForUIModeChanged | ButtonItem, EUIMode, PanelSection, PanelSectionRow, ToggleField, staticClasses | SteamUIStore, window.SP_REACT, Steam stores | Python | /dev/input |
|
||||
| Decky LSFG-VK | none detected | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, Spinner, TextField, Toggle, ToggleField, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | flatpak; x86_64 |
|
||||
| Network Sentinel | none detected | ButtonItem, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky Clash | Settings.GetCurrentLanguage | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Dropdown, DropdownItem, Field, Focusable, GamepadButton, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, Spinner, TextField, ToggleField, showContextMenu, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Muon | System, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest | ButtonItem, DialogButton, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, Toggle, findModuleExport, showModal, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | sudo; systemctl |
|
||||
| DeckyParty File Manager | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | sudo |
|
||||
| Speed Test | none detected | ButtonItem, PanelSection, PanelSectionRow, staticClasses | none detected | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky Clipboard | none detected | ButtonItem, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Field, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, TextField, ToggleField, staticClasses | Router, routerHook, toaster, local/Steam routes, toasts | Python + native payload; x86_64 | gamescope; sudo |
|
||||
| Reroll | Apps.RegisterForGameActionUserRequest, GameSessions.RegisterForAppLifetimeNotifications, Settings.GetCurrentLanguage | ButtonItem, DialogButton, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, SliderField, libraryAssetImageClasses, mainMenuAppRunningClasses, quickAccessMenuClasses, showModal | SteamUIStore, QAM/global tabs, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| DeckPass | Apps.AddShortcut, Apps.RunGame, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Input.ControllerKeyboardSendText | Button, ButtonItem, DialogButton, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, TextField, findSP, showModal, sleep, staticClasses | Router, appDetailsStore, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | flatpak |
|
||||
| Steam Status | none detected | ButtonItem, ConfirmModal, DialogButton, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, showModal, staticClasses | toaster, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| AlarMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SidebarNavigation, SliderField, TextField, ToggleField, findModuleChild, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky Localsend | none detected | ButtonItem, DialogBody, DialogButton, DialogButtonPrimary, DialogFooter, DialogHeader, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, TextField, ToggleField, showModal, staticClasses | Router, routerHook, toaster, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Launch Options | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogButtonPrimary, DialogControlsSection, DialogControlsSectionHeader, DialogFooter, DialogHeader, Dropdown, Field, Focusable, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanel, SidebarNavigation, SteamSpinner, Tabs, TextField, Toggle, ToggleField, afterPatch, createReactTreePatcher, fakeRenderComponent, findInReactTree, findInTree, findModule, findModuleByExport, showModal, staticClasses, useParams | SteamUIStore, routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | flatpak; gamescope |
|
||||
| Decky Translator | User.RegisterForPrepareForSystemSuspendProgress | ButtonItem, DialogButton, Dropdown, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, findModuleChild, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | /dev/hidraw; /sys/class/hidraw/hidraw{i}; /sys/class/hidraw/hidraw{i}/device/uevent; x86_64 |
|
||||
| Quick User Switcher | none detected | ButtonItem, ConfirmModal, Focusable, PanelSection, PanelSectionRow, ToggleField, afterPatch, showModal, staticClasses, wrapReactType | Router, routerHook, local/Steam routes, Steam page patches/module lookup | Python | sudo |
|
||||
| ShadowDeck | none detected | ButtonItem, DialogButton, DropdownItem, Focusable, PanelSection, PanelSectionRow, TextField, Toggle, showModal, staticClasses | toaster, toasts | Python | amd64 |
|
||||
| Games Prefix Manager | none detected | ButtonItem, DialogButton, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, definePlugin, showModal, staticClasses | none detected | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Download All | Downloads, Downloads.RegisterForDownloadItems | ButtonItem, PanelSection, PanelSectionRow, SliderField | appStore, toaster, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| SuggestMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBar, SidebarNavigation, SliderField, Spinner, TextField, ToggleField, showModal, staticClasses | appStore, collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Non-Steam Badges | none detected | DialogButton, DropdownItem, Navigation, PanelSection, PanelSectionRow, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, findInReactTree, getGamepadNavigationTrees | collectionStore, routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Deck Progress Tracker | Apps.GetAllApps, Apps.GetMyAchievementsForApp | ButtonItem, DialogButton, Focusable, Navigation, PanelSection, PanelSectionRow, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, findInReactTree, staticClasses | appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Deck Shelves | Apps.RegisterForAppDetails, System.GetOSVersion, System.GetSystemInfo | showContextMenu | SteamUIStore, appDetailsStore, appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky-Clipper | none detected | ButtonItem, ModalRoot, Navigation, PanelSection, Router, ToggleField, showModal, staticClasses | Router | Python + native payload; x86_64 | gamescope |
|
||||
| Decky Proton Launch | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions, User.StartRestart | ConfirmModal, DialogButton, DropdownItem, Focusable, GamepadButton, ModalRoot, PanelSection, PanelSectionRow, TextField, ToggleField, showModal, staticClasses | toaster, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Docky | none detected | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, ToggleField, definePlugin, showModal | toaster, window.SP_REACT, toasts | Python | /sys/bus/usb/devices/; /sys/bus/usb/devices/*; /sys/bus/usb/drivers/usb/; /sys/bus/usb/drivers/usb/%s; /sys/class/drm/*/status; /sys/class/drm/card*-*/; /sys/class/hwmon/hwmon*; /sys/class/hwmon/hwmonN; /sys/class/power_supply/*/online; /sys/class/thermal; /sys/class/thermal/thermal_zone*/temp; amdgpu; flatpak; gamescope; hwmon; pactl; sudo; systemctl |
|
||||
| Output Manager | none detected | ButtonItem, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField, staticClasses | none detected | Python | /sys/class/drm; /sys/class/drm/card{card_number}/device; /sys/kernel/debug/dri; pactl |
|
||||
| IPv6 Enabler Plugin | none detected | DropdownItem, Field, PanelSection, PanelSectionRow, staticClasses | toaster, toasts | Python | /sys/net/ipv6/conf |
|
||||
| DeckyClocker | none detected | ButtonItem, Field, PanelSection, PanelSectionRow, SliderField | toaster, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| MuteOnSleep | none detected | PanelSection, PanelSectionRow, ToggleField | Steam stores | Python | pactl; sudo |
|
||||
| Desktop Apps | Apps.AddShortcut, Apps.RemoveShortcut, Apps.RunGame, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Apps.SetShortcutStartDir, GameSessions.RegisterForAppLifetimeNotifications | DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, showContextMenu, staticClasses | appStore, toaster, toasts, Steam stores | Python | flatpak |
|
||||
| Mixer | none detected | ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, SliderField, TextField, showModal, staticClasses | toaster, toasts | Python | flatpak |
|
||||
| VARTA | Store.SetWishlist, StoreItems.SetWishlist, User, User.GetSteamID | ButtonItem, DropdownItem, Field, PanelSection, PanelSectionRow, Spinner, ToggleField, afterPatch, appDetailsClasses, beforePatch, createReactTreePatcher, findInReactTree, findModule, findModuleByExport, findModuleExport, staticClasses, useParams | routerHook, toaster, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
|
||||
## Native payload portability
|
||||
|
||||
All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. The three Windows DLLs belong to Decky-Framegen. The installer still rejects these archives. Apart from the explicitly described LocalSend experiment, ARM64 builds were not attempted: the constraints below remain unresolved.
|
||||
|
||||
| Plugin | Native payloads | ARM64 result / remaining blocker |
|
||||
| --- | --- | --- |
|
||||
| Fantastic | bin/backend | Fan backend/driver is Deck-specific; an ARM64 rebuild would not provide compatible Frame hardware. |
|
||||
| PowerTools | bin/libryzenadj.so, bin/ryzenadj, bin/backend | RyzenAdj and AMD power backend cannot control an ARM64 Frame SoC. |
|
||||
| Memory Deck | bin/libscanmem.so.1.0.0 | libscanmem must be rebuilt for aarch64 and validated against target process architecture; memory writes were not tested. |
|
||||
| Emuchievements | bin/hash | The bin/hash helper needs an ARM64 build and cross-platform hashing tests; no ARM64 artifact is published. |
|
||||
| MoonDeck | python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_parser.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/reader_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/mask.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_writer.cpython-313-x86_64-linux-gnu.so, … 26 total; full list in JSON | Rebuild vendored CPython 3.13 extension wheels for the headset Python ABI and ARM64; buddy/helper dependencies also need ports. |
|
||||
| Controller Tools | bin/backend | Native backend needs an ARM64 build and controller/HID dependencies; its direct websocket also bypasses the Framey bridge. |
|
||||
| DeckMTP | bin/umtprd | umtprd needs ARM64 libusb/MTP dependencies and USB gadget permissions; root user-service blocker remains. |
|
||||
| Decky Cloud Save | bin/rclone | rclone needs an ARM64 artifact; this ZIP also fails safe member validation. Cloud writes were not exercised. |
|
||||
| DeckyFileServer | bin/backend | Native file-server backend needs ARM64 dependencies; root requirement also blocks the user service. |
|
||||
| TS3 QuickAccess | bin/ts3-qs4sd.so | TeamSpeak client plugin must match an ARM64 TeamSpeak client and SDK ABI, not merely rebuild the .so. |
|
||||
| Syncthing | bin/decky-syncthing-watchdog | The watchdog needs an ARM64 build, plus a supported Syncthing runtime/package and safe path validation. |
|
||||
| Wine Cellar | bin/backend | Native backend needs an ARM64 build; direct websocket and Wine runtime assumptions also need integration. |
|
||||
| MicroSDeck | bin/backend | Native backend needs an ARM64 build and block-device/mount permission audit; no storage actions were run. |
|
||||
| EmuDecky | bin/hello | Published bin/hello is x86_64 even if unused; upstream must remove it or publish an ARM64 archive, plus remove root dependency. |
|
||||
| MagicPods | bin/magicpodscore | magicpodscore requires an upstream ARM64 artifact/source and protocol tests; frontend uses a direct websocket. |
|
||||
| Decky-Undervolt | bin/libryzenadj.so, bin/ryzenadj | RyzenAdj is AMD-specific and cannot be ported to control the Frame SoC. |
|
||||
| Decky-SpoofDPI | bin/spoofdpi | SpoofDPI is a separate upstream helper; needs an ARM64 artifact/protocol and network-permission validation. |
|
||||
| Decky-Framegen | bin/nvngx.dll, bin/dlssg_to_fsr3_amd_is_better.dll, bin/nvapi64.dll | Windows DLL payload targets x86 game translation rather than a native ARM backend; archive is 75,140,408 bytes, beyond the 50 MiB installer limit. |
|
||||
| HueSync | bin/hello, py_modules/portio.so | portio and handheld LED backends depend on x86 I/O/unsupported hardware; ARM64 compilation alone is insufficient. |
|
||||
| Decky Clipboard | bin/xclip | xclip needs ARM64 X11 libraries/server; root and gamescope assumptions also block this archive. |
|
||||
| Reroll | bin/memscan.so | memscan.so needs ARM64 process-memory support and ABI testing; root requirement remains. Frontend initializes but no memory access was tested. |
|
||||
| Decky Localsend | bin/localsend-core | LocalSend-Go commit 66edc509a28b9f877e7374504892dcbc653a4da4 cross-compiled successfully with CGO_ENABLED=0 GOOS=linux GOARCH=arm64. Static aarch64 ELF SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Not executed; source tip is not proven to match the shipped helper/protocol. Official ZIP is still x86_64. |
|
||||
| Launch Options | bin/hello | Remove the template x86 bin/hello or publish an ARM64 rebuild; missing Steam module mapping also prevents frontend initialization. |
|
||||
| Decky-Clipper | bin/gstreamer-1.0/libgstproxy.so, bin/gstreamer-1.0/libgstmidi.so, bin/gstreamer-1.0/libgstmpegtsmux.so, bin/gstreamer-1.0/gst-plugin-scanner, bin/gstreamer-1.0/libgstivfparse.so, … 115 total; full list in JSON | Rebuild all bundled GStreamer libraries/plugins for ARM64 and verify capture/codec ABI against Frame SteamVR; gamescope capture differs. |
|
||||
|
||||
## Evidence and maintenance
|
||||
|
||||
The canonical JSON contains full archive hashes, native paths, source URLs, frontend observations and fake-Frame outcomes. Store compatibility is advisory except known installation/hardware blockers; removal always remains available. A panel retains its compatibility reason after the initializer reports ready. Upstream fixes require a new digest-scoped audit. Do not mark a plugin works based on archive installation, fake backend replies or initializer completion.
|
||||
@@ -1,229 +0,0 @@
|
||||
# Framey loader on Steam Frame
|
||||
|
||||
Framey is an experimental bridge for Decky store plugins inside Frame Control's
|
||||
SteamVR dashboard panel. It is opt in. The panel and Decky backend are ordinary
|
||||
user services under `~/.local/share/frame-control`; neither needs root or a
|
||||
read-write SteamOS root filesystem. Decky's standard Quick Access menu still
|
||||
does not appear in the separate SteamVR CEF target.
|
||||
|
||||
See the [complete official-store compatibility table](framey-compatibility.md) for version-specific statuses, blockers and verification limits. Both stores and installed panels show the same digest-scoped results; a new published archive starts unverified.
|
||||
|
||||
## Use in the headset
|
||||
|
||||
Install **Frame Control in VR** and **Decky Loader on Frame** from Frame Control
|
||||
on your computer. Open the Frame Control tab in SteamVR, then choose **Decky
|
||||
plugins → Open official store**. Search the catalogue, confirm an install, and
|
||||
choose **Open panel** from the installed plugin list. The panel contains the
|
||||
plugin's own controls and settings. Update and Remove affect only plugin
|
||||
folders that Frame Control's store manager installed.
|
||||
Removing a plugin keeps its Decky settings. Removing the Frame Control headset
|
||||
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, 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
|
||||
API v2 bundles and API v1 bundles using Decky's modern frontend initializer,
|
||||
with `dist/index.js`. Legacy `(function (...) {...})(DFL, SP_REACT)` bundles are
|
||||
also accepted: their returned initializer receives the legacy `serverAPI`, and
|
||||
keyword backend calls use Decky's legacy RPC. The wrapper runs as a blob ES
|
||||
module inside the same sandbox; it does not require `eval`. Other installed plugins remain manageable but have no
|
||||
Framey panel. API v1 admission is a compatibility attempt, not a guarantee
|
||||
that the plugin's controls work in VR. The store manager does not currently
|
||||
screen a plugin's runtime network downloads.
|
||||
|
||||
## What the compatibility host supplies
|
||||
|
||||
The plugin bundle runs in an opaque-origin iframe in the Frame Control SteamVR
|
||||
panel. It receives React 18.3.1, a small `DFL` panel-control shim, and the
|
||||
Decky API v2 `call`/`callable` interface. Backend methods are sent by the
|
||||
authenticated parent to Steam's existing `SharedJSContext` CDP client, then
|
||||
through Decky's *existing* websocket connection. Framey never opens a second
|
||||
Decky websocket, because Decky allows only one frontend connection. The iframe
|
||||
does not receive Frame Control's UI key. React and ReactDOM are bundled for
|
||||
offline use under their MIT licenses in `ui/vendor`.
|
||||
Framey gives each plugin a bounded `localStorage` substitute because browser
|
||||
storage is denied to an opaque-origin iframe. The parent panel keeps a separate
|
||||
cache for each plugin: up to 64 keys, 64 KiB per value and 128 KiB total. It
|
||||
restores the cache when a panel reopens if the parent's browser storage is
|
||||
available. If storage is denied or cleared, the cache lasts only for the current
|
||||
panel. Use the plugin backend for settings that must survive cache loss.
|
||||
|
||||
Decky API v1/v2 `addEventListener` and `removeEventListener` receive real backend
|
||||
events while the panel is open. The parent resolves the plugin name from its
|
||||
installed store receipt, polls Steam's existing Decky event connection, and sends
|
||||
only subscribed events to that panel session. A subscription is established
|
||||
before a backend call can emit its response. The bridge allows 32 event names,
|
||||
queues at most 64 events and 48 KiB, and drops any event whose arguments exceed
|
||||
8 KiB. Polling occurs about once a second, so this is unsuitable for high-rate
|
||||
input/audio streams. Closing/reopening a panel removes its subscriptions;
|
||||
abandoned CEF subscriptions expire after 15 seconds. This does not deliver
|
||||
global notifications while a Framey panel is closed.
|
||||
|
||||
The [26 October live checks](evidence/framey-events-backend-2026-10-26.json)
|
||||
used the actual official Notebook and Simple Timer Python backends in a private
|
||||
removable ARM64 Decky service. Notebook's default notebook saved a pointer stroke
|
||||
and loaded it after a backend restart. Simple Timer's start/cancel controls,
|
||||
countdown, completion toast and persisted recents/subtle mode worked. Simple
|
||||
Timer remains partial: its own expired-timer startup raises a missing
|
||||
`timer_task` error, sound/suspend are unsupported, and worn VR was not checked.
|
||||
|
||||
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.
|
||||
|
||||
ProtonDB Badges' current API v2 bundle rendered its settings in a local Chromium
|
||||
browser with a stubbed backend reply on 25 October 2026. Its controls accepted
|
||||
pointer input. This does **not** verify SteamVR visibility, controller input,
|
||||
live Decky settings persistence, or badges on Steam game pages. The last item
|
||||
has no supported injection target in the Frame Control panel.
|
||||
The [browser and fake Frame evidence](evidence/framey-browser-2026-10-25.md)
|
||||
records exactly what was exercised.
|
||||
The pinned official Steam Status 1.2.3 frontend also
|
||||
[rendered in local Chromium](evidence/framey-steam-status-browser-2026-10-25.md)
|
||||
with a stubbed settings reply. Its public status request worked, but its cache
|
||||
logged `localStorage` errors in Framey's v0.4.34 opaque iframe. A later
|
||||
[local browser check](evidence/framey-session-cache-2026-10-25.md) confirmed
|
||||
that a panel-lifetime substitute avoids those errors while preserving the
|
||||
opaque origin. A [second browser check](evidence/framey-cache-browser-2026-10-25.md)
|
||||
tested the per-plugin cache after reopening a panel. These checks used a stub
|
||||
backend and no worn headset.
|
||||
Fake Frame CI also installs and removes pinned official ProtonDB Badges 1.3.4,
|
||||
Steam Status 1.2.3, and Storage Cleaner 1.4.0 archives, checks their API v1/v2
|
||||
manifests and frontend bytes, and verifies they can be packed as Framey panels.
|
||||
It does not execute their actual backend code or prove their frontend controls
|
||||
work in VR. Storage Cleaner rendered an
|
||||
[empty-data panel in local Chromium](evidence/framey-api-v1-browser-2026-10-25.md).
|
||||
Its cleanup actions were not tested; they may remove local game saves.
|
||||
|
||||
Framey also supplies local tabs/sidebar pages, spinners, dialog/menu wrappers
|
||||
and progress bars. Its explicit SteamClient subscription fallbacks return
|
||||
disposable handles without emitting fabricated events. There is no attached
|
||||
running game in `DFL.Router`, and power, app launch and hardware mutation methods
|
||||
are not supplied. Plugins that need live Steam stores still require integration
|
||||
work. IndexedDB is hidden because the browser exposes it but denies access in
|
||||
an opaque iframe; localForage can then select Framey's bounded localStorage.
|
||||
Direct IndexedDB users remain unsupported.
|
||||
|
||||
`SuspensefulImage` renders a native image. SteamClient `Storage.GetJSON` and
|
||||
`SetObject` use the same bounded per-plugin cache, including panel reopen
|
||||
persistence; they do not read Steam's account/global JSON store. Missing keys
|
||||
reject with `Not found`. Detached `SteamUIStore.RunningApps` is a frozen empty
|
||||
list, and the suspend store has no methods. Optional Steam app/account globals
|
||||
are undefined. These fallbacks let settings render without inventing live
|
||||
games or account identity. [The exact-bundle follow-up](evidence/framey-ui-fallbacks-2026-10-26.json)
|
||||
verifies Pause Games and Tailscale Control settings in local Chromium; their
|
||||
primary backend actions remain unverified. PlayTime still requires Steam's
|
||||
real app store and game observers.
|
||||
|
||||
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. 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.
|
||||
|
||||
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. The headset panel and desktop app send the hash of
|
||||
the version you saw; if the official store changes that version before the job
|
||||
starts, Frame Control stops before downloading or changing the headset. Refresh
|
||||
the store and review the new version before retrying. If Frame Control restarts, it cannot recover the old
|
||||
job result. The panel clears that job ID and asks you to refresh the installed
|
||||
list before making another change.
|
||||
|
||||
## 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
|
||||
by `definePlugin`, and build settings as panel controls from `@decky/ui`.
|
||||
Backend calls through `@decky/api` reach only that plugin's own backend.
|
||||
For best VR compatibility, keep the panel self-contained and use `PanelSection`,
|
||||
`PanelSectionRow`, `ButtonItem`, `ToggleField`, and `DropdownItem`. Handle a
|
||||
missing Steam UI route, store overlay, or `SteamClient` object gracefully.
|
||||
Avoid root, remote binary downloads, Steam library patches, and assumptions
|
||||
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.
|
||||
|
||||
## Removal
|
||||
|
||||
Use **Remove** on each installed Framey plugin, then **Remove managed Decky**
|
||||
and **Remove** under Frame Control in VR. These operations remove only
|
||||
Frame Control-owned folders and services. Settings, the Chromium profile, and
|
||||
other users' plugins are preserved. To remove one managed plugin manually,
|
||||
first inspect its `.frame-control-store.json` receipt in
|
||||
`~/.local/share/frame-control/decky/plugins/<folder>`, then delete only that
|
||||
folder and run `systemctl --user try-restart frame-control-decky.service`.
|
||||
The Decky and headset service manual removal commands are in
|
||||
[Decky on Frame](decky-frame.md#opt-in-and-remove) and
|
||||
[Frame Control in the headset](headset-app.md#remove).
|
||||
|
||||
## Fast worn-headset check
|
||||
|
||||
With an already installed Framey plugin and someone wearing the headset, run
|
||||
`python3 scripts/framey-worn-check.py --worn-confirmed` from a paired computer. The script
|
||||
atomically acquires `/tmp/frame-test.lock` before reading battery or SteamVR
|
||||
activity. If another job owns the lock, it makes no sensor reads. It releases
|
||||
its own lock without starting the interaction when the headset is in standby.
|
||||
Within 35 seconds, use a controller
|
||||
to open a managed plugin panel and touch one setting. The script records a
|
||||
fresh trusted pointer event, the panel-ready event, SteamVR overlay visibility,
|
||||
and a stereo screenshot with varied pixels. It deletes its own short-lived
|
||||
challenge and releases only its own lock. It records the screenshot hash and
|
||||
color count, then removes the private temporary image from both devices.
|
||||
Pixel variety alone cannot prove the UI text was legible; a person must still
|
||||
confirm the view in the headset. The script does not install or remove a plugin,
|
||||
alter Steam settings, or wake the headset.
|
||||
|
||||
|
||||
## Unattended VR checks
|
||||
|
||||
The [unattended check](framey-vr-check.md) can wake, inspect and capture the
|
||||
real compositor, send OpenVR mouse input, and exercise a temporary sample
|
||||
against live Decky with restoration and shared-lock protection. Its
|
||||
[device evidence](evidence/framey-vr-2026-10-25/README.md) distinguishes these
|
||||
checks from physical controller and wearer validation. A trusted pointer event
|
||||
and active-headset telemetry are both reproducible without wearing the Frame;
|
||||
they must not be labelled as physical controller proof.
|
||||
@@ -1,115 +0,0 @@
|
||||
# Checking Framey without wearing the Frame
|
||||
|
||||
Use the existing paired SSH alias, directly or through lxso2. Start with the
|
||||
read-only check, then a temporary wake. A full input exercise is opt in:
|
||||
|
||||
```sh
|
||||
python3 scripts/framey-vr-check.py --via lxso2 --output /tmp/framey-inspect
|
||||
python3 scripts/framey-vr-check.py --via lxso2 --wake --output /tmp/framey-wake
|
||||
python3 scripts/framey-vr-check.py --via lxso2 --exercise --output /tmp/framey-exercise
|
||||
```
|
||||
|
||||
Omit `--via` when the computer can already `ssh frame`; use `--host` for another
|
||||
paired alias. No SSH trust or credentials are changed. The output folder is
|
||||
private by default. Stereo screenshots and the OBS mirror can contain room
|
||||
pixels. Review/redact them before committing or sharing. JSON reports have
|
||||
screenshot hashes and no UI access keys.
|
||||
|
||||
The runner acquires `/tmp/frame-test.lock` by atomic `mkdir`, writes a random
|
||||
owner, then reads battery and SteamVR state. A foreign lock prevents sensor
|
||||
reads, wake, installation and input. Unknown battery or below 20% aborts.
|
||||
Battery is read again before release. The default check never wakes or
|
||||
installs anything; it returns nonzero when the panel is absent/hidden.
|
||||
|
||||
`--wake` saves the exact current `power.pauseCompositorOnStandby` and
|
||||
`power.turnOffScreensTimeout`, sets them to `0` and `120`, calls the supported
|
||||
`vrcmd --handlewakeup` and requests the Frame Control dashboard panel. It reads
|
||||
OpenVR state, captures a stereo compositor screenshot through the production
|
||||
`ui/frame_vrshot.py` API, and captures `/dev/video99`, the same SteamVR mirror
|
||||
used by OBS. The mirror reader drains 60 frames before selecting its image,
|
||||
because the first loopback buffer can contain a previous session. It restores the saved power values and reads them back. It hides
|
||||
the test dashboard if the dashboard was originally closed. It does not change
|
||||
the proximity sensor, tracking driver, display hardware, root filesystem,
|
||||
room setup, or spatial panel placement. An unreachable suspended Frame still
|
||||
needs a physical wake; an SSH command cannot reach it.
|
||||
|
||||
`--exercise` requires the managed headset panel and Decky services to be absent;
|
||||
it refuses to replace an existing installation. It creates a random test
|
||||
plugin folder, checks that its settings folder does not exist, and installs
|
||||
the repository's actual sample frontend/backend, with only the plugin name
|
||||
and receipt changed to distinguish this test. It installs the verified official
|
||||
ARM64 Decky release through Frame Control's existing installer, followed by the
|
||||
normal headset app bundle. Only the test bundle's browser startup is modified:
|
||||
loopback-only `--remote-debugging-port=0` and a disposable `/tmp` browser profile.
|
||||
Production browser startup is unchanged. Decky's installer can reload Steam's
|
||||
web helper; this is not a Steam/SteamVR restart, but its current UI can change.
|
||||
The test should run while the headset is unattended.
|
||||
|
||||
CDP opens the actual plugin through `openFrameyPanel`, observes the opaque
|
||||
iframe through its own debugging target, and reads control geometry. OpenVR's
|
||||
`IVROverlayView_003.PostOverlayEvent` supplies mouse move/down/up to the
|
||||
actual gamescope overlay. The check verifies delivery to the intended element
|
||||
and a trusted pointer event, toggles the sample, reads its real Decky settings
|
||||
file, reopens a fresh iframe, and checks the saved value. It also clicks the
|
||||
Settings/Panel tabs and quick action. A smooth-scroll event is attempted and its measured scroll offset is reported.
|
||||
Temporary controls exercise the shipped
|
||||
`DFL.SliderField` and `DFL.TextField`; their values remain UI-only. Text uses
|
||||
CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input,
|
||||
not proof of a physical controller or the SteamVR virtual keyboard.
|
||||
|
||||
SteamVR can leave a recreated panel hidden with a stale focus request. The
|
||||
exercise retries once by removing and reapplying `STEAM_GAME` on its own test
|
||||
browser window, then requests focus again. It never retags another application.
|
||||
The report records whether this recovery was needed. Registration, dashboard
|
||||
visibility, actual panel visibility, and pixel capture are separate checks.
|
||||
A visible docked panel can report `IsDashboardVisible=false` and
|
||||
`IsActiveDashboardOverlay=false`. A zero-sized main texture or failed image
|
||||
read must not be described as a texture-refresh measurement. The compositor
|
||||
before/after images provide independent pixel evidence.
|
||||
|
||||
Normal cleanup removes only the test-created managed services and random
|
||||
plugin/profile paths, restores power, cancels its power watchdog, checks battery
|
||||
and settings, then releases its own lock. A four-minute independent systemd
|
||||
user timer restores the saved power values even if the controlling process is
|
||||
killed or disconnected. It deliberately leaves the lock and test artifacts
|
||||
for recovery. `recovery.json` records the host, owner, timer, remote restore
|
||||
script and original values as soon as the timer starts. The final report is
|
||||
`result.json`. Cleanup errors force a nonzero exit and retain the lock; inspect
|
||||
that report before manually releasing it. Never remove another job's lock.
|
||||
A power watchdog is not a promise to complete service cleanup after a hard
|
||||
kill. The next owner must finish that recorded cleanup first.
|
||||
|
||||
Exit 0 means the requested machine checks and cleanup passed. It never means
|
||||
a wearer has approved readability, comfort, controller rays, haptics or text
|
||||
entry on the VR keyboard. The [real-device evidence](evidence/framey-vr-2026-10-25/README.md)
|
||||
records the exact tested version and limits. No public CI runner should contact
|
||||
the shared headset: run this command on a paired, serialized self-hosted worker.
|
||||
Ordinary CI runs `tests/test_framey_vr_check.py`, the sample backend tests, and
|
||||
the same-plugin navigation regression without a headset.
|
||||
|
||||
## Three-minute worn checklist
|
||||
|
||||
1. **0:00–0:30:** Coordinate the shared lock. With an installed managed panel,
|
||||
Decky backend and harmless sample, wear the headset and open SteamVR →
|
||||
Frame Control. Check the panel is in front of you, both eyes agree, text is
|
||||
legible, and the size is comfortable. Do not use a save-cleaning plugin.
|
||||
2. **0:30–1:15:** Run
|
||||
`python3 scripts/framey-worn-check.py --via lxso2 --worn-confirmed`. Within its 35-second
|
||||
interaction window, use the physical controller to open the sample and
|
||||
toggle **Example setting**. Confirm the laser aims where expected and the
|
||||
script records a fresh input/panel event and visible stereo output. The flag
|
||||
is the operator’s statement; sensor activity and trusted pointers can also
|
||||
be produced synthetically and cannot identify controller hardware.
|
||||
3. **1:15–2:15:** Use the controller to open Settings, return to Panel, invoke
|
||||
**Read saved setting**, close/reopen the plugin, and confirm the value
|
||||
remains. Try controller scroll, a slider and a harmless text field in an
|
||||
installed compatible plugin; check SteamVR's actual keyboard and Back.
|
||||
4. **2:15–3:00:** Restore the original sample setting. Switch away and back,
|
||||
close/reopen the Frame Control panel, and confirm controller input still
|
||||
works. Record comfort/legibility, controller model, any haptic behavior,
|
||||
keyboard behavior and the command's JSON separately. Confirm battery and
|
||||
release only your lock. Do not infer these human checks from synthetic input.
|
||||
|
||||
The watchdog's creation and cancellation are exercised on the real Frame.
|
||||
A forced host kill/network loss followed by timer recovery has not been fault
|
||||
injected. Its saved recovery script remains available on a cleanup failure.
|
||||
+2
-12
@@ -14,13 +14,8 @@ panel. **Show in SteamVR** requests that dashboard tab; the wearer can select
|
||||
it again from SteamVR's dashboard. Closing the panel stops its browser and
|
||||
server. The service is not enabled at boot.
|
||||
|
||||
The page shows battery, free home storage and SteamVR status.
|
||||
The **Large status screen** uses large read-only values for battery, SteamVR,
|
||||
free home storage and Frame OS, with a refresh button and clear unknown values.
|
||||
It is an at-a-glance view, not a crash diagnosis or a Decky plugin page.
|
||||
The headset must still be worn to verify that a person can see and use it.
|
||||
|
||||
The page can request SteamVR's main Game Mode dashboard, list and force quit a running game after an
|
||||
The page shows battery, free home storage and SteamVR status. It can request
|
||||
SteamVR's main Game Mode dashboard, list and force quit a running game after an
|
||||
unsaved-progress confirmation, save an OpenVR screenshot to
|
||||
`~/Pictures/FrameControl`, and record the SteamVR mirror to
|
||||
`~/Videos/FrameControl`. Recordings have a 20-minute limit and use a separate
|
||||
@@ -33,11 +28,6 @@ It offers the version 1 read-only `capabilities` and `status` bridge from
|
||||
[the extension guide](extensions.md). Installing an extension still means
|
||||
copying its folder to the Frame; the panel does not download third-party code.
|
||||
|
||||
The opt-in [Framey loader](framey-loader.md) hosts compatible Decky plugin
|
||||
panels and settings in a separate sandboxed iframe on this SteamVR page. The
|
||||
existing official Decky store manager installs and removes their code. The
|
||||
frontend host is browser-tested, but wearer-visible VR use remains unverified.
|
||||
|
||||
The panel also lists Decky plugins and can install, update, or remove compatible
|
||||
plugins from the official store when the opt-in managed loader is running.
|
||||
Each change asks for confirmation. The Frame VR shell does not show Decky's
|
||||
|
||||
+6
-38
@@ -70,31 +70,11 @@ game recording path and were not checked for passthrough in this test.
|
||||
|
||||
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
|
||||
clips and background recordings, newest first, with the game, length and size.
|
||||
**Export** joins one into a single `.mp4` under `~/Videos/SteamFrame`, named after
|
||||
its clip folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
|
||||
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
|
||||
Steam's own export is reported to stop at about 60 seconds. Exports run in the
|
||||
background; ones already on your computer are marked.
|
||||
|
||||
Each export keeps its headset, Steam account and complete recording session ID:
|
||||
|
||||
```
|
||||
~/Videos/SteamFrame/headset-<id>/<account>/clips/<clip>/video/<session>/<clip>.mp4
|
||||
~/Videos/SteamFrame/headset-<id>/<account>/video/<session>/<session>.mp4
|
||||
```
|
||||
|
||||
The headset folder uses the same short device-identity hash as screenshots.
|
||||
All folders in the Steam clip ID are kept, including nested sessions, so equal
|
||||
clip names across accounts, headsets or sessions keep separate files. Manual
|
||||
**Export** and automatic copying use the same path and saved marker. Exporting
|
||||
the same identified session again replaces only that session's export.
|
||||
|
||||
Existing exports in the old flat `~/Videos/SteamFrame/<clip>.mp4` layout stay
|
||||
untouched. Their source identity cannot be proved, so they do not mark a clip
|
||||
as saved. Export again or use **Copy everything now** to make an identified
|
||||
copy. Automatic copying still remembers IDs copied before the upgrade; use
|
||||
**Copy everything now** to copy those again. This destination change has local
|
||||
fixture coverage; it has not been tested on a real headset.
|
||||
|
||||
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
|
||||
recording session.
|
||||
|
||||
@@ -158,29 +138,18 @@ case was tested with separate fake sources, not two real same-name screenshots.
|
||||
- **When this computer is full** (under 512 MB free, or a copy runs out of
|
||||
space) it pauses and says so in Settings and above the lists. Free some space
|
||||
and press **Resume**.
|
||||
- **When one file keeps failing** it tries at most three times, then leaves
|
||||
the problem visible in Settings. Fix the source problem and press **Resume**
|
||||
to allow another three attempts without restarting Frame Control. **Copy
|
||||
everything now** also resets failed attempts and includes older files.
|
||||
Resume keeps the history of successful copies, so it does not recopy them.
|
||||
- **With several headsets** each check and copy belongs to the headset selected
|
||||
when the check began. Switching between lists or copies stops that pass;
|
||||
another headset's saved files cannot become part of its copy history.
|
||||
- **With several headsets** it copies from the one you're connected to.
|
||||
|
||||
Settings shows when it last checked, how many files it last copied and when,
|
||||
and the last problem it had. The screenshot and clip lists say when it's on.
|
||||
Retry and switch handling have offline regression coverage with fake sources;
|
||||
see the [test record](evidence/autosync-reliability-2026-10-25.md).
|
||||
|
||||
## For developers
|
||||
|
||||
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
|
||||
copying, in `ui/frame_autosync.py`, does):
|
||||
|
||||
- `list_clips(run_frame, device_id=...)` and
|
||||
`export_clip(id, run_frame, run, fetch, device_id=...)` are the computer's
|
||||
side; the server passes how to reach the Frame and its pinned device identity.
|
||||
Both use `clip_local_path(id, device_id, folder=None)` for the destination.
|
||||
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
|
||||
computer's side; the server passes how to reach the Frame.
|
||||
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
|
||||
run as `python3 - list` / `python3 - export ID` with the file on stdin.
|
||||
- `Recorder` runs one recording from Start to the copy.
|
||||
@@ -203,8 +172,7 @@ The API, with the same guards as every other request:
|
||||
They aren't available when the server runs on the Frame itself (the iPhone
|
||||
app), since they save to a computer.
|
||||
|
||||
Tests: `tests/test_clips.py`, `tests/test_clip_copy.py` for server destinations
|
||||
and collision coverage, and `tests/test_autosync.py` for automatic
|
||||
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
|
||||
copying (with a fake headset). They run the recording's shell under bash with a
|
||||
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
|
||||
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
|
||||
|
||||
@@ -20,12 +20,6 @@ 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.
|
||||
|
||||
+1
-23
@@ -12,21 +12,6 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
|
||||
## Unit tests
|
||||
|
||||
Automatic screenshot and clip copying has offline coverage in
|
||||
`tests/test_autosync.py`:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_autosync.py -v
|
||||
```
|
||||
|
||||
Fake sources exercise the three-attempt limit, visible exhausted errors,
|
||||
Resume/Copy everything now recovery, successful-copy deduplication, disk-full
|
||||
pauses and headset changes around lists and transfers. Server tests use the
|
||||
production `working(meant)` reservation and listing/copy adapters; a Node test
|
||||
executes the Settings renderer to check that Resume is available after an error.
|
||||
These tests use no headset. See the
|
||||
[auto-sync reliability test record](evidence/autosync-reliability-2026-10-25.md).
|
||||
|
||||
The gamescope keyboard/trackpad route has focused suites in `test_input.py`,
|
||||
`test_touch.py` and `test_service_regressions.py`. They cover pad translation,
|
||||
ASCII/modifier keys, Unicode queuing/retries, stale panel targets, release
|
||||
@@ -261,14 +246,7 @@ title; the following process sweep found none. The final cleanup found no
|
||||
test titles or processes, the test desktop was closed, and the battery
|
||||
remained at 99%. That historical run used the Python entry point directly;
|
||||
the current POSIX wrapper has local offline coverage on a PATH without zsh.
|
||||
The current runner and all-six-ID cleanup sweeps passed another 13-step real
|
||||
Frame run on 25 October, build `20261022.6153909`, at source commit `1255ef2`.
|
||||
The Windows title's remove step stopped five test-owned Proton/wrapper PIDs;
|
||||
the final sweep and independent readback found no reserved files or processes.
|
||||
SteamVR stayed running, observed focus properties matched before/after, and
|
||||
this task's shared lock was released. The headset was unworn. This run did
|
||||
not seed synthetic orphan metadata or shortcuts; those cases use local
|
||||
fixtures. [Sanitized results](evidence/round2-smoke-2026-10-25.json).
|
||||
The orphan-metadata and shortcut follow-up has not been rerun on a headset.
|
||||
The local regression suite runs real Linux processes in temporary fake title
|
||||
directories (including deleted folders and a TERM-resistant wrapper); it checks
|
||||
exit, escalation and survival of unrelated processes. Local filesystem commands
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
node_modules/
|
||||
@@ -1,53 +0,0 @@
|
||||
# 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_PLUGIN_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
|
||||
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.
|
||||
|
||||
The **About this setting** button opens a local detail page at
|
||||
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
|
||||
`Navigation.Navigate`, `useParams`, `NavigateBack`, and removal of the route in
|
||||
`onDismount`. The `:topic?` segment is optional. Framey keeps this navigation
|
||||
inside the plugin panel; it does not open a Steam library page. Put optional
|
||||
segments at the end of the path and do not use query strings. This detail page
|
||||
has not been checked with headset controllers.
|
||||
|
||||
## Build
|
||||
|
||||
From this directory, run:
|
||||
|
||||
```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, including the quick action's scoped
|
||||
backend call and local route registration/navigation. The fake Frame store flow is exercised by
|
||||
the repository's e2e suite. No worn-headset visibility or controller-input test
|
||||
has passed for this sample yet.
|
||||
|
||||
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.
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
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 routerHook = api.routerHook;
|
||||
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(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
|
||||
}
|
||||
function Details() {
|
||||
const { topic } = DFL.useParams();
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Sample detail page", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsxs("p", { children: ["Topic: ", topic || 'overview'] }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { children: "The toggle is stored by this plugin's Decky backend in the user's settings directory." }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.NavigateBack(), children: "Back to setting" }) })] });
|
||||
}
|
||||
var index = definePlugin(() => {
|
||||
routerHook.addRoute('/framey-sample/details/:topic?', Details);
|
||||
return Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
settings: SP_JSX.jsx(Content, {}),
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
export { index as default };
|
||||
//# sourceMappingURL=index.js.map
|
||||
-1
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return <PanelSection title=\"Sample detail page\">\n <PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>\n <PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => {\n routerHook.addRoute('/framey-sample/details/:topic?', Details);\n return Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the mainLine truncated
|
||||
@@ -1,42 +0,0 @@
|
||||
"""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_PLUGIN_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
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Framey Settings Sample",
|
||||
"author": "Frame Control contributors",
|
||||
"flags": [],
|
||||
"api_version": 2
|
||||
}
|
||||
Generated
-1239
File diff suppressed because it is too large.
Load diff
@@ -1,3 +0,0 @@
|
||||
import deckyPlugin from '@decky/rollup';
|
||||
|
||||
export default deckyPlugin({});
|
||||
@@ -1,81 +0,0 @@
|
||||
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
|
||||
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
|
||||
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><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>
|
||||
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
function Details() {
|
||||
const {topic} = useParams<{topic?: string}>();
|
||||
return <PanelSection title="Sample detail page">
|
||||
<PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>
|
||||
<PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute('/framey-sample/details/:topic?', Details);
|
||||
return Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: <span aria-hidden="true">⚙</span>,
|
||||
content: <Content />,
|
||||
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
settings: <Content />,
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve exact official frontends to a local Chromium audit, with all actions denied.
|
||||
|
||||
Open the printed loopback URL with T3's collaborative browser. Run `runAudit()`
|
||||
in the page, then inspect `results` or download its JSON. This is initialization
|
||||
evidence only. No official Python/native backend is imported or executed.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT/'ui'))
|
||||
import framey_panel
|
||||
|
||||
PRELUDE = '''<script>
|
||||
const report=(kind,message)=>parent.postMessage({type:'audit:log',kind,message:String(message)},'*');
|
||||
addEventListener('error',event=>report('error',event.message));
|
||||
addEventListener('unhandledrejection',event=>report('rejection',event.reason?.stack||event.reason));
|
||||
for(const key of ['error','warn']){const original=console[key];console[key]=(...args)=>{
|
||||
report(key,args.map(String).join(' '));original(...args);};}
|
||||
window.fetch=()=>Promise.reject(Error('Audit blocks plugin network'));
|
||||
window.WebSocket=class{constructor(){throw Error('Audit blocks WebSocket');}};
|
||||
</script>'''
|
||||
|
||||
PAGE = '''<!doctype html><meta charset=utf-8><title>Framey catalog frontend audit</title>
|
||||
<h1>Framey catalog frontend audit</h1>
|
||||
<p>Exact archived frontend code runs in an opaque iframe. All backend calls and network requests are denied.
|
||||
Ready means initializer completion, not useful plugin behavior. No plugin backend executes.</p>
|
||||
<button id=run>Run catalog</button> <button id=download>Download observations</button><p id=progress></p>
|
||||
<iframe id=panel sandbox="allow-scripts" style="width:1100px;height:740px"></iframe>
|
||||
<script>
|
||||
const plugins=CATALOG_DATA;
|
||||
window.results=[];let active=null,running=false;
|
||||
addEventListener('message',event=>{
|
||||
if(event.source!==panel.contentWindow||!active)return;
|
||||
const data=event.data;
|
||||
if(data.type==='audit:log'&&active.logs.length<8)active.logs.push({kind:data.kind,message:data.message.split('\\n')[0].slice(0,400)});
|
||||
if(data.type==='framey:error')active.error=String(data.message).slice(0,500);
|
||||
if(data.type==='framey:ready')active.ready=true;
|
||||
if(data.type==='framey:events')event.source.postMessage({type:'framey:reply',id:data.id,result:{events:[],cursor:0}},'*');
|
||||
if(data.type==='framey:call'){
|
||||
if(!active.methods.includes(data.method))active.methods.push(data.method);
|
||||
event.source.postMessage({type:'framey:reply',id:data.id,error:'Read-only compatibility audit: backend method blocked'},'*');}
|
||||
if(data.type==='framey:cache:init')event.source.postMessage({type:'framey:cache:seed',session:data.session,entries:{}},'*');
|
||||
});
|
||||
window.runAudit=async(ids)=>{
|
||||
if(running)throw Error('Audit already running');running=true;run.disabled=true;
|
||||
try{for(const plugin of plugins.filter(plugin=>!ids||ids.includes(plugin.id))){
|
||||
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[]};
|
||||
progress.textContent=plugin.id+' '+plugin.name;panel.src='/panel/'+plugin.id;
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));results.push(active);active=null;
|
||||
panel.src='about:blank';await new Promise(resolve=>setTimeout(resolve,30));
|
||||
}}finally{running=false;run.disabled=false;progress.textContent='Done: '+results.length;}
|
||||
return results;
|
||||
};
|
||||
run.onclick=()=>runAudit();
|
||||
download.onclick=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify(results,null,2)],{type:'application/json'}));
|
||||
const anchor=document.createElement('a');anchor.href=url;anchor.download='framey-browser-audit.json';anchor.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
</script>'''
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--cache', type=Path, required=True)
|
||||
parser.add_argument('--catalog', type=Path, default=ROOT/'ui/framey-compatibility.json')
|
||||
parser.add_argument('--port', type=int, default=18765)
|
||||
args = parser.parse_args()
|
||||
plugins = json.loads(args.catalog.read_text())['plugins']
|
||||
records = {p['id']: p for p in plugins}
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split('?')[0]
|
||||
content_type = 'text/html; charset=utf-8'
|
||||
if path == '/':
|
||||
# Only names/IDs are needed by the UI; never embed raw executable source.
|
||||
catalog = json.dumps([{'id': p['id'], 'name': p['name']} for p in plugins]).replace('<', '\\u003c')
|
||||
body = PAGE.replace('CATALOG_DATA', catalog).encode()
|
||||
elif path.startswith('/panel/'):
|
||||
try:
|
||||
record = records[int(path.split('/')[-1])]
|
||||
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != record['sha256']:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
bundle = archive.read(record['folder']+'/dist/index.js')
|
||||
with patch.object(framey_panel, 'local_bundle', return_value=bundle):
|
||||
body = framey_panel.panel(record['id']).replace(
|
||||
b'<script id="framey-bundle"', PRELUDE.encode()+b'<script id="framey-bundle"')
|
||||
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
|
||||
self.send_error(404, 'Verified frontend archive unavailable')
|
||||
return
|
||||
else:
|
||||
files = {'/framey-runtime/react.js': 'ui/vendor/react-18.3.1.min.js',
|
||||
'/framey-runtime/react-dom.js': 'ui/vendor/react-dom-18.3.1.min.js',
|
||||
'/framey-runtime/host.js': 'ui/framey-host.js'}
|
||||
if path not in files:
|
||||
self.send_error(404)
|
||||
return
|
||||
body = (ROOT/files[path]).read_bytes()
|
||||
content_type = 'text/javascript'
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline' blob:; style-src 'unsafe-inline'; img-src data:; frame-src 'self'; connect-src 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
print(f'Frontend audit: http://127.0.0.1:{args.port}', flush=True)
|
||||
ThreadingHTTPServer(('127.0.0.1', args.port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit a verified catalog cache in a private disposable fake-Frame project."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--cache', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
cache = args.cache.resolve()
|
||||
output = args.output.resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
records = json.loads((root/'ui/framey-compatibility.json').read_text())['plugins']
|
||||
if not all((cache/(p['sha256']+'.zip')).is_file() for p in records):
|
||||
parser.error('cache must contain all exact catalog archives; run framey-audit-store.py first')
|
||||
# Unique project avoids tearing down another job's fake Frame or its volumes.
|
||||
project = 'framey-catalog-'+uuid.uuid4().hex[:10]
|
||||
subprocess.run(['docker', 'build', '-q', '-t', 'fakeframe-frame', '-f',
|
||||
str(root/'tests/fakeframe/Containerfile'), str(root/'tests/fakeframe')], check=True)
|
||||
subprocess.run(['docker', 'build', '-q', '-t', 'fakeframe-host', '-f',
|
||||
str(root/'tests/fakeframe/host.Containerfile'), str(root/'tests/fakeframe')], check=True)
|
||||
with tempfile.TemporaryDirectory(prefix='framey-catalog-') as temp:
|
||||
override = Path(temp)/'compose.json'
|
||||
override.write_text(json.dumps({'services': {'host': {
|
||||
'volumes': [f'{cache}:/audit-cache:ro', f'{output.parent}:/audit-output'],
|
||||
'environment': {'FRAMEY_AUDIT_CACHE': '/audit-cache',
|
||||
'FRAMEY_AUDIT_OUTPUT': '/audit-output/'+output.name}}}}))
|
||||
compose = ['docker', 'compose', '-p', project, '-f', str(root/'tests/fakeframe/compose.yaml'), '-f', str(override)]
|
||||
try:
|
||||
subprocess.run(compose+['up', '-d', '--wait'], check=True)
|
||||
subprocess.run(compose+['exec', '-T', '-w', '/repo/tests/e2e', 'host', 'python3', '-m',
|
||||
'unittest', '-v', 'test_framey_catalog.CatalogAudit'], check=True)
|
||||
finally:
|
||||
subprocess.run(compose+['down', '-v', '--remove-orphans'], check=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect official Decky archives without importing or executing plugin code.
|
||||
|
||||
Archives stay in a caller-selected cache. SHA-256 is checked before inspection.
|
||||
The resulting observations are static evidence, not compatibility guarantees.
|
||||
"""
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
STORE = 'https://plugins.deckbrew.xyz/plugins'
|
||||
CDN = 'https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/'
|
||||
INIT = '__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit'
|
||||
|
||||
|
||||
def fetch(url, limit):
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'Frame-Control-compatibility-audit'})
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
if int(response.headers.get('Content-Length', '0')) > limit:
|
||||
raise ValueError('download exceeds audit size limit')
|
||||
data = response.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
raise ValueError('download exceeds audit size limit')
|
||||
return data
|
||||
|
||||
|
||||
def inspect(item, cache):
|
||||
version = item['versions'][0]
|
||||
digest = version['hash']
|
||||
result = {'id': item['id'], 'name': item['name'], 'version': version['name'],
|
||||
'sha256': digest, 'tags': item.get('tags', [])}
|
||||
try:
|
||||
path = cache / (digest + '.zip')
|
||||
data = path.read_bytes() if path.exists() else fetch(CDN + digest + '.zip', 200 * 1024 * 1024)
|
||||
if hashlib.sha256(data).hexdigest() != digest:
|
||||
raise ValueError('official archive SHA-256 mismatch')
|
||||
path.write_bytes(data)
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
entries = archive.infolist()
|
||||
if len(entries) > 6000 or sum(e.file_size for e in entries) > 500 * 1024 * 1024:
|
||||
raise ValueError('archive exceeds audit size limit')
|
||||
manifests = [e.filename for e in entries if e.filename.count('/') == 1 and e.filename.endswith('/plugin.json')]
|
||||
if len(manifests) != 1:
|
||||
raise ValueError('expected one top-level plugin.json')
|
||||
manifest = json.loads(archive.read(manifests[0]))
|
||||
folder = manifests[0].split('/')[0]
|
||||
frontend_path = folder + '/dist/index.js'
|
||||
frontend = archive.read(frontend_path).decode('utf-8', errors='replace') if frontend_path in archive.namelist() else ''
|
||||
result.update(folder=folder, archiveBytes=len(data), api=manifest.get('api_version', 'legacy'), flags=manifest.get('flags', []),
|
||||
modernBridge=INIT in frontend, frontend=bool(frontend),
|
||||
frontendApi=('legacy' if re.match(r'\s*\(function\s*\(', frontend) else
|
||||
next(iter(re.findall(r'API_VERSION\s*=\s*(\d+)', frontend)), 'unknown')))
|
||||
python_files, native, sources = [], [], []
|
||||
for entry in entries:
|
||||
if entry.is_dir():
|
||||
continue
|
||||
with archive.open(entry) as stream:
|
||||
header = stream.read(20)
|
||||
if header[:4] == b'\x7fELF':
|
||||
machine = int.from_bytes(header[18:20], 'little' if header[5] == 1 else 'big')
|
||||
native.append({'file': entry.filename, 'elfMachine': machine,
|
||||
'arch': {62: 'x86_64', 183: 'aarch64', 3: 'x86', 40: 'arm'}.get(machine, str(machine))})
|
||||
elif header[:2] == b'MZ':
|
||||
native.append({'file': entry.filename, 'arch': 'Windows'})
|
||||
if entry.filename.endswith('.py') and entry.file_size < 2 * 1024 * 1024:
|
||||
python_files.append(entry.filename)
|
||||
sources.append(archive.read(entry).decode('utf-8', errors='replace'))
|
||||
backend = '\n'.join(sources)
|
||||
metadata = json.dumps(manifest)
|
||||
for suffix in ('/package.json', '/README.md'):
|
||||
name = folder + suffix
|
||||
if name in archive.namelist() and archive.getinfo(name).file_size < 1024 * 1024:
|
||||
metadata += '\n' + archive.read(name).decode('utf-8', errors='replace')
|
||||
repositories = set(re.findall(r'https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', metadata + '\n' + backend))
|
||||
repositories.update('https://github.com/' + match for match in re.findall(
|
||||
r'https://raw\.githubusercontent\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)', metadata))
|
||||
|
||||
result.update(pythonFiles=python_files, native=native,
|
||||
steamClient=sorted(set(re.findall(r'SteamClient\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)', frontend))),
|
||||
dfl=sorted(set(re.findall(r'(?:DFL|__DFL__|deckyFrontendLib)\.([A-Za-z_$][\w$]*)', frontend))),
|
||||
globals=sorted(set(re.findall(r'\b(?:Router|routerHook|toaster|SteamUIStore|appStore|appDetailsStore|collectionStore|achievementStore|window\.SP_[A-Z_]+|DeckyPluginLoader|webpackChunksteamui)\b', frontend))),
|
||||
uiSurfaces=[label for label, pattern in (
|
||||
('local/Steam routes', r'routerHook|addRoute|removeRoute'),
|
||||
('toasts', r'\btoaster\b|\.toast\('),
|
||||
('QAM/global tabs', r'quickAccessMenu|addGlobalComponent|addTab|QAM'),
|
||||
('Steam page patches/module lookup', r'afterPatch|beforePatch|findModule|createModuleMapping'),
|
||||
('Steam stores', r'\b\w+Store\b'),
|
||||
) if re.search(pattern, frontend)],
|
||||
hardwareSignals=sorted(set(re.findall(r'(?:/sys/[^\s\x22\x27)]+|/dev/(?:hidraw|input|i2c|drm)[\w/*-]*|\b(?:ryzenadj|intel_pstate|amdgpu|hwmon|gamescope|pactl|bluetoothctl|flatpak|systemctl|sudo|x86_64|amd64)\b)', backend))),
|
||||
sourceURLs=sorted(repositories)[:20])
|
||||
except Exception as failure:
|
||||
result['auditError'] = str(failure)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--catalog', type=Path, help='saved official catalog (otherwise fetch current)')
|
||||
parser.add_argument('--cache', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
records = json.loads(args.catalog.read_bytes() if args.catalog else fetch(STORE, 4 * 1024 * 1024))
|
||||
records = [r for r in records if r.get('visible') is not False and r.get('versions')]
|
||||
args.cache.mkdir(parents=True, exist_ok=True)
|
||||
with ThreadPoolExecutor(max_workers=6) as workers:
|
||||
plugins = list(workers.map(lambda item: inspect(item, args.cache), records))
|
||||
args.output.write_text(json.dumps({'checkedAtUtc': datetime.now(timezone.utc).isoformat(),
|
||||
'source': STORE, 'method': 'SHA-256-verified official archives; static inspection only; plugin code was not executed.',
|
||||
'plugins': plugins}, indent=2) + '\n')
|
||||
print(f"Audited {len(plugins)} official plugins; {sum('auditError' in p for p in plugins)} errors. {args.output}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the documentation from the same digest-scoped data used by the store."""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def cell(value):
|
||||
if isinstance(value, list):
|
||||
value = ', '.join(str(v) for v in value) or 'none detected'
|
||||
return str(value).replace('|', '|').replace('\n', ' ').replace('<', '<')
|
||||
|
||||
|
||||
def render(data):
|
||||
counts = Counter(p['status'] for p in data['plugins'])
|
||||
lines = ['# Official Decky store compatibility with Framey', '',
|
||||
f"Snapshot: {data['checkedAtUtc']}; {len(data['plugins'])} current visible official plugins from {data['source']}.", '',
|
||||
'This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). '
|
||||
'Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. '
|
||||
'Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.', '',
|
||||
f"**Works: {counts['works']} · Partial: {counts['partial']} · Broken: {counts['broken']} · N/A: {counts['na']}.**", '',
|
||||
'- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.',
|
||||
'- **Partial** means a limited frontend/settings surface initialized or rendered. Backend-dependent functionality remains unverified; it is not an end-to-end success claim.',
|
||||
'- **Broken** means a concrete archive, frontend or Steam dependency blocks the audited version. An artificial backend/network denial alone is not evidence that the real backend is broken.',
|
||||
'- **N/A** means the plugin targets hardware or a display/UI surface that Framey does not provide. The store disables installation for these versions.', '',
|
||||
'## Verification limits', '',
|
||||
f"All exact archives were hash-checked and statically inspected. The local Chromium host used Framey commit `{data['hostCommit']}`, React 18.3.1 and an opaque `allow-scripts` iframe. "
|
||||
'Each frontend was attempted for 1.2 seconds, including React effects. All plugin backend calls, fetches and websockets were denied. '
|
||||
'Some plugins attempt mutating actions on mount; none reached a backend. Initialization (`ready`) does not guarantee React rendered, and delayed errors/actions may occur after the observation window. '
|
||||
'The JSON preserves initializer errors, capped console observations and attempted method names.', '',
|
||||
'The optional full-catalog fake-Frame audit exercises the real HTTP/SSH installer, receipt, frontend bytes and removal, '
|
||||
'or expected rejection. It uses a private disposable Compose project with all plugin backend execution stubbed. '
|
||||
'It checks archive transport and safeguards, not Python/native execution. Run it with:', '',
|
||||
'```sh',
|
||||
'python3 scripts/framey-audit-store.py --cache /tmp/framey-archives --output /tmp/framey-observations.json',
|
||||
'python3 scripts/framey-audit-fake.py --cache /tmp/framey-archives --output /tmp/framey-fake/results.json',
|
||||
'python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives',
|
||||
'```', '',
|
||||
'Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.', '',
|
||||
'The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. '
|
||||
'Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.', '',
|
||||
'After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. '
|
||||
'Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. '
|
||||
'The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, '
|
||||
'and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) '
|
||||
'record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) '
|
||||
'verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, '
|
||||
'countdown/completion events, a visible toast and persisted recents/subtle mode. '
|
||||
'Only private test settings were written. Simple Timer remains partial because its own expired-timer startup '
|
||||
'raises a missing `timer_task` error; sound/suspend are unsupported. '
|
||||
'All other official backends remain unexecuted. '
|
||||
'The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. '
|
||||
'No worn-VR official-plugin result is claimed. '
|
||||
'A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings '
|
||||
'with all backend/network actions denied; it does not verify process pausing or Tailscale changes. '
|
||||
'Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), '
|
||||
'[Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), '
|
||||
'[Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and '
|
||||
'[legacy MusicControl](evidence/framey-legacy-2026-10-25.md).', '',
|
||||
'## Every current plugin', '',
|
||||
'The API column shows manifest / actual bundled frontend API. Many manifests say v1 while their bundle connects API2. '
|
||||
'Missing manifest API is labelled legacy; an unrecognized bundle is unknown. Globals and hardware are static signals, not exhaustive dependencies.', '',
|
||||
'| Plugin / version | Manifest / bundle API | Status | Reason | Verified by |',
|
||||
'| --- | --- | --- | --- | --- |']
|
||||
for p in data['plugins']:
|
||||
lines.append('| '+ ' | '.join(map(cell, [f"{p['id']}: {p['name']} {p['version']}",
|
||||
f"{p['api']} / {p['frontendApi']}", 'N/A' if p['status']=='na' else p['status'], p['reason'], p['verification']]))+' |')
|
||||
lines += ['', '## API, UI and backend inventory', '',
|
||||
'DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stores and router/toaster/QAM signals are listed separately. '
|
||||
'No signal means the static scan did not detect one, not that no dependency exists. Native binaries are classified by ELF machine or Windows MZ header; '
|
||||
'Python files and source links are preserved in the JSON.', '',
|
||||
'| Plugin | SteamClient methods | DFL / @decky/ui exports | Other Steam globals / UI surfaces | Backend | Hardware / OS signals |',
|
||||
'| --- | --- | --- | --- | --- | --- |']
|
||||
for p in data['plugins']:
|
||||
native = sorted({n['arch'] for n in p['native']})
|
||||
lines.append('| '+' | '.join(map(cell, [p['name'],p['steamClient'],p['dfl'],p['globals']+p.get('uiSurfaces',[]),
|
||||
p['backend']+('; '+', '.join(native) if native else ''),p['hardware']]))+' |')
|
||||
lines += ['', '## Native payload portability', '',
|
||||
'All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. '
|
||||
'The three Windows DLLs belong to Decky-Framegen. The installer still rejects these archives. '
|
||||
'Apart from the explicitly described LocalSend experiment, ARM64 builds were not attempted: the constraints below remain unresolved.', '',
|
||||
'| Plugin | Native payloads | ARM64 result / remaining blocker |', '| --- | --- | --- |']
|
||||
for p in data['plugins']:
|
||||
if p['native']:
|
||||
binaries = [n['file'].removeprefix(p['folder']+'/') for n in p['native']]
|
||||
if len(binaries)>5:
|
||||
binaries = binaries[:5]+[f"… {len(binaries)} total; full list in JSON"]
|
||||
lines.append('| '+' | '.join(map(cell,[p['name'],binaries,p['nativePort']]))+' |')
|
||||
lines += ['', '## Evidence and maintenance', '',
|
||||
'The canonical JSON contains full archive hashes, native paths, source URLs, frontend observations and fake-Frame outcomes. '
|
||||
'Store compatibility is advisory except known installation/hardware blockers; removal always remains available. '
|
||||
'A panel retains its compatibility reason after the initializer reports ready. Upstream fixes require a new digest-scoped audit. '
|
||||
'Do not mark a plugin works based on archive installation, fake backend replies or initializer completion.', '']
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--check', action='store_true')
|
||||
args = parser.parse_args()
|
||||
output = ROOT/'docs/framey-compatibility.md'
|
||||
expected = render(json.loads((ROOT/'ui/framey-compatibility.json').read_text(encoding='utf-8')))
|
||||
if args.check:
|
||||
if not output.exists() or output.read_text(encoding='utf-8') != expected:
|
||||
raise SystemExit('Compatibility documentation differs from canonical store data')
|
||||
else:
|
||||
output.write_text(expected, encoding='utf-8')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,310 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded real-Frame VR diagnostics. No device writes without the shared lock.
|
||||
|
||||
Default: inspect only. --wake: reversible wake + stereo capture. --exercise:
|
||||
temporary clean user-service install, live sample settings and OpenVR input.
|
||||
Exit 0 means requested machine checks passed, never a worn/controller verdict.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
import signal
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_decky
|
||||
import frame_headset
|
||||
|
||||
LOCK = '/tmp/frame-test.lock'
|
||||
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
|
||||
KEY = 'valve.steam.desktopgame.2001947813'
|
||||
|
||||
|
||||
class Remote:
|
||||
def __init__(self, host, via=None):
|
||||
self.argv = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
|
||||
'-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2', host]
|
||||
self.via = via
|
||||
|
||||
def __call__(self, command, stdin=None, text=True, timeout=30):
|
||||
argv = self.argv + [command]
|
||||
if self.via:
|
||||
argv = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', self.via,
|
||||
shlex.join(argv)]
|
||||
result = subprocess.run(argv, input=stdin, capture_output=True, text=text, timeout=timeout)
|
||||
if result.returncode:
|
||||
raise RuntimeError((result.stderr or result.stdout).strip())
|
||||
return result.stdout
|
||||
|
||||
def python(self, source, timeout=30):
|
||||
return json.loads(self('python3 -', stdin=source, timeout=timeout))
|
||||
|
||||
|
||||
PROBE = '''
|
||||
import json,pathlib,subprocess,ctypes as C
|
||||
lib=C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
|
||||
lib.VR_InitInternal2.argtypes=[C.POINTER(C.c_int),C.c_int,C.c_char_p]
|
||||
lib.VR_GetGenericInterface.argtypes=[C.c_char_p,C.POINTER(C.c_int)]
|
||||
lib.VR_GetGenericInterface.restype=C.c_void_p
|
||||
error=C.c_int();lib.VR_InitInternal2(C.byref(error),3,None)
|
||||
if error.value: raise RuntimeError('OpenVR unavailable')
|
||||
try:
|
||||
table=lib.VR_GetGenericInterface(b'FnTable:IVROverlay_027',C.byref(error))
|
||||
if error.value or not table: raise RuntimeError('Overlay ABI unavailable')
|
||||
dashboard=bool(C.CFUNCTYPE(C.c_bool)(C.cast(table,C.POINTER(C.c_void_p))[66])())
|
||||
finally: lib.VR_ShutdownInternal()
|
||||
battery=[]
|
||||
for p in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
|
||||
if (p.parent/'type').read_text().strip()=='Battery': battery.append(int(p.read_text()))
|
||||
def cmd(*args):
|
||||
p=subprocess.run(args,capture_output=True,text=True,timeout=8)
|
||||
if p.returncode: raise RuntimeError(p.stderr or p.stdout)
|
||||
return p.stdout.strip()
|
||||
vr='/opt/steamvr/bin/linuxarm64/vrcmd'
|
||||
print(json.dumps({'battery':battery,'dashboardVisible':dashboard,'stats':json.loads(cmd(vr,'--stats')),
|
||||
'overlays':cmd(vr,'--overlays'),
|
||||
'pause':cmd(vr,'--settings-bool','power.pauseCompositorOnStandby'),
|
||||
'timeout':cmd(vr,'--settings-float','power.turnOffScreensTimeout')}))
|
||||
'''
|
||||
|
||||
|
||||
def settings(state):
|
||||
pause = state['pause'].split('=', 1)[1]
|
||||
timeout = state['timeout'].split('=', 1)[1]
|
||||
if pause not in ('true', 'false') or not 0 <= float(timeout) <= 86400:
|
||||
raise RuntimeError('Cannot safely save original SteamVR power values')
|
||||
return pause, timeout
|
||||
|
||||
|
||||
def diagnostic_source():
|
||||
return (ROOT / 'scripts/framey_openvr.py').read_text() + '''
|
||||
import json
|
||||
vr=Overlay()
|
||||
try: print(json.dumps({'state':vr.snapshot(),'imageRead':vr.image_hash()}))
|
||||
finally: vr.close()
|
||||
'''
|
||||
|
||||
|
||||
def debug_bundle(original, profile):
|
||||
"""Only the test-owned browser gets CDP and a disposable profile."""
|
||||
out = io.BytesIO()
|
||||
with tarfile.open(fileobj=io.BytesIO(original), mode='r:gz') as src, tarfile.open(fileobj=out, mode='w:gz') as dst:
|
||||
for item in src.getmembers():
|
||||
data = src.extractfile(item).read()
|
||||
if item.name == 'ui/frame_headset_runtime.py':
|
||||
code = data.decode()
|
||||
needle = "'--noerrdialogs', '--window-size=1200,800',"
|
||||
if needle not in code:
|
||||
raise RuntimeError('Runtime changed; review test browser setup')
|
||||
code = code.replace(needle, "'--noerrdialogs', '--remote-debugging-port=0', '--window-size=1200,800',")
|
||||
code = code.replace("str(BASE / 'profile')", repr(profile))
|
||||
data = code.encode()
|
||||
item.size = len(data)
|
||||
dst.addfile(item, io.BytesIO(data))
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def exercise_source(profile, plugin_id, folder):
|
||||
# Runs entirely on the headset. The normal iframe, HTTP handler, CEF bridge
|
||||
# and Decky Python backend are used. CDP observes DOM/coordinates; OpenVR
|
||||
# delivers clicks. No JS .click() or backend stubs.
|
||||
return (ROOT/'scripts/framey_openvr.py').read_text() + '\n' + (ROOT/'scripts/framey_vr_input.py').read_text() + '\n' + (
|
||||
'print(json.dumps(exercise(%r,%r,%r)))\n' % (profile, plugin_id, folder))
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--host', default='frame')
|
||||
parser.add_argument('--via', help='Existing paired SSH host, e.g. lxso2')
|
||||
parser.add_argument('--wake', action='store_true')
|
||||
parser.add_argument('--exercise', action='store_true', help='Temporary user installs; requires no managed panel/Decky install')
|
||||
parser.add_argument('--output', type=Path, required=True, help='Private evidence folder (screenshots may contain room pixels)')
|
||||
args = parser.parse_args(argv)
|
||||
args.output.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
report = {'schema': 1, 'time': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'controllerHardwareVerified': False, 'wornVisualComfortVerified': False}
|
||||
git = subprocess.run(['git','rev-parse','HEAD'],cwd=ROOT,capture_output=True,text=True)
|
||||
report['sourceCommit'] = git.stdout.strip() if git.returncode == 0 else None
|
||||
remote = Remote(args.host, args.via)
|
||||
token = 'framey-vr-' + secrets.token_hex(8)
|
||||
folder = token
|
||||
profile = '/tmp/' + token + '-profile'
|
||||
locked = power_changed = plugin_created = decky_created = panel_created = watchdog = False
|
||||
original_bundle = frame_headset.bundle
|
||||
before = None
|
||||
previous_term = signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(RuntimeError('Terminated; cleaning up')))
|
||||
try:
|
||||
remote('mkdir ' + LOCK + ' && { printf %s ' + shlex.quote(token) + ' > ' + LOCK + '/owner || { rmdir ' + LOCK + '; exit 1; }; }')
|
||||
locked = True
|
||||
before = remote.python(PROBE)
|
||||
report['before'] = before
|
||||
pause, timeout = settings(before)
|
||||
if not before['battery'] or min(before['battery']) < 20:
|
||||
raise RuntimeError('Battery unknown or below 20%')
|
||||
if args.wake or args.exercise:
|
||||
# Independent systemd timer restores power even if the controller is
|
||||
# killed or SSH disappears. It retains the test lock for recovery.
|
||||
recovery = '/tmp/' + token + '-restore.py'
|
||||
restore_source = ("import pathlib,subprocess\n"
|
||||
"p=pathlib.Path('/tmp/frame-test.lock/owner')\n"
|
||||
"if p.exists() and p.read_text()==" + repr(token) + ":\n"
|
||||
" subprocess.run([" + repr(VRCMD) + ", '--set-settings-bool','power.pauseCompositorOnStandby'," + repr('1' if pause == 'true' else '0') + "],check=True)\n"
|
||||
" subprocess.run([" + repr(VRCMD) + ", '--set-settings-float','power.turnOffScreensTimeout'," + repr(timeout) + "],check=True)\n")
|
||||
remote('umask 077; cat > ' + shlex.quote(recovery), stdin=restore_source)
|
||||
remote('systemd-run --user --quiet --unit=' + token + '-restore --on-active=240s /usr/bin/python3 ' + shlex.quote(recovery))
|
||||
watchdog = True
|
||||
report['powerRecovery'] = {'timer':token+'-restore.timer','script':recovery,
|
||||
'originalPause':pause,'originalTimeout':timeout}
|
||||
(args.output/'recovery.json').write_text(json.dumps({'host':args.host,'via':args.via,'lockOwner':token,**report['powerRecovery']},indent=2)+'\n')
|
||||
if args.exercise:
|
||||
d, h = frame_decky.status(remote), frame_headset.status(remote)
|
||||
if d['managed'] or h['managed'] or d['upstreamInstall']:
|
||||
raise RuntimeError('Exercise needs absent managed services; existing installs left intact')
|
||||
# Refuse every collision, including retained plugin settings.
|
||||
files = {name: base64.b64encode((ROOT/'examples/framey-settings'/name).read_bytes()).decode()
|
||||
for name in ('plugin.json', 'main.py', 'dist/index.js')}
|
||||
source = '''
|
||||
import base64,json,pathlib
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky'
|
||||
folder=%r
|
||||
p=base/'plugins'/folder
|
||||
if p.exists() or (base/'settings'/folder).exists(): raise RuntimeError('Test path collision')
|
||||
p.mkdir(parents=True); (p/'dist').mkdir()
|
||||
for name,data in %r.items(): (p/name).write_bytes(base64.b64decode(data))
|
||||
manifest=json.loads((p/'plugin.json').read_text());manifest['name']='Framey VR Check '+folder
|
||||
(p/'plugin.json').write_text(json.dumps(manifest))
|
||||
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check'}))
|
||||
print(json.dumps({'created':True}))
|
||||
''' % (folder, files)
|
||||
remote.python(source)
|
||||
plugin_created = True
|
||||
decky_created = True
|
||||
report['deckyInstall'] = frame_decky.change(remote, 'install')
|
||||
# Decky reloads its Steam frontend; install the panel afterwards.
|
||||
time.sleep(3)
|
||||
frame_headset.bundle = lambda: debug_bundle(original_bundle(), profile)
|
||||
panel_created = True
|
||||
report['panelInstall'] = frame_headset.change(remote, 'install')
|
||||
if args.wake or args.exercise:
|
||||
power_changed = True # before first setter, so partial failure restores
|
||||
remote(VRCMD + ' --set-settings-bool power.pauseCompositorOnStandby 0')
|
||||
remote(VRCMD + ' --set-settings-float power.turnOffScreensTimeout 120')
|
||||
remote(VRCMD + ' --handlewakeup')
|
||||
remote(VRCMD + ' --showdashboard ' + KEY)
|
||||
time.sleep(2)
|
||||
report['overlay'] = remote.python(diagnostic_source())
|
||||
if args.exercise and not report['overlay']['state']['visible']:
|
||||
report['retaggedTestWindow'] = True
|
||||
remote.python('''
|
||||
import json,pathlib,subprocess,time,os
|
||||
p=pathlib.Path.home()/'.local/share/frame-control/headset/state.json'
|
||||
w=json.loads(p.read_text())['window'];env={**os.environ,'DISPLAY':':0'}
|
||||
subprocess.run(['xprop','-id',w,'-remove','STEAM_GAME'],env=env,check=True)
|
||||
time.sleep(1)
|
||||
subprocess.run(['xprop','-id',w,'-f','STEAM_GAME','32c','-set','STEAM_GAME','2001947813'],env=env,check=True)
|
||||
time.sleep(1)
|
||||
subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--showdashboard','valve.steam.desktopgame.2001947813'],capture_output=True,check=True)
|
||||
time.sleep(2)
|
||||
print('{}')
|
||||
''')
|
||||
report['overlay'] = remote.python(diagnostic_source())
|
||||
if args.wake or args.exercise:
|
||||
shot = remote.python((ROOT/'ui/frame_vrshot.py').read_text(), timeout=20)['path']
|
||||
try:
|
||||
image = remote('cat ' + shlex.quote(shot), text=False, timeout=20)
|
||||
(args.output/'before-stereo-private.png').write_bytes(image)
|
||||
report['beforeStereoSha256'] = hashlib.sha256(image).hexdigest()
|
||||
finally:
|
||||
remote('rm -f ' + shlex.quote(shot))
|
||||
if args.exercise:
|
||||
report['input'] = remote.python(exercise_source(profile, 2000000001, folder), timeout=100)
|
||||
if args.wake or args.exercise:
|
||||
shot = remote.python((ROOT/'ui/frame_vrshot.py').read_text(), timeout=20)['path']
|
||||
try:
|
||||
image = remote('cat ' + shlex.quote(shot), text=False, timeout=20)
|
||||
(args.output/'stereo-private.png').write_bytes(image)
|
||||
report['stereoSha256'] = hashlib.sha256(image).hexdigest()
|
||||
report['compositorChanged'] = report['stereoSha256'] != report.get('beforeStereoSha256')
|
||||
finally:
|
||||
remote('rm -f ' + shlex.quote(shot))
|
||||
# Same SteamVR mirror used by OBS; FFmpeg is already on the Frame.
|
||||
mirror = remote("ffmpeg -hide_banner -loglevel error -f v4l2 -i /dev/video99 -vf 'select=gte(n\\,60)' -frames:v 1 -f image2pipe -vcodec png -", text=False, timeout=20)
|
||||
(args.output/'mirror-private.png').write_bytes(mirror)
|
||||
report['mirrorSha256'] = hashlib.sha256(mirror).hexdigest()
|
||||
report['passed'] = bool(report['overlay']['state']['visible'])
|
||||
if args.exercise:
|
||||
report['passed'] = report['passed'] and report['input']['passed'] and report.get('compositorChanged',False)
|
||||
except Exception as exc:
|
||||
report['error'] = str(exc)
|
||||
report['passed'] = False
|
||||
finally:
|
||||
frame_headset.bundle = original_bundle
|
||||
cleanup = []
|
||||
def clean(name, action):
|
||||
try: action()
|
||||
except Exception as exc: cleanup.append({'step':name,'error':str(exc)})
|
||||
if locked:
|
||||
if panel_created:
|
||||
clean('remove test panel', lambda: frame_headset.change(remote, 'remove') if frame_headset.status(remote)['managed'] else None)
|
||||
if decky_created:
|
||||
clean('remove test Decky', lambda: frame_decky.change(remote, 'remove') if frame_decky.status(remote)['managed'] else None)
|
||||
if plugin_created:
|
||||
# Only random per-run paths created after collision checks.
|
||||
clean('remove test files', lambda: remote.python('''
|
||||
import pathlib,shutil,json
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky'
|
||||
for parent in ('plugins','settings','data','logs'):
|
||||
p=base/parent/%r
|
||||
if p.is_dir() and not p.is_symlink(): shutil.rmtree(p)
|
||||
p=pathlib.Path(%r)
|
||||
if p.is_dir() and not p.is_symlink(): shutil.rmtree(p)
|
||||
print('{}')
|
||||
''' % (folder, profile)))
|
||||
if power_changed:
|
||||
clean('restore compositor pause', lambda: remote(VRCMD + ' --set-settings-bool power.pauseCompositorOnStandby ' + ('1' if pause == 'true' else '0')))
|
||||
clean('restore screen timeout', lambda: remote(VRCMD + ' --set-settings-float power.turnOffScreensTimeout ' + shlex.quote(timeout)))
|
||||
clean('restore dashboard visibility', lambda: remote(VRCMD + ' --hidedashboard') if not before['dashboardVisible'] else None)
|
||||
try:
|
||||
report['after'] = remote.python(PROBE)
|
||||
if power_changed and settings(report['after']) != settings(before):
|
||||
cleanup.append({'step':'read back power settings','error':'Original values not restored'})
|
||||
except Exception as exc: cleanup.append({'step':'after probe','error':str(exc)})
|
||||
if watchdog and not cleanup:
|
||||
clean('stop power recovery timer', lambda: remote.python('''
|
||||
import subprocess,json
|
||||
unit=%r
|
||||
timer=subprocess.run(['systemctl','--user','show',unit+'.timer','--property=LoadState','--value'],capture_output=True,text=True)
|
||||
if timer.stdout.strip()=='loaded': subprocess.run(['systemctl','--user','stop',unit+'.timer'],check=True,capture_output=True)
|
||||
p=subprocess.run(['systemctl','--user','show',unit+'.service','--property=LoadState','--value'],capture_output=True,text=True)
|
||||
if p.stdout.strip()=='loaded': subprocess.run(['systemctl','--user','stop',unit+'.service'],check=True,capture_output=True)
|
||||
print('{}')
|
||||
''' % (token+'-restore')))
|
||||
if not cleanup:
|
||||
clean('remove power recovery script', lambda: remote('rm -f ' + shlex.quote(recovery)))
|
||||
report['powerRecoveryRemoved'] = not cleanup
|
||||
if not cleanup: (args.output/'recovery.json').unlink(missing_ok=True)
|
||||
if not cleanup:
|
||||
clean('release owned lock', lambda: remote('test "$(cat ' + LOCK + '/owner)" = ' + shlex.quote(token) + ' && rm ' + LOCK + '/owner && rmdir ' + LOCK))
|
||||
report['lockReleased'] = not cleanup
|
||||
else:
|
||||
report['lockRetainedForRecovery'] = token
|
||||
report['cleanupErrors'] = cleanup
|
||||
report['passed'] = bool(report.get('passed') and not cleanup)
|
||||
(args.output/'result.json').write_text(json.dumps(report, indent=2)+'\n')
|
||||
signal.signal(signal.SIGTERM, previous_term)
|
||||
print(json.dumps(report, indent=2))
|
||||
return 0 if report['passed'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,186 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded, opt-in real Frame check. Run only while someone wears the headset.
|
||||
|
||||
Requires an already installed Frame Control VR panel, Decky user service, and
|
||||
one Framey-managed plugin. It never installs, restarts, or removes a service.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SSH = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame']
|
||||
LOCK = '/tmp/frame-test.lock'
|
||||
VIA = None
|
||||
|
||||
def ssh_argv(command):
|
||||
argv = [*SSH, command]
|
||||
return ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', VIA, shlex.join(argv)] if VIA else argv
|
||||
|
||||
|
||||
|
||||
def remote(command, stdin=None, timeout=15):
|
||||
run = subprocess.run(ssh_argv(command), input=stdin, capture_output=True,
|
||||
text=True, timeout=timeout)
|
||||
if run.returncode:
|
||||
raise RuntimeError((run.stderr or run.stdout).strip() or f'SSH exited {run.returncode}')
|
||||
return run.stdout
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import json, pathlib, subprocess
|
||||
power=[]
|
||||
for item in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
|
||||
try:
|
||||
if (item.parent/'type').read_text().strip() == 'Battery':
|
||||
power.append({'name':item.parent.name,'percent':int(item.read_text().strip())})
|
||||
except (OSError,ValueError): pass
|
||||
stats=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--stats'],capture_output=True,text=True,timeout=5)
|
||||
try: activity=json.loads(stats.stdout)[0]['activity_level'] if stats.returncode == 0 else None
|
||||
except (ValueError,KeyError,IndexError,TypeError): activity=None
|
||||
panels=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--overlays'],capture_output=True,text=True,timeout=5)
|
||||
panel=next((line.strip() for line in panels.stdout.splitlines() if 'valve.steam.desktopgame.2001947813' in line),None)
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
plugins=[]
|
||||
if base.is_dir() and not base.is_symlink():
|
||||
for folder in base.iterdir():
|
||||
if folder.is_symlink(): continue
|
||||
try:
|
||||
p=json.loads((folder/'.frame-control-store.json').read_text())
|
||||
manifest=json.loads((folder/'plugin.json').read_text())
|
||||
if isinstance(p.get('storeId'),int): plugins.append({'id':p['storeId'],'name':manifest['name']})
|
||||
except (OSError,ValueError,KeyError): pass
|
||||
service=subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],capture_output=True,text=True)
|
||||
print(json.dumps({'battery':power,'activity':activity,'panel':panel,
|
||||
'panelService':service.returncode==0,'plugins':plugins}))
|
||||
'''
|
||||
|
||||
OVERLAY_PROBE = r'''
|
||||
import ctypes as C, json
|
||||
vr=C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
|
||||
vr.VR_InitInternal2.argtypes=[C.POINTER(C.c_int),C.c_int,C.c_char_p]
|
||||
vr.VR_GetGenericInterface.argtypes=[C.c_char_p,C.POINTER(C.c_int)]
|
||||
vr.VR_GetGenericInterface.restype=C.c_void_p
|
||||
error=C.c_int()
|
||||
vr.VR_InitInternal2(C.byref(error),3,None)
|
||||
result={'visible':None,'dashboardVisible':None,'error':error.value}
|
||||
if not error.value:
|
||||
try:
|
||||
table=C.cast(vr.VR_GetGenericInterface(b'FnTable:IVROverlay_028',C.byref(error)),C.POINTER(C.c_void_p))
|
||||
if not error.value and table:
|
||||
handle=C.c_uint64()
|
||||
find=C.CFUNCTYPE(C.c_int,C.c_char_p,C.POINTER(C.c_uint64))(table[0])
|
||||
if find(b'valve.steam.desktopgame.2001947813',C.byref(handle))==0:
|
||||
result['visible']=bool(C.CFUNCTYPE(C.c_bool,C.c_uint64)(table[45])(handle))
|
||||
result['dashboardVisible']=bool(C.CFUNCTYPE(C.c_bool)(table[68])())
|
||||
finally: vr.VR_ShutdownInternal()
|
||||
print(json.dumps(result))
|
||||
'''
|
||||
|
||||
|
||||
def probe():
|
||||
return json.loads(remote('python3 -', stdin=PROBE))
|
||||
|
||||
|
||||
def check_file():
|
||||
code = "import pathlib;print((pathlib.Path.home()/'.local/share/frame-control/headset/worn-check.json').read_text())"
|
||||
return json.loads(remote('python3 -c ' + shlex.quote(code)))
|
||||
|
||||
|
||||
def color_count(text):
|
||||
return len(set(re.findall(r'#[0-9A-Fa-f]{6,8}', text)))
|
||||
|
||||
|
||||
def main(worn_confirmed=False):
|
||||
result = {'time':datetime.datetime.now(datetime.timezone.utc).isoformat(), 'proof':'operator-confirmed worn check', 'operatorWornConfirmed':worn_confirmed}
|
||||
token = 'framey-' + secrets.token_hex(12)
|
||||
locked = False
|
||||
challenge = False
|
||||
try:
|
||||
if not worn_confirmed:
|
||||
raise RuntimeError('A wearer must be present; pass --worn-confirmed only after checking in person. Wake/activity and trusted pointer events alone do not prove wearing or controller hardware.')
|
||||
# Do not read even standby status until this task owns the device lock.
|
||||
remote('mkdir ' + LOCK + ' && { printf %s ' + shlex.quote(token) +
|
||||
' > ' + LOCK + '/owner || { rm -f ' + LOCK + '/owner; rmdir ' + LOCK + '; exit 1; }; }')
|
||||
locked = True
|
||||
before = probe()
|
||||
result['before'] = before
|
||||
if not before['battery'] or min(x['percent'] for x in before['battery']) < 20:
|
||||
raise RuntimeError('Battery capacity unavailable or below 20%; no device session started')
|
||||
if before['activity'] is None or before['activity'] >= 3:
|
||||
raise RuntimeError('SteamVR is in standby; no worn-headset check started')
|
||||
if not before['panelService'] or not before['plugins']:
|
||||
raise RuntimeError('Install and open the Frame Control panel and a Framey plugin before this check')
|
||||
after_lock = probe()
|
||||
if after_lock['activity'] >= 3 or min(x['percent'] for x in after_lock['battery']) < 20:
|
||||
raise RuntimeError('Activity or battery changed after acquiring the lock')
|
||||
state = json.dumps({'nonce':token,'started':time.time()})
|
||||
code = "import pathlib; p=pathlib.Path.home()/'.local/share/frame-control/headset/worn-check.json'; p.write_text(" + repr(state) + ")"
|
||||
remote('python3 -c ' + shlex.quote(code))
|
||||
challenge = True
|
||||
print('In the worn headset: open SteamVR dashboard → Frame Control → a managed Decky plugin → Open panel. Touch a setting with the controller. Waiting up to 35 seconds.', flush=True)
|
||||
deadline = time.monotonic() + 35
|
||||
while time.monotonic() < deadline:
|
||||
state = check_file()
|
||||
if state.get('nonce') == token and state.get('panel') and state.get('input'):
|
||||
break
|
||||
time.sleep(1)
|
||||
result['interaction'] = state
|
||||
result['after'] = probe()
|
||||
result['activeHeadset'] = isinstance(result['after']['activity'], int) and result['after']['activity'] < 3
|
||||
result['overlay'] = json.loads(remote('python3 -', stdin=OVERLAY_PROBE, timeout=15))
|
||||
result['panelVisible'] = result['overlay']['visible'] is True
|
||||
installed_ids = {p['id'] for p in before['plugins']}
|
||||
result['trustedPointerInput'] = bool(state.get('input', {}).get('at', 0) > state.get('started', 0))
|
||||
result['pluginPanelReady'] = bool(state.get('panel', {}).get('at', 0) > state.get('started', 0)
|
||||
and state['panel'].get('pluginId') in installed_ids)
|
||||
if result['panelVisible'] and result['pluginPanelReady']:
|
||||
shot = json.loads(remote('python3 -', stdin=(ROOT/'ui/frame_vrshot.py').read_text(), timeout=20))['path']
|
||||
local = Path('/tmp')/('framey-worn-' + token + '.png')
|
||||
try:
|
||||
with local.open('wb') as out:
|
||||
copy = subprocess.run(ssh_argv('cat ' + shlex.quote(shot)), stdout=out, stderr=subprocess.PIPE, timeout=20)
|
||||
if copy.returncode: raise RuntimeError('Could not copy the SteamVR screenshot')
|
||||
result['screenshotSha256'] = hashlib.sha256(local.read_bytes()).hexdigest()
|
||||
pixels = subprocess.run(['convert', str(local), '-resize', '64x64!', 'txt:-'],
|
||||
capture_output=True, text=True, timeout=15)
|
||||
result['screenshotColors'] = color_count(pixels.stdout) if pixels.returncode == 0 else None
|
||||
finally:
|
||||
local.unlink(missing_ok=True)
|
||||
remote('rm -f ' + shlex.quote(shot))
|
||||
result['passed'] = (result['activeHeadset'] and result['panelVisible'] and result['trustedPointerInput'] and
|
||||
result['pluginPanelReady'] and (result.get('screenshotColors') or 0) > 8)
|
||||
except Exception as exc:
|
||||
result['error'] = str(exc)
|
||||
finally:
|
||||
if challenge:
|
||||
code = "import json,pathlib; p=pathlib.Path.home()/'.local/share/frame-control/headset/worn-check.json'; d=json.loads(p.read_text()); p.unlink() if d.get('nonce')==" + repr(token) + " else None"
|
||||
try: remote('python3 -c ' + shlex.quote(code))
|
||||
except Exception as exc: result['challengeCleanupError'] = str(exc)
|
||||
if locked:
|
||||
try:
|
||||
remote('test "$(cat ' + LOCK + '/owner)" = ' + shlex.quote(token) +
|
||||
' && rm ' + LOCK + '/owner && rmdir ' + LOCK)
|
||||
result['lockReleased'] = True
|
||||
except Exception as exc: result['lockCleanupError'] = str(exc)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result.get('passed') else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--host', default='frame')
|
||||
parser.add_argument('--via', help='Paired SSH hop, e.g. lxso2')
|
||||
parser.add_argument('--worn-confirmed', action='store_true', help='Operator confirms someone is wearing the Frame')
|
||||
args = parser.parse_args()
|
||||
SSH[-1] = args.host
|
||||
VIA = args.via
|
||||
sys.exit(main(args.worn_confirmed))
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Small OpenVR diagnostic client; runs on the Frame, never polls another app's events.
|
||||
|
||||
Indices/signatures are for IVROverlay_027 and IVROverlayView_003 from Valve's
|
||||
shipped openvr_capi.h, not interchangeable with IVROverlay_028.
|
||||
"""
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
|
||||
KEY = b'valve.steam.desktopgame.2001947813'
|
||||
|
||||
|
||||
class Vector(C.Structure):
|
||||
_fields_ = [('x', C.c_float), ('y', C.c_float)]
|
||||
|
||||
|
||||
class Matrix(C.Structure):
|
||||
_fields_ = [('m', (C.c_float * 4) * 3)]
|
||||
|
||||
|
||||
class Mouse(C.Structure):
|
||||
_fields_ = [('x', C.c_float), ('y', C.c_float), ('button', C.c_uint32)]
|
||||
|
||||
|
||||
class Scroll(C.Structure):
|
||||
_fields_ = [('xdelta',C.c_float),('ydelta',C.c_float),('unused',C.c_uint32),('viewportscale',C.c_float)]
|
||||
|
||||
|
||||
class Data(C.Union):
|
||||
_fields_ = [('mouse', Mouse), ('scroll', Scroll), ('reserved', C.c_uint64 * 6)]
|
||||
|
||||
|
||||
class Event(C.Structure):
|
||||
_pack_ = 4 # Linux OpenVR preserves its historical 4-byte event packing.
|
||||
_fields_ = [('eventType', C.c_uint32), ('device', C.c_uint32),
|
||||
('age', C.c_float), ('data', Data)]
|
||||
|
||||
|
||||
class Overlay:
|
||||
def __init__(self):
|
||||
self.vr = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
|
||||
self.vr.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
|
||||
self.vr.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
|
||||
self.vr.VR_GetGenericInterface.restype = C.c_void_p
|
||||
error = C.c_int()
|
||||
self.vr.VR_InitInternal2(C.byref(error), 3, None) # background, no scene focus
|
||||
if error.value:
|
||||
raise RuntimeError('OpenVR init: ' + str(error.value))
|
||||
try:
|
||||
self.table = self.interface(b'FnTable:IVROverlay_027')
|
||||
self.handle = C.c_uint64()
|
||||
rc = self.fn(0, C.c_int, C.c_char_p, C.POINTER(C.c_uint64))(KEY, C.byref(self.handle))
|
||||
if rc:
|
||||
raise RuntimeError('Frame Control overlay not found: ' + str(rc))
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def interface(self, version):
|
||||
error = C.c_int()
|
||||
ptr = self.vr.VR_GetGenericInterface(version, C.byref(error))
|
||||
if error.value or not ptr:
|
||||
raise RuntimeError('OpenVR interface unavailable: ' + version.decode())
|
||||
return C.cast(ptr, C.POINTER(C.c_void_p))
|
||||
|
||||
def fn(self, index, result, *args):
|
||||
return C.CFUNCTYPE(result, *args)(self.table[index])
|
||||
|
||||
def snapshot(self):
|
||||
h = self.handle
|
||||
width, height = C.c_uint32(), C.c_uint32()
|
||||
texture_error = self.fn(64, C.c_int, C.c_uint64, C.POINTER(C.c_uint32), C.POINTER(C.c_uint32))(h, C.byref(width), C.byref(height))
|
||||
transform_type = C.c_int()
|
||||
type_error = self.fn(31,C.c_int,C.c_uint64,C.POINTER(C.c_int))(h,C.byref(transform_type))
|
||||
origin, matrix = C.c_int(), Matrix()
|
||||
transform_error = self.fn(33, C.c_int, C.c_uint64, C.POINTER(C.c_int), C.POINTER(Matrix))(h, C.byref(origin), C.byref(matrix))
|
||||
scale, method = Vector(), C.c_int()
|
||||
scale_error = self.fn(49, C.c_int, C.c_uint64, C.POINTER(Vector))(h, C.byref(scale))
|
||||
method_error = self.fn(47, C.c_int, C.c_uint64, C.POINTER(C.c_int))(h, C.byref(method))
|
||||
return {'interface': 'IVROverlay_027',
|
||||
'visible': bool(self.fn(43, C.c_bool, C.c_uint64)(h)),
|
||||
'dashboardVisible': bool(self.fn(66, C.c_bool)()),
|
||||
'activeDashboard': bool(self.fn(67, C.c_bool, C.c_uint64)(h)),
|
||||
'texture': {'error': texture_error, 'width': width.value, 'height': height.value},
|
||||
'transform': {'error': transform_error, 'typeError':type_error,'type':transform_type.value,'origin': origin.value,
|
||||
'matrix': [list(row) for row in matrix.m]},
|
||||
'input': {'error': method_error, 'method': method.value,
|
||||
'scaleError': scale_error, 'scale': [scale.x, scale.y]}}
|
||||
|
||||
def post_mouse(self, kind, x, y, button=0):
|
||||
# PostOverlayEvent is the supported overlay-view injection API. It is
|
||||
# not a tracked controller and its void return does not prove delivery.
|
||||
view = self.interface(b'FnTable:IVROverlayView_003')
|
||||
event = Event(kind, 0xFFFFFFFF, 0, Data(mouse=Mouse(x, y, button)))
|
||||
C.CFUNCTYPE(None, C.c_uint64, C.POINTER(Event))(view[2])(self.handle, C.byref(event))
|
||||
|
||||
def post_scroll(self, xdelta, ydelta):
|
||||
view = self.interface(b'FnTable:IVROverlayView_003')
|
||||
event = Event(309, 0xFFFFFFFF, 0, Data(scroll=Scroll(xdelta, ydelta, 0, 1)))
|
||||
C.CFUNCTYPE(None, C.c_uint64, C.POINTER(Event))(view[2])(self.handle, C.byref(event))
|
||||
|
||||
def image_hash(self):
|
||||
width, height = C.c_uint32(), C.c_uint32()
|
||||
call = self.fn(6, C.c_int, C.c_uint64, C.c_void_p, C.c_uint32,
|
||||
C.POINTER(C.c_uint32), C.POINTER(C.c_uint32))
|
||||
rc = call(self.handle, None, 0, C.byref(width), C.byref(height))
|
||||
size = width.value * height.value * 4
|
||||
result = {'error': rc, 'width': width.value, 'height': height.value}
|
||||
if 0 < size <= 32 * 1024 * 1024:
|
||||
pixels = C.create_string_buffer(size)
|
||||
rc = call(self.handle, pixels, size, C.byref(width), C.byref(height))
|
||||
result['error'] = rc
|
||||
if not rc:
|
||||
result['sha256'] = hashlib.sha256(pixels.raw).hexdigest()
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
self.vr.VR_ShutdownInternal()
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Headset-side real input exercise, appended after framey_openvr.py by the runner."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
|
||||
def exercise(profile, plugin_id, folder):
|
||||
sys.path.insert(0, str(Path.home()/'.local/share/frame-control/headset/bundle/ui'))
|
||||
import frame_steam
|
||||
port = int((Path(profile)/'DevToolsActivePort').read_text().splitlines()[0])
|
||||
frame_steam.CDP = f'http://127.0.0.1:{port}/json'
|
||||
parent = frame_steam.Page('Frame Control VR [fc-headset]')
|
||||
panel = None
|
||||
vr = Overlay()
|
||||
result = {'clickTransport':'IVROverlayView_003.PostOverlayEvent',
|
||||
'textTransport':'CDP Input.insertText after OpenVR focus',
|
||||
'controllerHardwareVerified':False}
|
||||
|
||||
def wait(read, accept, seconds=15):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
value = read()
|
||||
if accept(value): return value
|
||||
time.sleep(.25)
|
||||
raise RuntimeError('Timed out waiting for live UI: ' + str(value))
|
||||
|
||||
def connect_panel():
|
||||
def target():
|
||||
targets = json.load(urllib.request.urlopen(frame_steam.CDP, timeout=5))
|
||||
return next((p['title'] for p in targets if p.get('type') == 'iframe'
|
||||
and '/framey/panel/' + str(plugin_id) in p.get('url','')), None)
|
||||
page = frame_steam.Page(wait(target, bool))
|
||||
page.eval('window.__vrClickCount=0;addEventListener("pointerup",e=>{window.__vrClickCount++;window.__vrLastTarget=e.target;});true')
|
||||
return page
|
||||
|
||||
def command(page, method, params):
|
||||
page.next_id += 1
|
||||
page._send(json.dumps({'id':page.next_id,'method':method,'params':params}))
|
||||
while True:
|
||||
msg = json.loads(page._recv())
|
||||
if msg.get('id') == page.next_id:
|
||||
if 'error' in msg: raise RuntimeError(str(msg['error']))
|
||||
return msg.get('result', {})
|
||||
|
||||
def text(): return panel.eval('document.body.innerText')
|
||||
|
||||
def click(selector):
|
||||
# Account for CSS pixels, iframe position, border, HiDPI scaling, and
|
||||
# OpenVR's bottom-left mouse origin; read geometry after scrolling.
|
||||
panel.eval(f'document.querySelector({json.dumps(selector)}).scrollIntoView({{block:"center"}})')
|
||||
time.sleep(.3) # let Chromium present the scrolled iframe before native input
|
||||
pos = panel.eval(f'(()=>{{let r=document.querySelector({json.dumps(selector)}).getBoundingClientRect();return {{x:r.x+r.width/2,y:r.y+r.height/2}}}})()')
|
||||
outer = parent.eval('(()=>{let f=byId("frameyFrame"),r=f.getBoundingClientRect();return {x:r.x+f.clientLeft,y:r.y+f.clientTop,w:innerWidth,h:innerHeight}})()')
|
||||
sx, sy = vr.snapshot()['input']['scale']
|
||||
x = (outer['x'] + pos['x']) * sx / outer['w']
|
||||
y = sy - (outer['y'] + pos['y']) * sy / outer['h']
|
||||
if not 0 <= x <= sx or not 0 <= y <= sy:
|
||||
raise RuntimeError('Control is outside the visible viewport')
|
||||
count = panel.eval('__vrClickCount')
|
||||
vr.post_mouse(300, x, y)
|
||||
vr.post_mouse(301, x, y, 1)
|
||||
vr.post_mouse(302, x, y, 1)
|
||||
wait(lambda:panel.eval('__vrClickCount'), lambda n:n > count, seconds=3)
|
||||
hit = panel.eval(f'!!window.__vrLastTarget?.closest({json.dumps(selector)})')
|
||||
if not hit:
|
||||
raise RuntimeError('Native click hit another element: ' + str(panel.eval('({tag:__vrLastTarget?.tagName,text:__vrLastTarget?.textContent?.slice(0,100)})')))
|
||||
|
||||
try:
|
||||
parent.eval(f'(async()=>{{await refreshDecky();openFrameyPanel({plugin_id},"Framey VR Check");return true}})()')
|
||||
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
|
||||
panel = connect_panel()
|
||||
initial = wait(text, lambda s:'Saved setting: off' in s or 'Could not read setting:' in s)
|
||||
if 'Could not read setting:' in initial: raise RuntimeError(initial)
|
||||
result['initialText'] = initial
|
||||
panel.eval('window.__vrEvents=[];addEventListener("pointerup",e=>__vrEvents.push({trusted:e.isTrusted,x:e.clientX,y:e.clientY}));true')
|
||||
click('input[type=checkbox]')
|
||||
result['savedText'] = wait(text, lambda s:'Saved setting: on' in s)
|
||||
result['pointerEvents'] = panel.eval('__vrEvents')
|
||||
result['trustedOverlayPointer'] = any(e['trusted'] for e in result['pointerEvents'])
|
||||
disk = Path.home()/'.local/share/frame-control/decky/settings'/folder/'settings.json'
|
||||
result['diskSetting'] = json.loads(disk.read_text())
|
||||
# Fresh browser document, same normal parent API; no backend stub.
|
||||
panel.sock.close(); panel = None
|
||||
parent.eval(f'openFrameyPanel({plugin_id},"Framey VR Check")')
|
||||
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
|
||||
panel = connect_panel()
|
||||
result['reopenedText'] = wait(text, lambda s:'Saved setting: on' in s)
|
||||
result['reopenedChecked'] = panel.eval('document.querySelector("input[type=checkbox]").checked')
|
||||
# Settings tab and quick action are real host controls too.
|
||||
click('nav button:nth-child(2)')
|
||||
wait(text, lambda s:'Saved setting: on' in s)
|
||||
click('nav button:nth-child(1)')
|
||||
wait(text, lambda s:'Saved setting: on' in s)
|
||||
panel.eval('[...document.querySelectorAll("button")].find(b=>b.textContent==="Read saved setting").id="vr-quick"')
|
||||
click('#vr-quick')
|
||||
result['quickActionText'] = wait(text, lambda s:'Saved setting is on' in s)
|
||||
# Isolated diagnostic controls use the actual shipped DFL shims. Their
|
||||
# values are UI-only; they never change a user's settings or plugin.
|
||||
panel.eval('''(()=>{
|
||||
const mount=document.createElement('div');mount.id='vr-controls';document.body.prepend(mount);
|
||||
function Controls(){const R=SP_REACT,[v,S]=R.useState(10),[t,T]=R.useState('');return R.createElement('section',null,
|
||||
R.createElement(DFL.SliderField,{label:'VR test slider',min:0,max:100,value:v,showValue:true,onChange:S}),
|
||||
R.createElement(DFL.TextField,{label:'VR test text',value:t,onChange:e=>T(e.target.value)}),
|
||||
R.createElement('p',{id:'vr-control-values'},v+':'+t));}
|
||||
SP_REACTDOM.createRoot(mount).render(SP_REACT.createElement(Controls));return true;
|
||||
})()''')
|
||||
wait(lambda:panel.eval('!!document.querySelector("#vr-controls input[type=range]")'), bool)
|
||||
click('#vr-controls input[type=range]')
|
||||
result['sliderValue'] = wait(lambda:panel.eval('document.querySelector("#vr-controls input[type=range]").value'), lambda v:v != '10')
|
||||
click('#vr-controls input[type=text]')
|
||||
# CDP only types text; OpenVR supplies pointer focus into the field.
|
||||
command(parent, 'Input.insertText', {'text':'framey-ci'})
|
||||
result['textValue'] = wait(lambda:panel.eval('document.querySelector("#vr-controls input[type=text]").value'), lambda v:v == 'framey-ci')
|
||||
panel.eval('window.scrollTo(0,0)')
|
||||
result['scrollBefore'] = panel.eval('scrollY')
|
||||
vr.post_scroll(0,-3)
|
||||
time.sleep(.5)
|
||||
result['scrollAfter'] = panel.eval('scrollY')
|
||||
result['nativeScrollMoved'] = result['scrollAfter'] != result['scrollBefore']
|
||||
panel.eval('document.getElementById("vr-controls").remove()')
|
||||
# Scroll saved status into the visible iframe for compositor evidence.
|
||||
panel.eval('document.querySelector("[role=status]").scrollIntoView({block:"center"})')
|
||||
result['finalOverlay'] = vr.snapshot()
|
||||
result['passed'] = (result['trustedOverlayPointer'] and result['diskSetting'] == {'enabled':True}
|
||||
and result['reopenedChecked'] and result['sliderValue'] != '10'
|
||||
and result['textValue'] == 'framey-ci')
|
||||
return result
|
||||
except Exception as exc:
|
||||
result['error'] = str(exc)
|
||||
result['passed'] = False
|
||||
if panel:
|
||||
result['failureText'] = text()
|
||||
result['focus'] = panel.eval('({tag:document.activeElement?.tagName,type:document.activeElement?.type})')
|
||||
return result
|
||||
finally:
|
||||
vr.close()
|
||||
parent.sock.close()
|
||||
if panel: panel.sock.close()
|
||||
@@ -1,184 +0,0 @@
|
||||
"""Verified Decky-store-shaped install flow through the real HTTP server and fake Frame SSH."""
|
||||
import hashlib
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
import zipfile
|
||||
|
||||
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh
|
||||
from framey_official import LEGACY_PLUGINS, PLUGINS as PANEL_PLUGINS, archive as official_archive
|
||||
|
||||
OFFICIAL_PLUGINS = PANEL_PLUGINS + LEGACY_PLUGINS
|
||||
|
||||
require() # Skip the module outside the disposable fake Frame, like its peers.
|
||||
|
||||
|
||||
def archive(version):
|
||||
raw = io.BytesIO()
|
||||
with zipfile.ZipFile(raw, 'w') as bundle:
|
||||
bundle.writestr('sample-panel/plugin.json', json.dumps({'name':'Sample Panel','api_version':2,'flags':[]}))
|
||||
bundle.writestr('sample-panel/dist/index.js',
|
||||
f"export default () => ({{content: SP_REACT.createElement('p', null, 'Sample {version}')}});")
|
||||
bundle.writestr('sample-panel/README.txt', ''.join(hashlib.sha256(f'{version}-{n}'.encode()).hexdigest() for n in range(35)))
|
||||
return raw.getvalue()
|
||||
|
||||
|
||||
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):
|
||||
release = 0
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == '/plugins':
|
||||
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]}]}]
|
||||
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])
|
||||
content_type = 'application/zip'
|
||||
else:
|
||||
body = None
|
||||
self.send_response(200 if body is not None else 404)
|
||||
self.send_header('Content-Type', content_type if body is not None else 'text/plain')
|
||||
self.end_headers()
|
||||
self.wfile.write(body or b'not found')
|
||||
|
||||
|
||||
class FrameyFlow(FrameTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
require()
|
||||
StoreFixture.release = 0
|
||||
cls.fixture = ThreadingHTTPServer(('127.0.0.1', 0), StoreFixture)
|
||||
cls.thread = threading.Thread(target=cls.fixture.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
port = cls.fixture.server_port
|
||||
cls.previous = {key:os.environ.get(key) for key in ('FRAMEY_STORE_URL','FRAMEY_CDN_URL')}
|
||||
os.environ['FRAMEY_STORE_URL'] = f'http://127.0.0.1:{port}/plugins'
|
||||
os.environ['FRAMEY_CDN_URL'] = f'http://127.0.0.1:{port}/versions/'
|
||||
Server.stop()
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
Server.stop()
|
||||
cls.fixture.shutdown()
|
||||
for key, value in cls.previous.items():
|
||||
if value is None: os.environ.pop(key, None)
|
||||
else: os.environ[key] = value
|
||||
|
||||
def backend(self):
|
||||
code = '''import json,pathlib
|
||||
h=pathlib.Path.home(); b=h/'.local/share/frame-control/decky/.frame-control'; b.mkdir(parents=True,exist_ok=True)
|
||||
(b/'receipt.json').write_text(json.dumps({'managedBy':'Frame Control','version':'v3.3.0'}))
|
||||
(b.parent/'plugins').mkdir(exist_ok=True)
|
||||
u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=True,exist_ok=True); u.write_text('[Service]')'''
|
||||
ssh('python3 -c ' + shlex.quote(code))
|
||||
ssh('mkdir -p ~/.local/share/frame-control/headset/bundle/ui')
|
||||
source = (ROOT/'ui/frame_steam.py').read_bytes()
|
||||
copy = subprocess.run(['ssh','frame','cat > ~/.local/share/frame-control/headset/bundle/ui/frame_steam.py'],
|
||||
input=source, capture_output=True, timeout=20)
|
||||
self.assertEqual(copy.returncode, 0, copy.stderr)
|
||||
|
||||
def test_store_install_update_remove_and_guards(self):
|
||||
self.backend()
|
||||
status, _, _ = api('GET', '/api/decky/store', headers={'X-Frame-UI':''})
|
||||
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':999})
|
||||
self.assertEqual(status, 400) # Third-party code needs explicit acknowledgement.
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'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':999,'method':'get_setting','args':[]},
|
||||
headers={'X-Frame-UI':''})
|
||||
self.assertEqual(status, 403)
|
||||
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':999,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
|
||||
self.assertTrue(ok('POST', '/api/framey/method',
|
||||
{'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')
|
||||
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}],
|
||||
'legacy':True})['result'], {'success':True,'result':'light'})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}],
|
||||
'legacy':True})['result'], {'success':True,'result':True})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved')
|
||||
|
||||
StoreFixture.release = 1
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':999,'acknowledged':True}))
|
||||
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':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')
|
||||
stale = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':999,'sha256':HASHES[0],
|
||||
'acknowledged':True}))
|
||||
self.assertIn('version changed', stale.get('error', ''))
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
|
||||
|
||||
def test_pinned_official_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')).get('api_version', 'legacy'), plugin['api_version'])
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':plugin['id'],'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertFalse(any(item['storeId'] == plugin['id']
|
||||
for item in ok('GET', '/api/decky')['plugins']))
|
||||
@@ -1,120 +0,0 @@
|
||||
"""Optional full-catalog archive/transport audit; no official backend executes."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
import unittest
|
||||
import zipfile
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from harness import FrameTestCase, ROOT, Server, finished, ok, require, ssh
|
||||
import frame_decky_store
|
||||
|
||||
require()
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('FRAMEY_AUDIT_CACHE'), 'optional full catalog audit cache not mounted')
|
||||
class CatalogAudit(FrameTestCase):
|
||||
def backend(self):
|
||||
# Only the fake service/CEF stub executes. Official Python/native code
|
||||
# copied by the installer is never imported or launched by this harness.
|
||||
source = '''import json,pathlib
|
||||
h=pathlib.Path.home();b=h/'.local/share/frame-control/decky/.frame-control';b.mkdir(parents=True,exist_ok=True)
|
||||
(b/'receipt.json').write_text(json.dumps({'managedBy':'Frame Control','version':'v3.3.0'}))
|
||||
(b.parent/'plugins').mkdir(exist_ok=True)
|
||||
u=h/'.config/systemd/user/frame-control-decky.service';u.parent.mkdir(parents=True,exist_ok=True);u.write_text('[Service]')'''
|
||||
ssh('python3 -c '+shlex.quote(source))
|
||||
ssh('mkdir -p ~/.local/share/frame-control/headset/bundle/ui')
|
||||
result = subprocess.run(['ssh', 'frame', 'cat > ~/.local/share/frame-control/headset/bundle/ui/frame_steam.py'],
|
||||
input=(ROOT/'ui/frame_steam.py').read_bytes(), capture_output=True, timeout=20)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
records = json.loads((ROOT/'ui/framey-compatibility.json').read_text())['plugins']
|
||||
cache = Path(os.environ['FRAMEY_AUDIT_CACHE'])
|
||||
|
||||
class Fixture(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == '/plugins':
|
||||
body = json.dumps([{'id': p['id'], 'name': p['name'], 'tags': p['tags'],
|
||||
'versions': [{'name': p['version'], 'hash': p['sha256']}]}
|
||||
for p in records]).encode()
|
||||
else:
|
||||
path = cache / self.path.rsplit('/', 1)[-1]
|
||||
if path.suffix != '.zip' or path.name not in {p['sha256']+'.zip' for p in records}:
|
||||
self.send_error(404)
|
||||
return
|
||||
body = path.read_bytes()
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass # Installer stops reading at its archive size limit.
|
||||
|
||||
fixture = ThreadingHTTPServer(('127.0.0.1', 0), Fixture)
|
||||
cls.addClassCleanup(fixture.server_close)
|
||||
cls.addClassCleanup(fixture.shutdown)
|
||||
threading.Thread(target=fixture.serve_forever, daemon=True).start()
|
||||
old = {k: os.environ.get(k) for k in ('FRAMEY_STORE_URL', 'FRAMEY_CDN_URL')}
|
||||
def restore():
|
||||
Server.stop()
|
||||
for key, value in old.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
cls.addClassCleanup(restore)
|
||||
os.environ['FRAMEY_STORE_URL'] = f'http://127.0.0.1:{fixture.server_port}/plugins'
|
||||
os.environ['FRAMEY_CDN_URL'] = f'http://127.0.0.1:{fixture.server_port}/versions/'
|
||||
Server.stop()
|
||||
super().setUpClass()
|
||||
|
||||
def test_all_current_official_archives(self):
|
||||
self.backend()
|
||||
records = json.loads((ROOT/'ui/framey-compatibility.json').read_text())['plugins']
|
||||
cache = Path(os.environ['FRAMEY_AUDIT_CACHE'])
|
||||
catalog = {p['id']: p for p in ok('GET', '/api/decky/store')['plugins']}
|
||||
self.assertEqual(set(catalog), {p['id'] for p in records})
|
||||
results = []
|
||||
for plugin in records:
|
||||
with self.subTest(plugin=plugin['name']):
|
||||
data = (cache/(plugin['sha256']+'.zip')).read_bytes()
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
|
||||
validation = None
|
||||
try:
|
||||
folder = frame_decky_store.archive_info(data, plugin['sha256'], plugin['name'])
|
||||
except ValueError as error:
|
||||
validation = str(error)
|
||||
blocked = catalog[plugin['id']]['root'] or plugin['blockInstall'] or validation
|
||||
job = finished(ok('POST', '/api/decky/plugin', {'action': 'install', 'id': plugin['id'],
|
||||
'sha256': plugin['sha256'], 'acknowledged': True}))
|
||||
record = {'id': plugin['id'], 'sha256': plugin['sha256'],
|
||||
'archiveValidation': validation or 'accepted', 'result': 'blocked' if blocked else 'installed-removed'}
|
||||
if blocked:
|
||||
self.assertTrue(job.get('error'), job)
|
||||
record['reason'] = job['error']
|
||||
self.assertFalse(any(p['storeId'] == plugin['id'] for p in ok('GET', '/api/decky')['plugins']))
|
||||
else:
|
||||
self.assertFalse(job.get('error'), job)
|
||||
installed = f'~/.local/share/frame-control/decky/plugins/{folder}'
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
entry = folder+'/dist/index.js'
|
||||
if entry in archive.namelist():
|
||||
expected = hashlib.sha256(archive.read(entry)).hexdigest()
|
||||
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
||||
found = next(p for p in ok('GET', '/api/decky')['plugins'] if p['storeId'] == plugin['id'])
|
||||
self.assertEqual((found['version'], found['sha256']), (plugin['version'], plugin['sha256']))
|
||||
removed = finished(ok('POST', '/api/decky/plugin', {'action': 'remove', 'id': plugin['id'], 'acknowledged': True}))
|
||||
self.assertFalse(removed.get('error'), removed)
|
||||
self.assertFalse(any(p['storeId'] == plugin['id'] for p in ok('GET', '/api/decky')['plugins']))
|
||||
results.append(record)
|
||||
Path(os.environ.get('FRAMEY_AUDIT_OUTPUT', '/tmp/framey-catalog-result.json')).write_text(json.dumps(results, indent=2)+'\n')
|
||||
@@ -3,11 +3,9 @@
|
||||
|
||||
Stable NRestarts=0 and inactive keep-awake are harness assumptions, not observed
|
||||
device behavior. Real Frame query forms come from the historical doctor runbook
|
||||
(2026-09-28, BUILD_ID 20260925.6191901). The Framey e2e fixture emulates only
|
||||
the Decky test unit's active/start/stop answers; no real service is launched.
|
||||
(2026-09-28, BUILD_ID 20260925.6191901). No service control is emulated here.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
args = sys.argv[1:]
|
||||
if args in (['--user', 'show', 'steam.service', '-p', 'NRestarts', '--value'],
|
||||
@@ -15,12 +13,5 @@ if args in (['--user', 'show', 'steam.service', '-p', 'NRestarts', '--value'],
|
||||
print('0')
|
||||
elif args == ['--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value']:
|
||||
print('inactive')
|
||||
elif args in (['--user', 'is-active', 'frame-control-decky.service'],
|
||||
['--user', 'is-active', '--quiet', 'frame-control-decky.service'],
|
||||
['--user', 'stop', 'frame-control-decky.service'],
|
||||
['--user', 'start', 'frame-control-decky.service']) and \
|
||||
(Path.home()/'.config/systemd/user/frame-control-decky.service').is_file():
|
||||
if args[1] == 'is-active' and '--quiet' not in args:
|
||||
print('active')
|
||||
else:
|
||||
sys.exit(1)
|
||||
@@ -25,31 +25,6 @@ function newShortcutId(steam) {
|
||||
}
|
||||
|
||||
function build(steam) {
|
||||
// Synthetic Decky API for Framey's CDP bridge test. This does not model the
|
||||
// real loader or prove frontend visibility on a headset.
|
||||
const deckyBackend = {async call(route, plugin, method, ...args) {
|
||||
if (!['loader/call_plugin_method','loader/call_legacy_plugin_method'].includes(route) || plugin !== 'Sample Panel')
|
||||
throw Error('unexpected Decky plugin route');
|
||||
const settings = (steam.decky_settings ||= {});
|
||||
const owned = (settings[plugin] ||= {});
|
||||
if (route === 'loader/call_legacy_plugin_method') {
|
||||
const kwargs = args[0];
|
||||
if (method === 'get_setting')
|
||||
return {success:true, result:Object.hasOwn(owned, kwargs.key) ? owned[kwargs.key] : kwargs.default};
|
||||
if (method === 'set_setting') {
|
||||
owned[kwargs.key] = kwargs.value;
|
||||
return {success:true, result:true};
|
||||
}
|
||||
return {success:false, result:'method unavailable in fake Decky backend'};
|
||||
}
|
||||
if (method === 'get_setting')
|
||||
return Object.hasOwn(owned, args[0]) ? owned[args[0]] : args[1];
|
||||
if (method === 'set_setting') {
|
||||
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));
|
||||
const gameOverview = a => ({
|
||||
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: a.app_type ?? 1,
|
||||
@@ -91,7 +66,6 @@ function build(steam) {
|
||||
value,
|
||||
});
|
||||
return {
|
||||
window: {DeckyBackend: deckyBackend}, DeckyBackend: deckyBackend,
|
||||
...require('./canvas_stub').surface(),
|
||||
collectionStore: {
|
||||
GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); },
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
-31
@@ -1,31 +0,0 @@
|
||||
# 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 |
|
||||
| --- | --- | --- | --- |
|
||||
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
|
||||
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
|
||||
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
|
||||
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
|
||||
| `18.zip` | Bluetooth | 3.1.1 | `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5` |
|
||||
| `26.zip` | HLTB for Deck | 2.0.12 | `bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558` |
|
||||
|
||||
The matching source projects are
|
||||
[MusicControl](https://github.com/nikurasu/MusicControl),
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky),
|
||||
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner),
|
||||
[Bluetooth](https://github.com/Outpox/Bluetooth), 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. Separate local host tests
|
||||
start Bluetooth's frontend and its initial panel without running effects or
|
||||
actions, and start HLTB's frontend and shallow settings view with a stub Steam
|
||||
module lookup. They do **not** run plugin Python backends, Bluetooth actions,
|
||||
HLTB searches, or prove JavaScript panels render in the VR shell. The
|
||||
Bluetooth result and limits are in
|
||||
[the fixture evidence](../../../docs/evidence/framey-bluetooth-fixture-2026-10-25.md).
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Pinned official Decky store archives used only in disposable test fixtures."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||
PLUGINS = (
|
||||
{'id': 12, 'name': 'MusicControl', 'version': '1.1.6', 'api_version': 'legacy', 'folder': 'MusicControl', 'sha256': '246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62'},
|
||||
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2,
|
||||
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
|
||||
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
|
||||
'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'},
|
||||
{'id': 50, 'name': 'Storage Cleaner', 'version': '1.4.0', 'api_version': 1,
|
||||
'folder': 'decky-storage-cleaner', 'sha256': '1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591'},
|
||||
{'id': 18, 'name': 'Bluetooth', 'version': '3.1.1', 'api_version': 1,
|
||||
'folder': 'Bluetooth', 'sha256': 'f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5'},
|
||||
{'id': 26, 'name': 'HLTB for Deck', 'version': '2.0.12', 'api_version': 1,
|
||||
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
|
||||
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
|
||||
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
|
||||
)
|
||||
|
||||
# This archive has a legacy IIFE frontend with no manifest API version.
|
||||
LEGACY_PLUGINS = (
|
||||
{'id': 7, 'name': 'CSS Loader', 'version': '2.1.3', 'api_version': 'legacy',
|
||||
'folder': 'SDH-CssLoader', 'sha256': '99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231'},
|
||||
)
|
||||
|
||||
|
||||
def archive(plugin):
|
||||
return (ROOT / f"{plugin['id']}.zip").read_bytes()
|
||||
@@ -1,259 +0,0 @@
|
||||
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; window.__legacy=legacyServerAPI;\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 roots=new Map();
|
||||
const window={React,ReactDOM:{createRoot:node=>{
|
||||
const root={render(value){this.value=value;},unmount(){this.unmounted=true;}};
|
||||
roots.set(node,root);return root;
|
||||
}},addEventListener(){}};
|
||||
const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)};
|
||||
const context=vm.createContext({window,document:{getElementById:id=>{
|
||||
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()},
|
||||
navigator:{language:'en'}, parent,DOMException,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];
|
||||
const checkbox=nodes(window.DFL.DialogCheckbox({label:'Game',disabled:true,
|
||||
onChange:()=>changed++}),'input')[0];
|
||||
for(const control of [toggle,dropdown,button,input,slider,checkbox])assert.equal(control.props.disabled,true);
|
||||
assert.equal(input.props.value,'Old');
|
||||
assert.equal(slider.props.type,'range');
|
||||
assert.equal(dropdown.props.value,'1');
|
||||
assert.equal(nodes(dropdown,'optgroup').length,1);
|
||||
toggle.props.onChange({target:{checked:false}});
|
||||
dropdown.props.onChange({target:{value:'0'}});
|
||||
button.props.onClick({});
|
||||
input.props.onChange({target:{value:'New'}});
|
||||
slider.props.onChange({target:{value:'4'}});
|
||||
checkbox.props.onChange({target:{checked:true}});
|
||||
assert.equal(changed,0);
|
||||
let entered='';
|
||||
const readyInput=nodes(window.DFL.TextField({label:'Alias',value:'Old',onChange:e=>{entered=e.target.value;}}),'input')[0];
|
||||
readyInput.props.onChange({target:{value:'New'}});assert.equal(entered,'New');
|
||||
let speed=0;
|
||||
const readySlider=nodes(window.DFL.SliderField({label:'Speed',value:2,onChange:v=>{speed=v;}}),'input')[0];
|
||||
readySlider.props.onChange({target:{value:'4'}});assert.equal(speed,4);
|
||||
let selected;
|
||||
const readyDropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast',
|
||||
onChange:option=>{selected=option;}}),'select')[0];
|
||||
readyDropdown.props.onChange({target:{value:'0'}});assert.equal(selected.data,'slow');
|
||||
const readyButton=nodes(window.DFL.ButtonItem({label:'Reload',onClick:()=>changed++}),'button')[0];
|
||||
assert.equal(readyButton.props.disabled,false);
|
||||
readyButton.props.onClick({});assert.equal(changed,1);
|
||||
let called=0;
|
||||
const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{type:'title'},
|
||||
frameyQuickActions:[{label:'Refresh data',onClick:async()=>{called++;return 'Fresh';}},
|
||||
{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');
|
||||
const call=messages.find(message=>message.type==='framey:call');
|
||||
assert.equal(call.method,'get_setting');
|
||||
assert.deepEqual(Array.from(call.args),['mode','default']);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:call.id,result:'dark'}});
|
||||
assert.equal(await reply,'dark');
|
||||
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
|
||||
|
||||
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);
|
||||
// Decky's toast fields are ReactNode values. Keep JSX in the panel instead of
|
||||
// showing "[object Object]", and release their React roots on dismissal.
|
||||
const heading=React.createElement('em',null,'Saved');
|
||||
const detail=React.createElement('span',null,'Mode updated');
|
||||
const icon=React.createElement('svg',null);
|
||||
const rich=api.toaster.toast({title:heading,body:[detail,' again'],icon,
|
||||
subtext:'Open settings',showToast:true});
|
||||
const richNode=elements.get('framey-toasts').children[1];
|
||||
assert.equal(roots.get(richNode.children[0]).value,icon);
|
||||
assert.equal(roots.get(richNode.children[1]).value,heading);
|
||||
assert.deepEqual(Array.from(roots.get(richNode.children[2]).value),[detail,' again']);
|
||||
assert.equal(richNode.children[3].textContent,'Open settings');
|
||||
rich.dismiss();rich.dismiss();
|
||||
assert.equal(richNode.removed,true);
|
||||
for(const node of richNode.children.slice(0,3))assert.equal(roots.get(node).unmounted,true);
|
||||
let opened=0;
|
||||
const actionable=api.toaster.toast({title:'Open',onClick:()=>opened++});
|
||||
const actionNode=elements.get('framey-toasts').children[2];
|
||||
actionNode.children.at(-1).onclick();
|
||||
assert.equal(opened,1);
|
||||
assert.equal(actionNode.removed,true);
|
||||
actionable.dismiss();
|
||||
const hidden=api.toaster.toast({title:heading,showToast:false});
|
||||
assert.equal(elements.get('framey-toasts').children.length,3);
|
||||
hidden.dismiss();
|
||||
|
||||
function LocalRoute(){return React.createElement('p',null,'Route content');}
|
||||
const undo=api.routerHook.addRoute('/plugin/details',LocalRoute);
|
||||
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);
|
||||
|
||||
// Legacy backend methods preserve Decky keyword arguments and success envelopes.
|
||||
const legacyReply=window.__legacy.callPluginMethod('get_setting',{key:'theme'});
|
||||
const legacyCall=messages.filter(message=>message.type==='framey:call').at(-1);
|
||||
assert.equal(legacyCall.legacy,true);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(legacyCall.args)),[{key:'theme'}]);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:legacyCall.id,
|
||||
result:{success:true,result:'dark'}}});
|
||||
assert.equal((await legacyReply).result,'dark');
|
||||
assert.equal((await window.__legacy.callPluginMethod('get_setting',[])).success,false);
|
||||
const failedLegacy=window.__legacy.callPluginMethod('get_setting',{});
|
||||
const failedCall=messages.filter(message=>message.type==='framey:call').at(-1);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:failedCall.id,error:'Unavailable'}});
|
||||
assert.equal((await failedLegacy).success,false);
|
||||
|
||||
// Local UI controls work without Steam components or fabricated hardware data.
|
||||
const tabChanges=[];
|
||||
const tabs=window.DFL.Tabs({tabs:[{id:'one',title:'One',content:'First'},
|
||||
{id:'two',title:'Two',content:'Second'},{id:'locked',title:'Locked',disabled:true}],
|
||||
activeTab:'two',onShowTab:id=>tabChanges.push(id)});
|
||||
assert.equal(nodes(tabs,'button')[1].props['aria-selected'],true);
|
||||
assert.deepEqual(nodes(tabs,'div').at(-1).children,['Second']);
|
||||
nodes(tabs,'button')[0].props.onClick();
|
||||
nodes(tabs,'button')[2].props.onClick();
|
||||
assert.deepEqual(tabChanges,['one']);
|
||||
assert.equal(window.DFL.Spinner({}).props.role,'status');
|
||||
assert.equal(window.DFL.ProgressBar({nProgress:120}).props.value,100);
|
||||
assert.equal(window.DFL.joinClassNames('one',null,'two'),'one two');
|
||||
let eventCount=0;
|
||||
const unsubscribe=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(()=>eventCount++);
|
||||
assert.equal(typeof unsubscribe,'function');
|
||||
assert.equal(unsubscribe.unregister,unsubscribe);
|
||||
unsubscribe.unregister();unsubscribe();assert.equal(eventCount,0);
|
||||
assert.equal(window.SteamClient.System.ShutdownPC,undefined);
|
||||
assert.equal(window.DFL.Router.MainRunningApp,undefined);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,0);
|
||||
assert.equal(window.DFL.Router.RunningApps,window.SteamUIStore.RunningApps);
|
||||
assert.ok(Object.isFrozen(window.SteamUIStore));
|
||||
assert.ok(Object.isFrozen(window.SuspendResumeStore));
|
||||
assert.equal(window.SuspendResumeStore.InitiateSleep,undefined);
|
||||
for(const name of ['App','appStore','appInfoStore','collectionStore'])assert.equal(window[name],undefined);
|
||||
for(const register of [window.SteamClient.System.UI.RegisterForSystemKeyEvents,
|
||||
window.SteamClient.Apps.RegisterForGameActionUserRequest]){
|
||||
const dispose=register(()=>eventCount++);assert.equal(dispose.unregister,dispose);dispose();
|
||||
}
|
||||
assert.equal(eventCount,0);
|
||||
const image=window.DFL.SuspensefulImage({src:'data:image/svg+xml,<svg/>',alt:'Setup',style:{width:50}});
|
||||
assert.equal(image.type,'img');assert.equal(image.props.alt,'Setup');assert.equal(image.props.style.width,50);
|
||||
await assert.rejects(window.SteamClient.Storage.GetJSON('missing'),/Not found/);
|
||||
await window.SteamClient.Storage.SetObject('settings',{enabled:false,count:3});
|
||||
assert.equal(await window.SteamClient.Storage.GetJSON('settings'),'{"enabled":false,"count":3}');
|
||||
assert.equal(window.localStorage.getItem('steam-client:settings'),'{"enabled":false,"count":3}');
|
||||
await assert.rejects(window.SteamClient.Storage.SetObject('large','x'.repeat(65537)),/Framey plugin cache is full/);
|
||||
await assert.rejects(window.SteamClient.Storage.SetObject('undefined',undefined),/JSON value/);
|
||||
assert.equal(window.indexedDB,undefined); // localForage must use bounded plugin storage.
|
||||
const unpatch=window.DFL.beforePatch({},'method',()=>{});
|
||||
assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch();
|
||||
assert.equal(typeof window.DFL.showModal({type:'p'}).Close,'function');
|
||||
let selectedMenuItem=0;
|
||||
window.DFL.MenuItem({onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
window.DFL.MenuItem({disabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
window.DFL.MenuItem({bDisabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
window.DFL.MenuItem({onClick:()=>selectedMenuItem++,onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
assert.equal(selectedMenuItem,2);
|
||||
for (const [locale,expected] of [['de-DE','german'],['zh-CN','schinese'],['zh-TW','tchinese'],
|
||||
['pt-BR','brazilian'],['en-US','english'],['xx-ZZ','english']]) {
|
||||
context.navigator.language=locale;
|
||||
assert.equal(await window.SteamClient.Settings.GetCurrentLanguage(),expected);
|
||||
}
|
||||
function GameRoute(){return React.createElement('p',null,window.DFL.useParams().appid);}
|
||||
const removeGame=api.routerHook.addRoute('/plugin/game/:appid/:view?',GameRoute);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
|
||||
assert.equal(window.DFL.useParams().appid,'123');
|
||||
assert.equal(window.DFL.useParams().view,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,1);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true);
|
||||
assert.equal(window.DFL.useParams().view,'art');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art/extra'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123?admin=1'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game//art'),false);
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute));
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
|
||||
removeGame();
|
||||
assert.equal(window.DFL.useParams().appid,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
|
||||
|
||||
// Event subscription is acknowledged before a method that immediately emits.
|
||||
const onMessage=listeners.find(x=>x.type==='message').handler;
|
||||
const eventValues=[];
|
||||
const callback=(...args)=>eventValues.push(args);
|
||||
assert.equal(api.addEventListener('timer:tick',callback),callback);
|
||||
const subscribe=messages.at(-1);
|
||||
assert.equal(subscribe.type,'framey:events');
|
||||
assert.deepEqual(Array.from(subscribe.args[0]),['timer:tick']);
|
||||
const before=messages.length;
|
||||
const timerCall=api.call('load_remaining_seconds');
|
||||
assert.equal(messages.length,before);
|
||||
// Other windows cannot inject backend replies.
|
||||
onMessage({source:{},data:{type:'framey:reply',id:subscribe.id,result:{events:[],cursor:0}}});
|
||||
await Promise.resolve();assert.equal(messages.length,before);
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:subscribe.id,
|
||||
result:{events:[{event:'timer:tick',args:[12,'seconds']}],cursor:1}}});
|
||||
for(let i=0;i<12;i++)await Promise.resolve();
|
||||
assert.deepEqual(eventValues,[[12,'seconds']]);
|
||||
const timerRequest=messages.at(-1);
|
||||
assert.equal(timerRequest.type,'framey:call');assert.equal(timerRequest.method,'load_remaining_seconds');
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:timerRequest.id,result:null}});await timerCall;
|
||||
api.removeEventListener('timer:tick',callback);
|
||||
const unsubscribeRequest=messages.at(-1);assert.equal(unsubscribeRequest.type,'framey:events');
|
||||
assert.equal(unsubscribeRequest.args[0].length,0);
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:unsubscribeRequest.id,
|
||||
result:{events:[{event:'timer:tick',args:[99]}],cursor:2}}});
|
||||
for(let i=0;i<12;i++)await Promise.resolve();assert.equal(eventValues.length,1);
|
||||
assert.throws(()=>api.addEventListener('bad event',callback),/Invalid Decky event/);
|
||||
assert.throws(()=>api.addEventListener('tick',null),/Invalid Decky event/);
|
||||
listeners.find(x=>x.type==='unload').handler();
|
||||
assert.equal(messages.at(-1).type,'framey:events:close');
|
||||
@@ -1,43 +0,0 @@
|
||||
// Start the unmodified official Bluetooth frontend and build its initial UI.
|
||||
// Effects and click handlers are inert: no Bluetooth/backend operation runs.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const bundle=readFileSync(process.argv[2],'utf8');
|
||||
const official=bundle.replace(/export \{ index as default \};\s*\/\/# sourceMappingURL=index.js.map\s*$/,
|
||||
'window.__official = index;');
|
||||
assert.notEqual(official,bundle);
|
||||
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const host=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
|
||||
assert.notEqual(host,source);
|
||||
const react=new Proxy({Fragment:'fragment',Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props,children}),
|
||||
createContext:value=>({Provider:()=>{},Consumer:()=>{},_currentValue:value}),
|
||||
useState:value=>[value,()=>{}],useReducer:(_,value)=>[value,()=>{}],
|
||||
useEffect(){},useRef:value=>({current:value}),useMemo:fn=>fn(),
|
||||
useCallback:fn=>fn,forwardRef:fn=>fn,memo:fn=>fn},
|
||||
{get:(target,key)=>target[key]??(()=>{})});
|
||||
const window={React:react,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
|
||||
const elements=new Map();
|
||||
const messages=[];
|
||||
const context=vm.createContext({window,navigator:{language:'en'},parent:{postMessage:message=>messages.push(message)},
|
||||
document:{getElementById:id=>{if(!elements.has(id))elements.set(id,{});return elements.get(id);}},
|
||||
addEventListener(){},setTimeout:()=>1,clearTimeout(){},console,URL,Blob});
|
||||
vm.runInContext(host,context);
|
||||
context.DFL=window.DFL;
|
||||
context.SP_REACT=window.SP_REACT;
|
||||
context.SP_JSX=window.SP_JSX;
|
||||
context.LocalizationManager=window.LocalizationManager;
|
||||
assert.equal(window.LocalizationManager.m_mapTokens.size,0);
|
||||
assert.equal(window.LocalizationManager.m_mapFallbackTokens.get('Settings_Bluetooth_Devices'),'Devices');
|
||||
vm.runInContext(official,context,{timeout:2000});
|
||||
const plugin=window.__official();
|
||||
assert.equal(plugin.content?.type?.name,'Content');
|
||||
const panel=plugin.content.type();
|
||||
assert.equal(panel.type,'div');
|
||||
const text=JSON.stringify(panel);
|
||||
assert.match(text,/Bluetooth/);
|
||||
assert.match(text,/Devices/); // Readable fallback when Steam tokens are absent.
|
||||
assert.doesNotMatch(text,/Settings_Bluetooth_Devices/);
|
||||
assert.equal(messages.filter(message=>message.type==='framey:call').length,0);
|
||||
@@ -1,72 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
|
||||
const parentSource=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
|
||||
const hostSource=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8')
|
||||
.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
|
||||
assert.match(hostSource,/framey:cache:init/);
|
||||
const values=new Map();
|
||||
const localStorage={getItem:key=>values.get(key)??null,setItem:(key,value)=>values.set(key,value)};
|
||||
const elements=new Map();
|
||||
const el=id=>{if(!elements.has(id))elements.set(id,{id,hidden:false,removeAttribute(){},scrollIntoView(){}});
|
||||
return elements.get(id);};
|
||||
const parentListeners=[];
|
||||
const parent=vm.createContext({document:{getElementById:el,createElement:()=>({})},
|
||||
location:{hash:'',pathname:'/'},history:{replaceState(){}},
|
||||
sessionStorage:{getItem:()=>null,setItem(){}},localStorage,
|
||||
window:{addEventListener:(type,fn)=>parentListeners.push({type,fn})},
|
||||
setTimeout(){},setInterval(){},fetch(){throw Error('No network expected');},console});
|
||||
vm.runInContext(parentSource,parent);
|
||||
vm.runInContext('deckyState={plugins:[{storeId:14}]};openFrameyPanel(14,"Steam Status")',parent);
|
||||
let session=vm.runInContext('frameySession',parent);
|
||||
const firstURL=new URL(el('frameyFrame').src,'http://127.0.0.1');
|
||||
const onParent=parentListeners.find(x=>x.type==='message').fn;
|
||||
|
||||
function iframe(){
|
||||
const listeners=[];const queued=[];let live=false;
|
||||
const frameWindow={location:{hash:`#${session}`},React:{Fragment:'fragment',Component:class {},createElement:()=>({}),isValidElement:()=>true},
|
||||
ReactDOM:{createRoot:()=>({render(){}})}};
|
||||
const proxy={postMessage:message=>listeners.find(x=>x.type==='message')?.fn({source:hostParent,data:message})};
|
||||
el('frameyFrame').contentWindow=proxy;
|
||||
const hostParent={postMessage:message=>live?onParent({source:proxy,data:message}):queued.push(message)};
|
||||
const context=vm.createContext({window:frameWindow,document:{getElementById:()=>({})},
|
||||
navigator:{language:'en'},parent:hostParent,DOMException,
|
||||
addEventListener:(type,fn)=>listeners.push({type,fn}),setTimeout(){},clearTimeout(){}});
|
||||
vm.runInContext(hostSource,context);
|
||||
for(const message of queued)onParent({source:proxy,data:message});
|
||||
live=true;
|
||||
const send=message=>onParent({source:proxy,data:message});
|
||||
return {storage:frameWindow.localStorage,steamStorage:frameWindow.SteamClient.Storage,send};
|
||||
}
|
||||
let first=iframe();
|
||||
assert.equal(first.storage.getItem('status-cache'),null);
|
||||
first.storage.setItem('status-cache','{"online":true}');
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
|
||||
first.storage.setItem('temporary','discard');
|
||||
first.storage.removeItem('temporary');
|
||||
await first.steamStorage.SetObject('playtime-settings',{count:3});
|
||||
const oldSession=session;
|
||||
vm.runInContext('openFrameyPanel(14,"Steam Status")',parent);
|
||||
session=vm.runInContext('frameySession',parent);
|
||||
assert.notEqual(session,oldSession);
|
||||
const reopenedURL=new URL(el('frameyFrame').src,'http://127.0.0.1');
|
||||
// Fragment-only navigation preserves the old document, host and cache session.
|
||||
assert.notEqual(reopenedURL.pathname+reopenedURL.search,firstURL.pathname+firstURL.search);
|
||||
assert.equal(reopenedURL.searchParams.get('session'),session);
|
||||
assert.equal(reopenedURL.hash.slice(1),session);
|
||||
let reopened=iframe();
|
||||
assert.equal(reopened.storage.getItem('status-cache'),'{"online":true}');
|
||||
assert.equal(reopened.storage.length,2);
|
||||
assert.equal(reopened.storage.key(0),'status-cache');
|
||||
assert.equal(await reopened.steamStorage.GetJSON('playtime-settings'),'{"count":3}');
|
||||
assert.throws(()=>reopened.storage.setItem('huge','x'.repeat(65537)),/Framey plugin cache is full/);
|
||||
// A malformed or over-limit message from a plugin must not poison saved data.
|
||||
const savedCache=values.get('framey-plugin-cache-v1:14');
|
||||
reopened.send({type:'framey:cache:save',session,entries:{huge:'x'.repeat(65537)}});
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),savedCache);
|
||||
reopened.send({type:'framey:cache:save',session:oldSession,entries:{poison:'yes'}});
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),savedCache);
|
||||
reopened.storage.clear();
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{}');
|
||||
@@ -1,49 +0,0 @@
|
||||
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 source=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
|
||||
const elements=new Map(),listeners=[],requests=[],replies=[];
|
||||
const el=id=>{if(!elements.has(id))elements.set(id,{removeAttribute(){},scrollIntoView(){}});return elements.get(id);};
|
||||
const frame={postMessage:data=>replies.push(data)};
|
||||
el('frameyFrame').contentWindow=frame;
|
||||
const context=vm.createContext({document:{getElementById:el,createElement:()=>({})},
|
||||
location:{hash:'',pathname:'/'},history:{replaceState(){}},
|
||||
sessionStorage:{getItem:()=>null,setItem(){}},localStorage:{getItem:()=>null,setItem(){}},
|
||||
window:{addEventListener:(type,fn)=>listeners.push({type,fn})},
|
||||
setTimeout(){},setInterval(){},console,
|
||||
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
|
||||
vm.runInContext(source,context);
|
||||
vm.runInContext('deckyState={plugins:[{storeId:96}]};openFrameyPanel(96,"Timer")',context);
|
||||
let session=vm.runInContext('frameySession',context);
|
||||
const onMessage=listeners.find(x=>x.type==='message').fn;
|
||||
const send=(data,origin=frame)=>onMessage({source:origin,data});
|
||||
const subscription={type:'framey:events',id:1,session,args:[['tick'],0]};
|
||||
await send({...subscription,session:'old'});
|
||||
await send(subscription,{});
|
||||
await send({...subscription,args:[['bad event'],0]});
|
||||
assert.equal(requests.length,0);
|
||||
const first=send(subscription);
|
||||
assert.equal(requests.length,1);
|
||||
assert.equal(requests[0].path,'/api/framey/events');
|
||||
assert.equal(requests[0].body.id,96);
|
||||
assert.equal(requests[0].body.session,session);
|
||||
// WindowProxy survives navigation: stale same-plugin replies need a session check.
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
const secondSession=vm.runInContext('frameySession',context);
|
||||
assert.notEqual(secondSession,session);
|
||||
assert.equal(requests[1].body.events.length,0); // Closes only the original subscription.
|
||||
assert.equal(requests[1].body.session,session);
|
||||
requests[0].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[42]}],cursor:1})});
|
||||
await first;assert.equal(replies.length,0);
|
||||
await send({type:'framey:call',id:2,session,method:'cancel_timer',args:[]});
|
||||
assert.equal(requests.length,2); // Old document cannot call a newly opened plugin.
|
||||
session=secondSession;
|
||||
const second=send({...subscription,id:3,session});
|
||||
requests[2].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[7]}],cursor:2})});
|
||||
await second;
|
||||
assert.equal(replies.length,1);assert.equal(replies[0].result.events[0].args[0],7);
|
||||
el('frameyClose').onclick();
|
||||
assert.equal(requests[3].body.session,session);assert.equal(requests[3].body.events.length,0);
|
||||
for(const request of requests.slice(1))request.resolve({ok:true,json:async()=>({events:[],cursor:0})});
|
||||
@@ -1,40 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source=readFileSync(new URL('../../ui/framey-events.js',import.meta.url),'utf8');
|
||||
const handlers=new Set(),timers=new Map();let now=1000,nextTimer=0;
|
||||
const backend={addEventListener(name,listener){assert.equal(name,'loader/plugin_event');handlers.add(listener);},
|
||||
removeEventListener(name,listener){assert.equal(name,'loader/plugin_event');handlers.delete(listener);}};
|
||||
const window={DeckyBackend:backend};
|
||||
const context=vm.createContext({window,Date:{now:()=>now},
|
||||
setInterval:fn=>{timers.set(++nextTimer,fn);return nextTimer;},clearInterval:id=>timers.delete(id)});
|
||||
const poll=(session='one',name='Simple Timer',events=['tick'],cursor=0)=>
|
||||
JSON.parse(JSON.stringify(vm.runInContext(source,context)({session,name,events,cursor})));
|
||||
const emit=(plugin,event,...args)=>{for(const handler of handlers)handler({plugin,event,args});};
|
||||
assert.deepEqual(poll(),{events:[],cursor:0});
|
||||
assert.equal(handlers.size,1);
|
||||
emit('Other plugin','tick',10);emit('Simple Timer','other',99);emit('Simple Timer','tick',42,'seconds');
|
||||
assert.deepEqual(poll().events,[{sequence:1,event:'tick',args:[42,'seconds']}]);
|
||||
assert.equal(handlers.size,1); // Polling never duplicates the backend subscription.
|
||||
assert.deepEqual(poll('one','Simple Timer',['tick'],1).events,[]);
|
||||
assert.equal(poll().events.length,1); // Retry a lost HTTP response without losing its event.
|
||||
assert.throws(()=>poll('one','Another plugin'),/belongs to another plugin/);
|
||||
poll('two','Another plugin');emit('Another plugin','tick',123);
|
||||
assert.deepEqual(poll('two','Another plugin').events[0].args,[123]);
|
||||
assert.equal(poll().events.length,1);
|
||||
emit('Simple Timer','tick','x'.repeat(8193));assert.equal(poll().events.length,1);
|
||||
for(let index=0;index<100;index++)emit('Simple Timer','tick',index);
|
||||
assert.equal(poll().events.length,64);
|
||||
assert.equal(poll().events.at(-1).args[0],99);
|
||||
for(let index=0;index<20;index++)emit('Simple Timer','tick','x'.repeat(8000));
|
||||
assert.ok(JSON.stringify(poll()).length<50*1024);
|
||||
poll('one','Simple Timer',[]);assert.equal(handlers.size,1);
|
||||
now+=16000;for(const timer of [...timers.values()])timer();
|
||||
assert.equal(handlers.size,0);assert.equal(timers.size,0);
|
||||
poll();assert.equal(handlers.size,1);
|
||||
// A Decky frontend reconnect must register against the replacement backend.
|
||||
let replaced=0;
|
||||
window.DeckyBackend={...backend,addEventListener(name,fn){replaced++;backend.addEventListener(name,fn);}};
|
||||
poll();assert.equal(replaced,1);assert.equal(handlers.size,1);
|
||||
poll('one','Simple Timer',[]);assert.equal(handlers.size,0);
|
||||
@@ -1,57 +0,0 @@
|
||||
// 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();
|
||||
@@ -1,44 +0,0 @@
|
||||
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 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,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});
|
||||
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);
|
||||
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]);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),true);
|
||||
assert.equal(window.DFL.useParams().topic,'saved-setting');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/library/home'),false);
|
||||
plugin.onDismount();
|
||||
assert.equal(window.DFL.useParams().topic,undefined);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),false);
|
||||
@@ -1,133 +0,0 @@
|
||||
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,body) => {
|
||||
if(path==='/api/decky/plugin'){assert.equal(body.sha256,'a'.repeat(64));return {job:'job-1'};}
|
||||
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
|
||||
if(path==='/api/decky')return {running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
|
||||
assert.equal(el('deckyJobStatus').textContent,'Installed Plugin 2');
|
||||
assert.equal(el('deckyJobProgress').value,100);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// A stopped Decky service must still offer recovery by removing its owned plugin.
|
||||
ctx.mockApi=async path => path==='/api/decky' ?
|
||||
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
|
||||
Promise.reject(Error(`unexpected ${path}`));
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
|
||||
el('deckySearch').value='';el('deckyStoreFilter').value='all';
|
||||
vm.runInContext('showDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
|
||||
ctx.mockApi=async (path,body) => {
|
||||
if(path==='/api/decky/plugin'){assert.equal(body.sha256,'b'.repeat(64));return {job:'job-2'};}
|
||||
if(path.startsWith('/api/job'))return {done:true,error:'archive digest mismatch'};
|
||||
if(path==='/api/decky')return {running:true,plugins:[]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"b".repeat(64)})',ctx);
|
||||
assert.match(el('deckyJobStatus').textContent,/Error: archive digest mismatch/);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// A failed refresh must remove old install buttons. A late older response
|
||||
// must not replace the newer catalog the wearer just requested.
|
||||
ctx.mockApi=async path => {assert.equal(path,'/api/decky/store');throw Error('offline');};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('loadDeckyStore()',ctx);
|
||||
assert.match(el('deckyStoreSummary').textContent,/Store unavailable: offline/);
|
||||
assert.equal(el('deckyStoreList').children.length,0);
|
||||
let finishOld, finishNew;
|
||||
ctx.mockApi=path => {assert.equal(path,'/api/decky/store');
|
||||
return new Promise(resolve=>{if(!finishOld)finishOld=resolve;else finishNew=resolve;});};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
const old=vm.runInContext('loadDeckyStore()',ctx);
|
||||
const current=vm.runInContext('loadDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children.length,0);
|
||||
assert.equal(el('deckyStoreSummary').textContent,'Loading official Decky store…');
|
||||
finishNew({plugins:[{id:98,name:'Current',version:'2.0',description:'Latest'}]});
|
||||
await current;
|
||||
finishOld({plugins:[{id:97,name:'Stale',version:'1.0',description:'Older'}]});
|
||||
await old;
|
||||
assert.match(el('deckyStoreList').children[0].children[0].textContent,/Current/);
|
||||
assert.doesNotMatch(el('deckyStoreList').children[0].children[0].textContent,/Stale/);
|
||||
|
||||
// A saved job does not survive a server restart. Clear it instead of offering
|
||||
// Check result forever for an ID that the new server cannot know.
|
||||
session.set('frameyPluginJob',JSON.stringify({job:'old-job',action:'install',name:'Plugin 2'}));
|
||||
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);
|
||||
|
||||
// Known hardware blockers remain visible, cannot install, and keep their reason in the opened panel.
|
||||
ctx.hardware={id:900,name:'Hardware only',version:'1.0',description:'Fan control',root:false,
|
||||
compatibility:{status:'na',reason:'Not supported on Frame: Deck fan hardware.',verification:['static archive','local browser'],blockInstall:true}};
|
||||
el('deckySearch').value='';el('deckyStoreFilter').value='all';
|
||||
vm.runInContext('deckyStore=[hardware];deckyState={running:true,plugins:[{...hardware,storeId:900}]};showDeckyStore()',ctx);
|
||||
const hardwareRow=el('deckyStoreList').children[0];
|
||||
assert.equal(hardwareRow.children[1].disabled,true);
|
||||
assert.match(hardwareRow.children[0].children[0].textContent,/N\/A: Not supported on Frame/);
|
||||
el('deckyStoreFilter').value='compatible';vm.runInContext('showDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children.length,0);
|
||||
vm.runInContext('openFrameyPanel(900,"Hardware only")',ctx);
|
||||
assert.match(el('frameyCompatibility').textContent,/Deck fan hardware/);
|
||||
assert.match(el('frameyCompatibility').textContent,/local browser/);
|
||||
+2
-227
@@ -7,13 +7,11 @@ import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import errno
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_autosync as fa # noqa: E402
|
||||
@@ -37,15 +35,13 @@ class FakeFrame:
|
||||
self.device, self.busy, self.down = "frame-a", False, False
|
||||
self.save_error = self.export_error = None
|
||||
|
||||
def list_shots(self, device):
|
||||
assert device == self.device
|
||||
def list_shots(self):
|
||||
self.calls.append("list_shots")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
return {"shots": list(self.shots)}
|
||||
|
||||
def list_clips(self, device):
|
||||
assert device == self.device
|
||||
def list_clips(self):
|
||||
self.calls.append("list_clips")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
@@ -267,134 +263,6 @@ class AutoSyncTest(unittest.TestCase):
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
|
||||
self.assertIn("ffmpeg couldn't join", self.sync.state()["error"] or "")
|
||||
self.assertIn("Resume", self.sync.state()["error"] or "")
|
||||
|
||||
def test_explicit_retry_recovers_without_recopying_successful_items(self):
|
||||
for kind in ("shots", "clips"):
|
||||
for action in ("resume", "copy_all"):
|
||||
with self.subTest(kind=kind, action=action):
|
||||
self.frame = FakeFrame()
|
||||
f = self.frame
|
||||
sync = self.make()
|
||||
sync.update({"screenshots": True, "clips": True})
|
||||
f.shots, f.clips = [shot(1, NOW + 5)], [clip(1, NOW + 5)]
|
||||
error_attr = "save_error" if kind == "shots" else "export_error"
|
||||
copy_call = "save_shots" if kind == "shots" else "export_clip"
|
||||
setattr(f, error_attr, RuntimeError("source not ready"))
|
||||
for _ in range(fa.TRIES + 2):
|
||||
sync.tick()
|
||||
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
|
||||
self.assertIn("source not ready", sync.state()["error"] or "")
|
||||
setattr(f, error_attr, None)
|
||||
sync.tick() # recovery alone must not create unlimited automatic retries
|
||||
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
|
||||
getattr(sync, action)()
|
||||
sync.tick()
|
||||
self.assertEqual(f.saved, [("frame-a", [shot(1, 0)["id"]])])
|
||||
self.assertEqual(f.exported, [("frame-a", clip(1, 0)["id"])])
|
||||
self.assertIsNone(sync.state()["error"])
|
||||
# Resume preserves the per-headset ledger even if a local file was deleted.
|
||||
for item in f.shots + f.clips:
|
||||
item["saved"] = False
|
||||
sync.resume()
|
||||
sync.tick()
|
||||
self.assertEqual((len(f.saved), len(f.exported)), (1, 1))
|
||||
self.sync.path.unlink(missing_ok=True)
|
||||
|
||||
def test_explicit_retry_is_still_bounded(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("bad source")
|
||||
for action in ("resume", "copy_all"):
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
before = self.frame.calls.count("export_clip")
|
||||
getattr(self.sync, action)()
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip") - before, fa.TRIES)
|
||||
self.assertIn("bad source", self.sync.state()["error"] or "")
|
||||
|
||||
def test_failed_item_saved_by_hand_clears_error(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("bad source")
|
||||
for _ in range(fa.TRIES + 1):
|
||||
self.sync.tick()
|
||||
self.frame.clips[0]["saved"] = True
|
||||
self.sync.tick()
|
||||
self.assertIsNone(self.sync.state()["error"])
|
||||
self.assertIn(clip(1, 0)["id"], self.sync._synced("frame-a", "clips"))
|
||||
|
||||
def test_switch_after_capture_does_not_list_the_new_headset(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
def switched():
|
||||
self.frame.device = "frame-b"
|
||||
return False
|
||||
self.sync.busy = switched # tick has already captured frame-a
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.frame.calls, [])
|
||||
|
||||
def test_list_callbacks_receive_the_captured_device(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.sync.list_shots = mock.Mock(return_value={"shots": []})
|
||||
self.sync.list_clips = mock.Mock(return_value={"clips": []})
|
||||
self.sync.tick()
|
||||
self.sync.list_shots.assert_called_once_with("frame-a")
|
||||
self.sync.list_clips.assert_called_once_with("frame-a")
|
||||
|
||||
def test_switch_during_listing_does_not_mark_or_copy_its_results(self):
|
||||
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
|
||||
with self.subTest(kind=kind):
|
||||
self.frame.device = "frame-a"
|
||||
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
def listing(*_args):
|
||||
self.frame.device = "frame-b"
|
||||
return {kind: [dict(item, saved=True), dict(item, id="uncopied", saved=False)]}
|
||||
setattr(self.sync, "list_" + kind, listing)
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.sync._synced("frame-a", kind), set())
|
||||
self.assertEqual(self.sync._synced("frame-b", kind), set())
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_switch_between_kinds_does_not_read_clips_or_mark_transfer(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.clips = [clip(1, NOW + 5, saved=True)]
|
||||
save = self.sync.save_shots
|
||||
def then_switch(device, ids):
|
||||
save(device, ids)
|
||||
self.frame.device = "frame-b"
|
||||
self.sync.save_shots = then_switch
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertNotIn("list_clips", self.frame.calls)
|
||||
self.assertEqual(self.sync._synced("frame-a", "clips"), set())
|
||||
self.assertEqual(self.sync._synced("frame-a", "shots"), set())
|
||||
|
||||
def test_switch_after_bookkeeping_stops_before_next_kind(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5, saved=True)]
|
||||
mark = self.sync._mark
|
||||
def then_switch(device, kind, ids):
|
||||
mark(device, kind, ids)
|
||||
self.frame.device = "frame-b"
|
||||
self.sync._mark = then_switch
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertNotIn("list_clips", self.frame.calls)
|
||||
|
||||
def test_switch_before_transfer_does_not_copy(self):
|
||||
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
|
||||
with self.subTest(kind=kind):
|
||||
self.frame.device = "frame-a"
|
||||
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
setattr(self.frame, kind, [item])
|
||||
def switched_space(_folder):
|
||||
self.frame.device = "frame-b"
|
||||
return 100 * GB
|
||||
self.sync.space = switched_space
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_worker_stops(self):
|
||||
stop = threading.Event()
|
||||
@@ -407,75 +275,6 @@ class AutoSyncTest(unittest.TestCase):
|
||||
|
||||
|
||||
class Server(unittest.TestCase):
|
||||
def test_switch_at_list_callback_entry_does_not_read_or_mark_the_new_headset(self):
|
||||
import server
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
for kind in ("shots", "clips"):
|
||||
with self.subTest(kind=kind):
|
||||
active = {"id": "frame-a"}
|
||||
link = mock.Mock()
|
||||
link.active_device.side_effect = lambda: dict(active)
|
||||
listing = mock.Mock(return_value={kind: [
|
||||
shot(1, NOW + 5, saved=True) if kind == "shots" else clip(1, NOW + 5, saved=True)]})
|
||||
def switch_then_list(device):
|
||||
active["id"] = "frame-b"
|
||||
return server._sync_list(listing)(device)
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
|
||||
sync = fa.AutoSync(switch_then_list, mock.Mock(), switch_then_list, mock.Mock(),
|
||||
lambda: active["id"], lambda: False,
|
||||
path=Path(folder) / (kind + ".json"), clock=lambda: NOW)
|
||||
sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
sync.tick()
|
||||
listing.assert_not_called()
|
||||
sync.save_shots.assert_not_called()
|
||||
sync.export_clip.assert_not_called()
|
||||
self.assertEqual(sync._synced("frame-a", kind), set())
|
||||
self.assertEqual(sync._synced("frame-b", kind), set())
|
||||
self.assertIn("switched headsets", sync.state()["error"])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_listing_rejects_switch_before_reserving_work(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-b"}
|
||||
listing = mock.Mock(return_value={"shots": [shot(1, NOW, saved=True)]})
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
|
||||
with self.assertRaisesRegex(server.Failure, "switched headsets"):
|
||||
server._sync_list(listing)("frame-a")
|
||||
listing.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_listing_reserves_work_on_the_captured_device(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-a", "name": "Frame A"}
|
||||
def listing():
|
||||
self.assertGreater(server._work[0], 0)
|
||||
with self.assertRaises(server.Failure):
|
||||
server.devices_post({"action": "use", "id": "frame-b"})
|
||||
return {"shots": []}
|
||||
with mock.patch.object(server, "LINK", link), \
|
||||
mock.patch.object(server, "PRIVATE", False), mock.patch.object(server, "_work", [0]):
|
||||
self.assertEqual(server._sync_list(listing)("frame-a"), {"shots": []})
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_copy_rejects_switch_after_listing(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-a"}
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]), \
|
||||
mock.patch.object(server, "save_shots") as save, \
|
||||
mock.patch.object(server.frame_clips, "export_clip") as export:
|
||||
server._sync_list(lambda: {"shots": []})("frame-a")
|
||||
link.active_device.return_value = {"id": "frame-b"}
|
||||
for copy in (lambda: server._sync_shots("frame-a", [shot(1, NOW)["id"]]),
|
||||
lambda: server._sync_clip("frame-a", clip(1, NOW)["id"])):
|
||||
with self.assertRaisesRegex(server.Failure, "switched headsets"):
|
||||
copy()
|
||||
save.assert_not_called()
|
||||
export.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_route_and_settings(self):
|
||||
import server
|
||||
self.assertIn("/api/autosync", server.POST)
|
||||
@@ -488,29 +287,5 @@ class Server(unittest.TestCase):
|
||||
server.auto_sync({"action": "now"}) # nothing connected
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node exercises the Settings renderer")
|
||||
class Settings(unittest.TestCase):
|
||||
def test_resume_is_available_after_a_copy_error(self):
|
||||
page = (Path(__file__).resolve().parents[1] / "ui/index.html").read_text(encoding="utf-8")
|
||||
script = page.split("const autoSync =", 1)[1].split("async function loadAutoSync", 1)[0]
|
||||
harness = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const nodes = {};
|
||||
const $ = id => nodes[id] ||= {};
|
||||
const HOST = {mobile: false};
|
||||
const esc = value => value;
|
||||
'''
|
||||
checks = r'''
|
||||
for (const state of [{error: 'Could not copy clip'}, {paused: 'Disk full'}, {}]) {
|
||||
renderAutoSync(state);
|
||||
assert.equal($('syncResume').hidden, !(state.error || state.paused));
|
||||
if (state.error) assert.match($('syncStatus').textContent, /Could not copy clip/);
|
||||
}
|
||||
'''
|
||||
result = subprocess.run(["node", "-e", harness + "const autoSync =" + script + checks],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Clip destinations through the server and automatic copying, without a headset."""
|
||||
import sandbox # noqa: F401
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_autosync
|
||||
import frame_clips
|
||||
import server
|
||||
|
||||
|
||||
IDS = ["1/clips/clip_620/video/bg", "2/clips/clip_620/video/bg",
|
||||
"1/clips/clip_620/video/other", "1/clips/other/video/bg"]
|
||||
NOW = 1800000000
|
||||
|
||||
|
||||
class ClipCopyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = Path(tempfile.mkdtemp(prefix="frame-clip-copy-"))
|
||||
self.addCleanup(shutil.rmtree, self.folder, ignore_errors=True)
|
||||
self.device = "frame-a"
|
||||
self.link = mock.Mock()
|
||||
self.link.active_device.side_effect = lambda: {"id": self.device, "name": self.device}
|
||||
self.link.snapshot.return_value = {"phase": "connected"}
|
||||
self.exports = []
|
||||
self.payload = None
|
||||
for name, value in (("LINK", self.link), ("LOCAL", False)):
|
||||
patch = mock.patch.object(server, name, value)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
patch = mock.patch.object(frame_clips, "VIDEOS_DIR", self.folder)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
for name, fn in (("_clips_frame", self.frame), ("fetch_file", self.fetch),
|
||||
("ssh", lambda *a, **k: "")):
|
||||
patch = mock.patch.object(server, name, side_effect=fn)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
(self.folder / "clip_620.mp4").write_bytes(b"legacy export")
|
||||
|
||||
def frame(self, args, timeout):
|
||||
if args == ["list"]:
|
||||
return {"clips": [{"id": cid, "kind": "clip", "time": NOW + 5, "idle": 60} for cid in IDS]}
|
||||
self.exports.append((self.device, args[1]))
|
||||
self.payload = f"{self.device}: {args[1]}".encode()
|
||||
return {"path": f"{frame_clips.WORK}/export-0123456789abcdef/clip_620.mp4",
|
||||
"size": len(self.payload)}
|
||||
|
||||
def fetch(self, remote, target):
|
||||
target.write_bytes(self.payload)
|
||||
|
||||
def test_manual_export_lists_only_matching_identity_and_keeps_job_shape(self):
|
||||
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()) as job:
|
||||
for device in ("frame-a", "frame-b"):
|
||||
self.device = device
|
||||
self.assertFalse(any(c["saved"] for c in server.list_clips()["clips"]))
|
||||
result = server.export_clip({"id": IDS[0]})
|
||||
self.assertEqual(set(result), {"message", "file"})
|
||||
self.assertEqual(Path(result["file"]).read_bytes(), f"{device}: {IDS[0]}".encode())
|
||||
self.assertEqual([c["saved"] for c in server.list_clips()["clips"]], [True, False, False, False])
|
||||
self.assertEqual(job.call_args.args[0], "Export clip_620.mp4")
|
||||
first = frame_clips.clip_local_path(IDS[0], "frame-a", self.folder)
|
||||
self.assertEqual(first.read_bytes(), f"frame-a: {IDS[0]}".encode())
|
||||
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
|
||||
|
||||
def test_auto_sync_uses_same_identity_as_manual_exports(self):
|
||||
with mock.patch.object(server, "start_job", side_effect=lambda label, work: work()):
|
||||
server.export_clip({"id": IDS[0]})
|
||||
sync = frame_autosync.AutoSync(
|
||||
server._sync_list(server.list_shots), server._sync_shots,
|
||||
server._sync_list(server.list_clips), server._sync_clip,
|
||||
server._sync_device, lambda: False, shots_folder=self.folder / "shots",
|
||||
clips_folder=self.folder, path=self.folder / "auto-sync.json", clock=lambda: NOW,
|
||||
space=lambda folder: 100 * 1024**3)
|
||||
sync.update({"clips": True})
|
||||
sync.tick()
|
||||
self.assertEqual(self.exports, [("frame-a", cid) for cid in IDS])
|
||||
self.device = "frame-b"
|
||||
sync.tick()
|
||||
self.assertEqual(self.exports, [(device, cid) for device in ("frame-a", "frame-b") for cid in IDS])
|
||||
sync.tick()
|
||||
self.assertEqual(len(self.exports), 8)
|
||||
for device in ("frame-a", "frame-b"):
|
||||
for cid in IDS:
|
||||
target = frame_clips.clip_local_path(cid, device, self.folder)
|
||||
self.assertEqual(target.read_bytes(), f"{device}: {cid}".encode())
|
||||
self.assertEqual((self.folder / "clip_620.mp4").read_bytes(), b"legacy export")
|
||||
|
||||
def test_delayed_job_refuses_a_different_headset(self):
|
||||
with mock.patch.object(server, "start_job", return_value={"job": "queued"}) as job:
|
||||
self.assertEqual(server.export_clip({"id": IDS[0]}), {"job": "queued"})
|
||||
self.device = "frame-b"
|
||||
with self.assertRaises(server.Failure) as error:
|
||||
job.call_args.args[1]()
|
||||
self.assertEqual(error.exception.status, 409)
|
||||
self.assertEqual(self.exports, [])
|
||||
|
||||
def test_local_and_headset_modes_reject_steam_clip_exports(self):
|
||||
for headset in (False, True):
|
||||
with self.subTest(headset=headset), mock.patch.object(server, "LOCAL", True), \
|
||||
mock.patch.object(server, "HEADSET_RUNTIME", headset):
|
||||
for action in (server.list_clips, lambda: server.export_clip({"id": IDS[0]}),
|
||||
lambda: server.auto_sync({"action": "now"})):
|
||||
with self.assertRaises(server.Failure) as error:
|
||||
action()
|
||||
self.assertEqual(error.exception.status, 400)
|
||||
self.assertEqual(self.exports, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+8
-65
@@ -71,17 +71,6 @@ class Ids(unittest.TestCase):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
clips.remove_command(bad)
|
||||
|
||||
def test_local_path_keeps_validated_identity_and_hashes_device_names(self):
|
||||
folder = Path("exports")
|
||||
target = clips.clip_local_path("123/clips/clip/video/session", "../../device:name", folder)
|
||||
self.assertRegex(target.parts[1], r"^headset-[0-9a-f]{12}$")
|
||||
self.assertEqual(target.parts[2:], ("123", "clips", "clip", "video", "session", "clip.mp4"))
|
||||
for bad in (None, "", 42):
|
||||
with self.subTest(device=bad), self.assertRaises(ValueError):
|
||||
clips.clip_local_path("123/video/bg", bad, folder)
|
||||
with self.assertRaises(ValueError):
|
||||
clips.clip_local_path("../clips/clip", "frame-a", folder)
|
||||
|
||||
def test_recording_names_checked(self):
|
||||
for fn in (clips.finish_command, clips.cleanup_command, lambda n: clips.record_command(n, True)):
|
||||
for bad in ("rec-1", "rec-20261020-201950; rm -rf ~", "../rec-20261020-201950", "*"):
|
||||
@@ -167,10 +156,10 @@ class Sessions(unittest.TestCase):
|
||||
|
||||
def test_listing_marks_what_is_already_here(self):
|
||||
here = self.dir / "here"
|
||||
write(clips.clip_local_path("1/clips/clip_620_20261020_202134/video/bg", "frame-a", here))
|
||||
write(here / "clip_620_20261020_202134.mp4")
|
||||
frame = lambda args, timeout: {"clips": [{"id": "1/clips/clip_620_20261020_202134/video/bg"},
|
||||
{"id": "1/video/bg_620_20261020_201950"}]}
|
||||
got = clips.list_clips(frame, folder=here, device_id="frame-a")
|
||||
got = clips.list_clips(frame, folder=here)
|
||||
self.assertEqual([c["saved"] for c in got["clips"]], [True, False])
|
||||
self.assertEqual(got["folder"], str(here))
|
||||
|
||||
@@ -272,16 +261,12 @@ class ComputerSide(unittest.TestCase):
|
||||
frame = lambda args, timeout: calls.append(("frame", args)) or {"path": path, "size": 3}
|
||||
run = lambda cmd, timeout: calls.append(("run", cmd))
|
||||
fetch = lambda remote, local: calls.append(("fetch", remote)) or Path(local).write_bytes(b"mp4")
|
||||
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir, device_id="frame-a")
|
||||
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir)
|
||||
self.assertEqual(got["message"], "Saved clip_620.mp4 to ~/Videos/SteamFrame")
|
||||
target = clips.clip_local_path("1/clips/clip_620/video/bg", "frame-a", self.dir)
|
||||
self.assertEqual(got["file"], str(target))
|
||||
self.assertEqual(set(got), {"message", "file"})
|
||||
self.assertEqual(target.read_bytes(), b"mp4")
|
||||
self.assertEqual((self.dir / "clip_620.mp4").read_bytes(), b"mp4")
|
||||
self.assertEqual([c[0] for c in calls], ["frame", "fetch", "run"])
|
||||
self.assertEqual(calls[2][1], clips.remove_command(path))
|
||||
self.assertEqual(list(self.dir.rglob("*.mp4")), [target])
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
self.assertEqual([p.name for p in self.dir.iterdir()], ["clip_620.mp4"])
|
||||
|
||||
def test_failed_copy_leaves_no_partial_file_and_still_cleans_up(self):
|
||||
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
@@ -292,62 +277,20 @@ class ComputerSide(unittest.TestCase):
|
||||
raise RuntimeError("connection lost")
|
||||
with self.assertRaisesRegex(RuntimeError, "connection lost"):
|
||||
clips.export_clip("1/clips/clip_620/video/bg", lambda a, t: {"path": path, "size": 4},
|
||||
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir, device_id="frame-a")
|
||||
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir)
|
||||
self.assertEqual(list(self.dir.iterdir()), [])
|
||||
self.assertEqual(ran, [clips.remove_command(path)])
|
||||
|
||||
def test_same_names_keep_each_account_headset_kind_and_nested_session(self):
|
||||
first = "1/clips/clip_620/video/bg"
|
||||
ids = [first, "2/clips/clip_620/video/bg", "1/clips/clip_620/video/other",
|
||||
"1/clips/clip_620/other/bg", "1/video/clip_620"]
|
||||
legacy = write(self.dir / "clip_620.mp4", b"legacy export")
|
||||
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
frame = lambda args, timeout: {"clips": [{"id": cid} for cid in ids]}
|
||||
stored = {}
|
||||
for device in ("frame-a", "frame-b"):
|
||||
self.assertFalse(any(c["saved"] for c in clips.list_clips(
|
||||
frame, folder=self.dir, device_id=device)["clips"]))
|
||||
for index, cid in enumerate(ids):
|
||||
payload = f"{device} session {index}".encode()
|
||||
result = clips.export_clip(cid, lambda a, t: {"path": remote, "size": len(payload)},
|
||||
lambda c, t: None, lambda r, p: p.write_bytes(payload),
|
||||
folder=self.dir, device_id=device)
|
||||
stored[Path(result["file"])] = payload
|
||||
listing = clips.list_clips(frame, folder=self.dir, device_id=device)
|
||||
self.assertEqual([c["saved"] for c in listing["clips"]],
|
||||
[n <= index for n in range(len(ids))])
|
||||
self.assertEqual(listing["folder"], str(self.dir))
|
||||
self.assertEqual(len(stored), len(ids) * 2)
|
||||
for path, payload in stored.items():
|
||||
self.assertEqual(path.read_bytes(), payload)
|
||||
self.assertEqual(legacy.read_bytes(), b"legacy export")
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
|
||||
def test_failed_reexport_preserves_completed_export(self):
|
||||
cid = "1/clips/clip_620/video/bg"
|
||||
target = write(clips.clip_local_path(cid, "frame-a", self.dir), b"completed")
|
||||
remote = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
|
||||
|
||||
def fetch(remote, local):
|
||||
local.write_bytes(b"partial")
|
||||
raise RuntimeError("connection lost")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "connection lost"):
|
||||
clips.export_clip(cid, lambda a, t: {"path": remote}, lambda c, t: None, fetch,
|
||||
folder=self.dir, device_id="frame-a")
|
||||
self.assertEqual(target.read_bytes(), b"completed")
|
||||
self.assertFalse(list(self.dir.glob(".incoming-*")))
|
||||
|
||||
def test_bad_id_never_reaches_the_frame(self):
|
||||
never = mock.Mock(side_effect=AssertionError("reached the Frame"))
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir, device_id="frame-a")
|
||||
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir)
|
||||
|
||||
def test_unexpected_path_from_the_frame_refused(self):
|
||||
never = mock.Mock(side_effect=AssertionError("deleted or copied"))
|
||||
with self.assertRaises(ValueError):
|
||||
clips.export_clip("1/video/bg", lambda a, t: {"path": ".ssh/id_ed25519", "size": 1}, never, never,
|
||||
folder=self.dir, device_id="frame-a")
|
||||
folder=self.dir)
|
||||
|
||||
|
||||
class Commands(unittest.TestCase):
|
||||
|
||||
@@ -11,12 +11,10 @@ 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):
|
||||
@@ -32,23 +30,14 @@ def archive(flags=(), extra=None):
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def test_catalog_selects_valid_visible_latest_versions(self):
|
||||
records = [{'id': 42, 'name': 'Example', 'author': 'Panel Author', 'visible': True, 'versions':
|
||||
records = [{'id': 42, 'name': 'Example', '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'], 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)])
|
||||
self.assertEqual([p['id'] for p in frame_decky_store.catalog()], [42])
|
||||
|
||||
def test_accepts_hashed_non_root_archive(self):
|
||||
data, digest = archive()
|
||||
@@ -64,22 +53,6 @@ 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):
|
||||
@@ -138,98 +111,17 @@ 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_reviewed_archive_changed_before_install_does_not_download(self):
|
||||
state = {'managed': True, 'running': True, 'plugins': []}
|
||||
catalog = [{'id': 42, 'name': 'Example', 'root': False,
|
||||
'sha256': 'b' * 64, 'version': '2.0'}]
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
|
||||
patch.object(frame_decky_store.urllib.request, 'urlopen',
|
||||
side_effect=AssertionError('No download expected')):
|
||||
with self.assertRaisesRegex(ValueError, 'version changed'):
|
||||
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
|
||||
'install', 42, reviewed_hash='a' * 64)
|
||||
|
||||
def test_stopped_managed_service_allows_removing_owned_plugin(self):
|
||||
state = {'managed': True, 'running': False, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
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, lambda stage, percent: progress.append((stage, percent)))
|
||||
frame_decky_store.change(ssh, 'remove', 42)
|
||||
network.assert_not_called()
|
||||
self.assertEqual(progress, [('Checking installed plugins', 5), ('Removing plugin on Frame', 80)])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""A compatibility claim must never transfer to a different published archive."""
|
||||
import sandbox # noqa: F401
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'ui'))
|
||||
import framey_compatibility as compatibility
|
||||
import frame_decky_store
|
||||
import frame_decky
|
||||
|
||||
|
||||
class Compatibility(unittest.TestCase):
|
||||
def test_table_matches_store_data_and_complete_browser_and_fake_evidence(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
subprocess.run([sys.executable, str(root/'scripts/framey-compatibility-doc.py'), '--check'], check=True)
|
||||
records = json.loads(compatibility.DATA.read_text())['plugins']
|
||||
self.assertEqual(len({p['id'] for p in records}), len(records))
|
||||
for record in records:
|
||||
self.assertEqual(record['browser']['id'], record['id'])
|
||||
self.assertEqual(record['fakeFrame']['sha256'], record['sha256'])
|
||||
self.assertIn(record['status'], ('works', 'partial', 'broken', 'na'))
|
||||
|
||||
def test_claims_require_exact_id_version_and_digest(self):
|
||||
records = json.loads(compatibility.DATA.read_text())['plugins']
|
||||
for plugin in records:
|
||||
with self.subTest(plugin=plugin['name']):
|
||||
expected = compatibility.lookup(plugin['id'], plugin['sha256'], plugin['version'])
|
||||
self.assertEqual(expected['status'], plugin['status'])
|
||||
for plugin_id, digest, version in (
|
||||
(plugin['id'], '0'*64, plugin['version']),
|
||||
(plugin['id'], plugin['sha256'], plugin['version']+'-new'),
|
||||
(99999, plugin['sha256'], plugin['version']),
|
||||
):
|
||||
result = compatibility.lookup(plugin_id, digest, version)
|
||||
self.assertEqual(result['status'], 'unverified')
|
||||
self.assertFalse(result['blockInstall'])
|
||||
|
||||
def test_installed_receipt_uses_its_digest_not_current_store_version(self):
|
||||
record = json.loads(compatibility.DATA.read_text())['plugins'][0]
|
||||
installed = {'plugins': [{'storeId': record['id'], 'version': record['version'],
|
||||
'sha256': record['sha256']},
|
||||
{'storeId': record['id'], 'version': record['version'], 'sha256': '0'*64}]}
|
||||
result = frame_decky.status(lambda *a, **k: json.dumps(installed))
|
||||
self.assertEqual(result['plugins'][0]['compatibility']['status'], record['status'])
|
||||
self.assertEqual(result['plugins'][1]['compatibility']['status'], 'unverified')
|
||||
|
||||
def test_hardware_block_precedes_any_download_or_ssh_mutation(self):
|
||||
with patch.object(frame_decky, 'status', return_value={'managed': True, 'running': True, 'plugins': []}), \
|
||||
patch.object(frame_decky_store, 'catalog', return_value=[{'id': 42, 'name': 'Hardware only',
|
||||
'sha256': 'a'*64, 'root': False, 'compatibility': {'blockInstall': True, 'reason': 'Not supported on Frame'}}]), \
|
||||
patch.object(frame_decky_store.urllib.request, 'urlopen') as download:
|
||||
def no_ssh(*a, **k):
|
||||
self.fail('must not mutate headset for known hardware blocker')
|
||||
with self.assertRaisesRegex(ValueError, 'Not supported on Frame'):
|
||||
frame_decky_store.change(no_ssh, 'install', 42)
|
||||
download.assert_not_called()
|
||||
|
||||
def test_missing_audit_data_is_unverified(self):
|
||||
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
|
||||
self.assertEqual(compatibility.lookup(12, 'a'*64, '1.0')['status'], 'unverified')
|
||||
@@ -1,83 +0,0 @@
|
||||
"""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
|
||||
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 LEGACY_PLUGINS, PLUGINS, archive
|
||||
|
||||
|
||||
class OfficialBundles(unittest.TestCase):
|
||||
def test_legacy_css_loader_store_archive_has_unversioned_frontend(self):
|
||||
plugin = LEGACY_PLUGINS[0]
|
||||
data = archive(plugin)
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
|
||||
self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']), plugin['folder'])
|
||||
with tempfile.TemporaryDirectory() as root, zipfile.ZipFile(io.BytesIO(data)) as bundle:
|
||||
manifest = json.loads(bundle.read(plugin['folder'] + '/plugin.json'))
|
||||
self.assertNotIn('api_version', manifest)
|
||||
frontend = bundle.read(plugin['folder'] + '/dist/index.js')
|
||||
self.assertIn(b'deckyFrontendLib.definePlugin', frontend)
|
||||
bundle.extractall(root)
|
||||
folder = Path(root) / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
with patch.object(framey_panel, 'PLUGINS', Path(root)):
|
||||
page = framey_panel.panel(plugin['id']).decode()
|
||||
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
|
||||
self.assertIsNotNone(encoded)
|
||||
self.assertEqual(base64.b64decode(encoded.group(1)), frontend)
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
|
||||
def test_bluetooth_initial_panel_uses_safe_token_fallback(self):
|
||||
with zipfile.ZipFile(io.BytesIO(archive(next(p for p in PLUGINS if p['id'] == 18)))) as bundle:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
frontend = Path(folder) / 'index.js'
|
||||
frontend.write_bytes(bundle.read('Bluetooth/dist/index.js'))
|
||||
script = Path(__file__).parent / 'page/framey_bluetooth.mjs'
|
||||
result = subprocess.run(['node', str(script), str(frontend)],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@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)
|
||||
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.get('api_version', 'legacy'), plugin['api_version'])
|
||||
z.extractall(plugins)
|
||||
folder = plugins / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
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()
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Framey hosts store-owned modern Decky frontends and scopes backend calls."""
|
||||
import sandbox # noqa: F401
|
||||
import gzip
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import tarfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'ui'))
|
||||
import framey_panel
|
||||
import frame_headset
|
||||
import server
|
||||
|
||||
|
||||
class Panel(unittest.TestCase):
|
||||
def test_decky_event_queue_scope_bounds_expiry_and_reconnect(self):
|
||||
for name in ('framey_events.mjs', 'framey_event_parent.mjs'):
|
||||
with self.subTest(script=name):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page'/name)],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_events_validate_before_ssh_and_resolve_name_from_store_receipt(self):
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
import shlex
|
||||
payload = json.loads(shlex.split(command)[2])
|
||||
self.assertEqual(payload, {'name':'Simple Timer', 'session':'test-123.45',
|
||||
'events':['timer:tick'], 'cursor':5})
|
||||
compile(stdin, '<event poll>', 'exec')
|
||||
self.assertIn('loader/plugin_event', stdin)
|
||||
return json.dumps({'events':[{'event':'timer:tick', 'args':[42]}], 'cursor':6})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':96,'name':'Simple Timer'}]}):
|
||||
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5)['cursor'], 6)
|
||||
for plugin_id, session, events, cursor in [
|
||||
(True,'test',['tick'],0), (96,'bad session',['tick'],0),
|
||||
(96,'test',['a']*33,0), (96,'test',['bad event'],0),
|
||||
(96,'test',[None],0), (96,'test',['tick'],-1),
|
||||
(96,'test',['tick'],True), (96,'test',['tick'],2**53),
|
||||
(19,'test',['tick'],0),
|
||||
]:
|
||||
with self.subTest(plugin_id=plugin_id, session=session, events=events, cursor=cursor):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.poll_events(lambda *a, **k:self.fail('Unexpected SSH'),
|
||||
plugin_id, session, events, cursor)
|
||||
|
||||
def test_event_action_has_same_headset_boundary_as_methods(self):
|
||||
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.framey_events_action({'id':96,'session':'test','events':['tick']})
|
||||
with patch.object(server, 'HEADSET_RUNTIME', True), \
|
||||
patch.object(framey_panel, 'poll_events', return_value={'events':[], 'cursor':0}) as poll:
|
||||
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick']})['events'], [])
|
||||
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0)
|
||||
|
||||
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_vr_store_browsing_and_search(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_store.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
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)
|
||||
self.plugins = Path(tmp.name)/'plugins'
|
||||
self.folder = self.plugins/'protondb-decky'
|
||||
(self.folder/'dist').mkdir(parents=True)
|
||||
(self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4'}))
|
||||
(self.folder/'plugin.json').write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
|
||||
(self.folder/'dist/index.js').write_text("export default () => ({content: SP_REACT.createElement('p', null, 'OK')});")
|
||||
home = patch.object(framey_panel, 'PLUGINS', self.plugins)
|
||||
home.start()
|
||||
self.addCleanup(home.stop)
|
||||
|
||||
def test_owned_api_v2_bundle_is_embedded_without_ui_key(self):
|
||||
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):
|
||||
framey_panel.panel(15)
|
||||
|
||||
def test_legacy_iife_is_admitted_without_api_manifest(self):
|
||||
(self.folder/'plugin.json').write_text(json.dumps({'name':'Legacy', 'flags':[]}))
|
||||
(self.folder/'dist/index.js').write_text(
|
||||
'(function(DFL, React){return DFL.definePlugin(serverAPI=>({content:React.createElement("p",null,"Legacy")}));})(DFL, SP_REACT);')
|
||||
self.assertIn('framey-bundle', framey_panel.panel(14).decode())
|
||||
(self.folder/'dist/index.js').write_text('window.unrecognized = true;')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.panel(14)
|
||||
|
||||
def test_legacy_keyword_rpc_is_scoped_and_validated(self):
|
||||
import shlex
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
payload = json.loads(shlex.split(command)[2])
|
||||
self.assertEqual(payload, {'name':'Legacy', 'method':'read_setting',
|
||||
'args':[{'key':'mode'}], 'legacy':True})
|
||||
self.assertIn('loader/call_legacy_plugin_method', stdin)
|
||||
return json.dumps({'result':{'success':True, 'result':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'Legacy'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True),
|
||||
{'result':{'success':True, 'result':'dark'}})
|
||||
for args, legacy in [([],True), ([[]],True), ([{},{}],True), ([{}],'true')]:
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy)
|
||||
|
||||
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
|
||||
manifest = self.folder/'plugin.json'
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':0}))
|
||||
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':[]}))
|
||||
dist = self.folder/'dist'
|
||||
dist.rename(self.folder/'saved-dist')
|
||||
dist.symlink_to(self.folder/'saved-dist')
|
||||
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
|
||||
dist.unlink()
|
||||
(self.folder/'.frame-control-store.json').unlink()
|
||||
with self.assertRaises(FileNotFoundError): framey_panel.panel(14)
|
||||
|
||||
def test_backend_method_is_scoped_to_store_receipt(self):
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
self.assertIn('python3 - ', command)
|
||||
self.assertEqual(stdin, framey_panel.METHOD_SCRIPT)
|
||||
return json.dumps({'result':{'theme':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'ProtonDB Badges'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark']),
|
||||
{'result':{'theme':'dark'}})
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[])
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[])
|
||||
compile(framey_panel.METHOD_SCRIPT, '<remote method>', 'exec')
|
||||
|
||||
def test_headset_bundle_contains_runtime_and_licenses(self):
|
||||
packed = frame_headset.bundle()
|
||||
self.assertGreater(len(packed), 100_000)
|
||||
self.assertLess(len(packed), frame_headset.MAX_ARCHIVE)
|
||||
with tarfile.open(fileobj=io.BytesIO(gzip.decompress(packed))) as archive:
|
||||
names = set(archive.getnames())
|
||||
self.assertTrue({'ui/framey_panel.py','ui/framey-host.js',
|
||||
'ui/framey-events.js',
|
||||
'ui/vendor/react-18.3.1.min.js','ui/vendor/react-dom-18.3.1.min.js',
|
||||
'ui/vendor/REACT-LICENSE.txt','ui/vendor/REACT-DOM-LICENSE.txt'} <= names)
|
||||
|
||||
def test_http_panel_is_sandboxable_and_missing_plugin_is_404(self):
|
||||
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
def get(path):
|
||||
conn = http.client.HTTPConnection('127.0.0.1', httpd.server_port, timeout=5)
|
||||
try:
|
||||
conn.request('GET', path)
|
||||
reply = conn.getresponse()
|
||||
return reply.status, dict(reply.getheaders()), reply.read()
|
||||
finally:
|
||||
conn.close()
|
||||
status, headers, body = get('/framey/panel/14')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('blob:', headers['Content-Security-Policy'])
|
||||
self.assertIn(b'framey-bundle', body)
|
||||
self.assertEqual(get('/framey/panel/15')[0], 404)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,62 +0,0 @@
|
||||
"""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_PLUGIN_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()
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Safety gates for unattended device work; no headset or OpenVR loaded."""
|
||||
import importlib.util
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
PATH = Path(__file__).resolve().parents[1]/'scripts/framey-vr-check.py'
|
||||
spec = importlib.util.spec_from_file_location('framey_vr_check', PATH)
|
||||
check = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(check)
|
||||
|
||||
|
||||
class Safety(unittest.TestCase):
|
||||
def test_foreign_lock_prevents_sensor_reads_and_cleanup(self):
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory:
|
||||
remote = factory.return_value
|
||||
remote.side_effect = RuntimeError('File exists')
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(check.main(['--exercise','--output',folder]), 1)
|
||||
remote.python.assert_not_called()
|
||||
self.assertEqual(remote.call_count, 1)
|
||||
self.assertIn('mkdir /tmp/frame-test.lock', remote.call_args.args[0])
|
||||
self.assertFalse(json.loads((Path(folder)/'result.json').read_text())['passed'])
|
||||
|
||||
def test_low_battery_never_wakes_or_installs_and_releases_lock(self):
|
||||
state = {'battery':[19], 'pause':'power.pauseCompositorOnStandby=true',
|
||||
'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False}
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory, \
|
||||
patch.object(check.frame_decky, 'change') as install:
|
||||
remote = factory.return_value
|
||||
remote.python.return_value = state
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(check.main(['--exercise','--output',folder]), 1)
|
||||
install.assert_not_called()
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args.args[0])
|
||||
self.assertFalse(any('--handlewakeup' in c.args[0] for c in remote.call_args_list))
|
||||
|
||||
def test_power_restore_after_partial_setter_failure(self):
|
||||
state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true',
|
||||
'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False}
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory:
|
||||
remote = factory.return_value
|
||||
remote.python.return_value = state
|
||||
def run(cmd, **_):
|
||||
if '--set-settings-float power.turnOffScreensTimeout 120' in cmd:
|
||||
raise RuntimeError('setter failed')
|
||||
return ''
|
||||
remote.side_effect = run
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(check.main(['--wake','--output',folder]), 1)
|
||||
commands = [c.args[0] for c in remote.call_args_list]
|
||||
self.assertTrue(any('--set-settings-bool power.pauseCompositorOnStandby 1' in c for c in commands))
|
||||
self.assertTrue(any('--set-settings-float power.turnOffScreensTimeout 17.000000' in c for c in commands))
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', commands[-1])
|
||||
|
||||
def test_exact_linux_event_abi_and_debug_profile(self):
|
||||
import ctypes
|
||||
overlay_spec = importlib.util.spec_from_file_location('framey_openvr', PATH.with_name('framey_openvr.py'))
|
||||
overlay = importlib.util.module_from_spec(overlay_spec); overlay_spec.loader.exec_module(overlay)
|
||||
self.assertEqual(ctypes.sizeof(overlay.Event), 60)
|
||||
self.assertEqual(overlay.Event.data.offset, 12)
|
||||
original = check.frame_headset.bundle()
|
||||
patched = check.debug_bundle(original, '/tmp/owned-profile')
|
||||
import tarfile
|
||||
with tarfile.open(fileobj=io.BytesIO(patched),mode='r:gz') as archive:
|
||||
source=archive.extractfile('ui/frame_headset_runtime.py').read().decode()
|
||||
self.assertIn("'--remote-debugging-port=0'", source)
|
||||
self.assertIn("'/tmp/owned-profile'", source)
|
||||
self.assertNotIn("str(BASE / 'profile')", source)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""The real-device check must abort before touching a standby or busy Frame."""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
MODULE = Path(__file__).resolve().parents[1]/'scripts/framey-worn-check.py'
|
||||
spec = importlib.util.spec_from_file_location('framey_worn_check', MODULE)
|
||||
worn = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(worn)
|
||||
|
||||
|
||||
class WornGate(unittest.TestCase):
|
||||
def run_gate(self, state, remote_side_effect=None):
|
||||
out = io.StringIO()
|
||||
with patch.object(worn, 'probe', return_value=state) as probe, \
|
||||
patch.object(worn, 'remote', side_effect=remote_side_effect) as remote, \
|
||||
contextlib.redirect_stdout(out):
|
||||
result = worn.main(worn_confirmed=True)
|
||||
return result, out.getvalue(), remote, probe
|
||||
|
||||
def test_no_wearer_confirmation_means_no_sensor_or_input_claim(self):
|
||||
with patch.object(worn,'remote') as remote, contextlib.redirect_stdout(io.StringIO()) as out:
|
||||
self.assertEqual(worn.main(),1)
|
||||
remote.assert_not_called()
|
||||
self.assertIn('trusted pointer events alone do not prove',out.getvalue())
|
||||
|
||||
def test_standby_is_read_only_under_owned_lock_and_releases_it(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':3, 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('standby', report)
|
||||
probe.assert_called_once()
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
self.assertIn('mkdir /tmp/frame-test.lock', remote.call_args_list[0].args[0])
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', remote.call_args_list[1].args[0])
|
||||
|
||||
def test_low_battery_releases_owned_lock(self):
|
||||
state = {'battery':[{'percent':19}], 'activity':0, 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state)
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('below 20%', report)
|
||||
probe.assert_called_once()
|
||||
self.assertEqual(remote.call_count, 2)
|
||||
|
||||
def test_foreign_lock_prevents_all_sensor_reads_and_is_never_removed(self):
|
||||
state = {'battery':[{'percent':99}], 'activity':0, 'panelService':True,
|
||||
'plugins':[{'id':14,'name':'ProtonDB Badges'}], 'panel':None}
|
||||
code, report, remote, probe = self.run_gate(state, RuntimeError('File exists'))
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('File exists', report)
|
||||
probe.assert_not_called()
|
||||
self.assertEqual(remote.call_count, 1)
|
||||
self.assertIn('mkdir /tmp/frame-test.lock', remote.call_args.args[0])
|
||||
|
||||
def test_screenshot_palette_counts_distinct_colors(self):
|
||||
self.assertEqual(worn.color_count('0 #ff00aa 1 #ff00aa 2 #00ff00'), 2)
|
||||
+12
-56
@@ -16,9 +16,9 @@ saved Steam clips to ~/Videos/SteamFrame.
|
||||
- When the headset can't be reached it waits longer each time, up to MAX_BACKOFF.
|
||||
|
||||
The server supplies how to reach the headset, so this can be tested without one:
|
||||
list_shots(device) -> {"shots": [{"id", "time", "saved"}...]}
|
||||
list_shots() -> {"shots": [{"id", "time", "saved"}...]}
|
||||
save_shots(device, ids) copies those screenshots here
|
||||
list_clips(device) -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
|
||||
list_clips() -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
|
||||
export_clip(device, id) exports one clip here
|
||||
device() -> the connected headset's id, or None
|
||||
busy() -> True while other work runs on the headset (installs, jobs, a recording)
|
||||
@@ -41,7 +41,7 @@ SETTLE = 30 # a clip written to within this many seconds is still being recorde
|
||||
MIN_FREE = 512 * 1024**2 # pause below this much free space on this computer
|
||||
KEEP = 5000 # copied ids remembered per headset and kind
|
||||
BATCH = 100 # screenshots per copy
|
||||
TRIES = 3 # failed attempts per item until Resume, Copy everything now, or restart
|
||||
TRIES = 3 # an item that fails this often is left until the app restarts
|
||||
KINDS = ("shots", "clips")
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ class AutoSync:
|
||||
self._running = False
|
||||
self._backoff = 0
|
||||
self._tries = {} # (device, id) -> failed attempts this run
|
||||
self._failures = {} # last error for each failed item, including exhausted retries
|
||||
self._errors = [] # this pass's, for the page
|
||||
self._s = self._load()
|
||||
|
||||
@@ -144,19 +143,13 @@ class AutoSync:
|
||||
"""Copy everything on the headset that isn't on this computer, once (also resumes)."""
|
||||
with self._lock:
|
||||
self._all, self._s["paused"] = True, None
|
||||
self._tries.clear()
|
||||
self._failures.clear()
|
||||
self._s["error"] = None
|
||||
self._save()
|
||||
self._backoff = 0
|
||||
self._wake.set()
|
||||
return self.state()
|
||||
|
||||
def resume(self):
|
||||
with self._lock:
|
||||
self._s["paused"], self._s["error"] = None, None
|
||||
self._tries.clear()
|
||||
self._failures.clear()
|
||||
self._save()
|
||||
self._backoff = 0
|
||||
self._wake.set()
|
||||
@@ -222,9 +215,6 @@ class AutoSync:
|
||||
|
||||
def _mark(self, device, kind, ids):
|
||||
with self._lock:
|
||||
for item in ids:
|
||||
self._tries.pop((device, item), None)
|
||||
self._failures.pop((device, item), None)
|
||||
per = self._s["synced"].setdefault(device, {})
|
||||
have = per.get(kind, [])
|
||||
new = [i for i in ids if i not in set(have)]
|
||||
@@ -243,46 +233,24 @@ class AutoSync:
|
||||
"then press Resume.")
|
||||
if self.offline(error):
|
||||
raise error
|
||||
with self._lock:
|
||||
key = (device, item)
|
||||
self._tries[key] = self._tries.get(key, 0) + 1
|
||||
message = f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}"
|
||||
if self._tries[key] >= TRIES:
|
||||
message += ". Automatic retries stopped. Press Resume to try again."
|
||||
self._failures[key] = message
|
||||
self._errors.append(message)
|
||||
|
||||
def _retry_allowed(self, device, item):
|
||||
with self._lock:
|
||||
key = (device, item)
|
||||
if self._tries.get(key, 0) < TRIES:
|
||||
return True
|
||||
self._errors.append(self._failures[key])
|
||||
return False
|
||||
|
||||
def _interrupted(self, device):
|
||||
return self.busy() or self.device() != device
|
||||
key = (device, item)
|
||||
self._tries[key] = self._tries.get(key, 0) + 1
|
||||
self._errors.append(f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}")
|
||||
|
||||
def _pass(self, device, shots, clips, since):
|
||||
"""-> (copied, clips still being written, finished without stopping for other work)."""
|
||||
copied = waiting = 0
|
||||
if shots:
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
listing = self.list_shots(device)["shots"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
listing = self.list_shots()["shots"]
|
||||
done = self._synced(device, "shots")
|
||||
self._mark(device, "shots", [s["id"] for s in listing if s.get("saved")])
|
||||
todo = [s["id"] for s in sorted(listing, key=lambda s: s["time"])
|
||||
if not s.get("saved") and (since is None or (s["time"] >= since and s["id"] not in done))
|
||||
and self._retry_allowed(device, s["id"])]
|
||||
and self._tries.get((device, s["id"]), 0) < TRIES]
|
||||
for i in range(0, len(todo), BATCH):
|
||||
if self._interrupted(device):
|
||||
if self.busy():
|
||||
return copied, waiting, False
|
||||
self._room(self.shots_folder)
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
batch = todo[i:i + BATCH]
|
||||
try:
|
||||
self.save_shots(device, batch)
|
||||
@@ -290,40 +258,28 @@ class AutoSync:
|
||||
for item in batch:
|
||||
self._failed(device, item, e)
|
||||
continue
|
||||
if self.device() != device:
|
||||
return copied, waiting, False
|
||||
self._mark(device, "shots", batch)
|
||||
copied += len(batch)
|
||||
if clips:
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
listing = [c for c in self.list_clips(device)["clips"] if c.get("kind") == "clip"]
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
listing = [c for c in self.list_clips()["clips"] if c.get("kind") == "clip"]
|
||||
done = self._synced(device, "clips")
|
||||
self._mark(device, "clips", [c["id"] for c in listing if c.get("saved")])
|
||||
for c in sorted(listing, key=lambda c: c["time"]):
|
||||
if c.get("saved"):
|
||||
if c.get("saved") or self._tries.get((device, c["id"]), 0) >= TRIES:
|
||||
continue
|
||||
if since is not None and (c["time"] < since or c["id"] in done):
|
||||
continue
|
||||
if not self._retry_allowed(device, c["id"]):
|
||||
continue
|
||||
if c.get("idle") is None or c["idle"] < SETTLE:
|
||||
waiting += 1 # still being written: copied on a later look
|
||||
continue
|
||||
if self._interrupted(device):
|
||||
if self.busy():
|
||||
return copied, waiting, False
|
||||
self._room(self.clips_folder)
|
||||
if self._interrupted(device):
|
||||
return copied, waiting, False
|
||||
try:
|
||||
self.export_clip(device, c["id"])
|
||||
except Exception as e:
|
||||
self._failed(device, c["id"], e)
|
||||
continue
|
||||
if self.device() != device:
|
||||
return copied, waiting, False
|
||||
self._mark(device, "clips", [c["id"]])
|
||||
copied += 1
|
||||
return copied, waiting, True
|
||||
+7
-20
@@ -26,7 +26,6 @@ that stream. The folder names are as users and Steam's own files describe them;
|
||||
listing doesn't depend on them: any folder under clips/ or video/ (up to four levels)
|
||||
holding init-stream*.m4s counts as a session.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -364,20 +363,10 @@ def parse_id(clip_id):
|
||||
|
||||
|
||||
def export_name(clip_id):
|
||||
"""The .mp4's basename; it alone does not identify a saved export."""
|
||||
"""The .mp4's name on the computer: the clip's (or background session's) own folder name."""
|
||||
return parse_id(clip_id)[2] + ".mp4"
|
||||
|
||||
|
||||
def clip_local_path(clip_id, device_id, folder=None):
|
||||
"""Keep the headset, account and full Steam session identity in the destination."""
|
||||
parts = parse_id(clip_id)
|
||||
if not isinstance(device_id, str) or not device_id:
|
||||
raise ValueError("missing clip headset identity")
|
||||
headset = hashlib.sha256(device_id.encode("utf-8")).hexdigest()[:12]
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
return folder / f"headset-{headset}" / Path(*parts) / export_name(clip_id)
|
||||
|
||||
|
||||
def read_session(folder):
|
||||
"""{stream number: [init, chunk, chunk...]} for one session folder, chunks in number
|
||||
order. A stream without its init segment or without chunks can't be played, so it's left out."""
|
||||
@@ -563,20 +552,19 @@ def remove_command(path):
|
||||
# run_frame(args, timeout) runs this file on the Frame with `args` and returns its JSON;
|
||||
# run(command, timeout) and fetch(remote, local) as for Recorder.
|
||||
|
||||
def list_clips(run_frame, folder=None, *, device_id):
|
||||
def list_clips(run_frame, folder=None):
|
||||
"""Steam's recordings on the Frame, each marked saved if its .mp4 is already here."""
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
clips = run_frame(["list"], 60)["clips"]
|
||||
for c in clips:
|
||||
c["saved"] = clip_local_path(c["id"], device_id, folder).is_file()
|
||||
c["saved"] = (folder / export_name(c["id"])).exists()
|
||||
return {"clips": clips, "folder": str(folder)}
|
||||
|
||||
|
||||
def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
def export_clip(clip_id, run_frame, run, fetch, folder=None):
|
||||
"""Stitch a Steam recording on the Frame and copy the .mp4 here. -> {"message", "file"}."""
|
||||
folder = Path(folder) if folder else VIDEOS_DIR
|
||||
target = clip_local_path(clip_id, device_id, folder) # validate before reaching the Frame
|
||||
name = target.name
|
||||
name = export_name(clip_id) # checks the id before anything reaches the Frame
|
||||
made = run_frame(["export", clip_id], 1900)
|
||||
path = made.get("path")
|
||||
remove = remove_command(path) # refuses a path that isn't one of our exports
|
||||
@@ -585,8 +573,7 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=folder))
|
||||
try:
|
||||
fetch(path, incoming / name)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(incoming / name, target)
|
||||
os.replace(incoming / name, folder / name)
|
||||
finally:
|
||||
shutil.rmtree(incoming, ignore_errors=True)
|
||||
finally:
|
||||
@@ -594,7 +581,7 @@ def export_clip(clip_id, run_frame, run, fetch, folder=None, *, device_id):
|
||||
run(remove, 30)
|
||||
except Exception:
|
||||
pass # the next export sweeps it
|
||||
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(target)}
|
||||
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(folder / name)}
|
||||
|
||||
|
||||
# ---- on the Frame -----------------------------------------------------------------
|
||||
|
||||
+3
-18
@@ -4,7 +4,6 @@ import json
|
||||
import re
|
||||
import secrets
|
||||
import urllib.request
|
||||
import framey_compatibility
|
||||
|
||||
|
||||
REPO = "SteamDeckHomebrew/decky-loader"
|
||||
@@ -39,8 +38,7 @@ def plugins(folder):
|
||||
found.append({'folder': child.name, 'name': str(data.get('name', child.name))[:80],
|
||||
'author': str(data.get('author', ''))[:80],
|
||||
'storeId': owned.get('storeId') if isinstance(owned.get('storeId'), int) else None,
|
||||
'version': str(owned.get('version', ''))[:40],
|
||||
'sha256': str(owned.get('sha256', ''))[:64]})
|
||||
'version': str(owned.get('version', ''))[:40]})
|
||||
except (ValueError, OSError, UnicodeError): pass
|
||||
return found
|
||||
os_release = {}
|
||||
@@ -65,7 +63,7 @@ print(json.dumps({'arch':platform.machine(), 'frameOS':os_release.get('VERSION_I
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import fcntl, hashlib, json, os, pathlib, subprocess, sys
|
||||
import hashlib, json, os, pathlib, subprocess, sys
|
||||
action, token, digest, version = sys.argv[1:]
|
||||
home = pathlib.Path.home()
|
||||
base = home / '.local/share/frame-control/decky'
|
||||
@@ -74,7 +72,6 @@ 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')
|
||||
@@ -82,13 +79,6 @@ 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')
|
||||
@@ -151,16 +141,11 @@ 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()
|
||||
'''
|
||||
|
||||
|
||||
def status(ssh):
|
||||
result = json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
|
||||
for plugin in result.get('plugins', []):
|
||||
plugin['compatibility'] = framey_compatibility.lookup(
|
||||
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'))
|
||||
return result
|
||||
return json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
|
||||
|
||||
|
||||
def release_binary():
|
||||
|
||||
+21
-84
@@ -6,14 +6,12 @@ digest validates transport, not plugin trust; store plugins run as the user.
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
import frame_decky
|
||||
import framey_compatibility
|
||||
|
||||
|
||||
STORE_URL = 'https://plugins.deckbrew.xyz/plugins'
|
||||
@@ -26,16 +24,8 @@ SHA = re.compile(r'[0-9a-f]{64}\Z')
|
||||
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
|
||||
|
||||
|
||||
def _urls():
|
||||
# Deterministic fixture URLs exist only inside the disposable fake Frame host.
|
||||
if os.environ.get('FRAME_E2E_CONTAINER') == '1':
|
||||
return (os.environ.get('FRAMEY_STORE_URL', STORE_URL),
|
||||
os.environ.get('FRAMEY_CDN_URL', CDN))
|
||||
return STORE_URL, CDN
|
||||
|
||||
|
||||
def catalog():
|
||||
request = urllib.request.Request(_urls()[0], headers={'User-Agent': 'Frame-Control'})
|
||||
request = urllib.request.Request(STORE_URL, headers={'User-Agent': 'Frame-Control'})
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw = response.read(MAX_STORE + 1)
|
||||
if len(raw) > MAX_STORE:
|
||||
@@ -55,21 +45,11 @@ 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 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
|
||||
unique = [item for item in result if counts[item['id']] == 1]
|
||||
for item in unique:
|
||||
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
|
||||
return unique
|
||||
'root': 'root' in tags})
|
||||
return result
|
||||
|
||||
|
||||
def archive_info(data, expected_hash, expected_name):
|
||||
@@ -86,20 +66,13 @@ 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
|
||||
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)):
|
||||
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):
|
||||
raise ValueError('Unsafe plugin archive member')
|
||||
seen.add(name)
|
||||
if entry.is_dir():
|
||||
continue
|
||||
with archive.open(entry) as source:
|
||||
@@ -109,8 +82,6 @@ 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', [])
|
||||
@@ -122,7 +93,7 @@ def archive_info(data, expected_hash, expected_name):
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import fcntl,hashlib,json,os,pathlib,shutil,subprocess,sys,zipfile
|
||||
import hashlib,json,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'
|
||||
@@ -131,7 +102,6 @@ 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)
|
||||
@@ -139,19 +109,12 @@ 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:
|
||||
@@ -160,24 +123,16 @@ 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')
|
||||
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)
|
||||
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')
|
||||
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')
|
||||
# Stop even a failed unit before removal to cancel any pending auto-restart.
|
||||
if running or action=='remove': service('stop','frame-control-decky.service')
|
||||
if running: service('stop','frame-control-decky.service')
|
||||
try:
|
||||
if action!='install': target.rename(backup)
|
||||
if action!='remove': (stage/folder).rename(target)
|
||||
@@ -193,41 +148,27 @@ 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, report=lambda stage, percent=None: None, reviewed_hash=None):
|
||||
def change(ssh, action, plugin_id):
|
||||
if action not in ('install', 'update', 'remove') or type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise ValueError('Choose a valid store plugin action')
|
||||
if reviewed_hash is not None and (action == 'remove' or not isinstance(reviewed_hash, str)
|
||||
or not SHA.fullmatch(reviewed_hash)):
|
||||
raise ValueError('Invalid reviewed store version')
|
||||
report('Checking installed plugins', 5)
|
||||
state = frame_decky.status(ssh)
|
||||
if not state['managed']:
|
||||
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')
|
||||
if not state['managed'] or not state['running']:
|
||||
raise ValueError('Start Frame Control managed Decky before changing 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')
|
||||
if reviewed_hash is not None and plugin['sha256'] != reviewed_hash:
|
||||
raise ValueError('The official store version changed; refresh the store and review it again')
|
||||
if plugin['root']:
|
||||
raise ValueError('Root plugins are not supported by the Frame user service')
|
||||
if plugin.get('compatibility', {}).get('blockInstall'):
|
||||
raise ValueError(plugin['compatibility']['reason'])
|
||||
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']]
|
||||
if action == 'install' and existing_name:
|
||||
raise ValueError('A plugin with this name is already installed')
|
||||
@@ -235,11 +176,9 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
|
||||
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'
|
||||
url = CDN + 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')
|
||||
@@ -249,9 +188,7 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
|
||||
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:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,52 +0,0 @@
|
||||
/* Executed in Steam CEF, using Decky 3.3.0's loader/plugin_event envelope. */
|
||||
(function(payload) {
|
||||
const backend = window.DeckyBackend;
|
||||
if (!backend?.addEventListener || !backend?.removeEventListener)
|
||||
throw Error('Decky backend events are unavailable');
|
||||
let bridge = window.__frameyEventBridgeV1;
|
||||
if (bridge && bridge.backend !== backend) {
|
||||
for (const key of [...bridge.sessions.keys()]) bridge.remove(key);
|
||||
bridge = null;
|
||||
}
|
||||
if (!bridge) {
|
||||
const sessions = new Map();
|
||||
const remove = key => {
|
||||
const item = sessions.get(key);
|
||||
if (item) backend.removeEventListener('loader/plugin_event', item.listener);
|
||||
sessions.delete(key);
|
||||
if (!sessions.size) {clearInterval(bridge.timer); bridge.timer = null;}
|
||||
};
|
||||
bridge = window.__frameyEventBridgeV1 = {backend, sessions, remove, timer:null};
|
||||
}
|
||||
const key = payload.session;
|
||||
let item = bridge.sessions.get(key);
|
||||
if (item && item.name !== payload.name) throw Error('Framey event session belongs to another plugin');
|
||||
if (!payload.events.length) {
|
||||
bridge.remove(key);
|
||||
return {events:[], cursor:0};
|
||||
}
|
||||
if (!item) {
|
||||
if (bridge.sessions.size >= 4) throw Error('Too many Framey event sessions');
|
||||
item = {name:payload.name, names:new Set(), queue:[], sequence:0, touched:Date.now()};
|
||||
item.listener = data => {
|
||||
if (data?.plugin !== item.name || !item.names.has(data.event) || !Array.isArray(data.args)) return;
|
||||
try {
|
||||
const encoded = JSON.stringify(data.args);
|
||||
if (encoded.length > 8192) return;
|
||||
item.queue.push({sequence:++item.sequence, event:data.event, args:JSON.parse(encoded)});
|
||||
// Bound both event count and the CDP response, even for a noisy plugin.
|
||||
while (item.queue.length > 64 || JSON.stringify(item.queue).length > 48*1024) item.queue.shift();
|
||||
} catch (_) {} // Non-JSON event data cannot cross the iframe bridge.
|
||||
};
|
||||
bridge.sessions.set(key, item);
|
||||
backend.addEventListener('loader/plugin_event', item.listener);
|
||||
if (!bridge.timer) bridge.timer = setInterval(() => {
|
||||
for (const [session, state] of bridge.sessions)
|
||||
if (Date.now() - state.touched > 15000) bridge.remove(session);
|
||||
}, 5000);
|
||||
}
|
||||
item.names = new Set(payload.events);
|
||||
item.touched = Date.now();
|
||||
return {events:item.queue.filter(event => event.sequence > payload.cursor && item.names.has(event.event)),
|
||||
cursor:item.sequence};
|
||||
})
|
||||
@@ -1,491 +0,0 @@
|
||||
/* Framey panel compatibility layer. The iframe has an opaque origin and no UI key. */
|
||||
(() => {
|
||||
'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};
|
||||
// Let plugins use optional chaining for Steam-only globals without inventing
|
||||
// user identity or suspend state in this isolated document.
|
||||
for (const name of ['App','appStore','appInfoStore','collectionStore']) window[name] = undefined;
|
||||
window.SuspendResumeStore = Object.freeze({});
|
||||
window.SteamUIStore = Object.freeze({RunningApps:Object.freeze([]), MainRunningApp:undefined});
|
||||
// Steam normally fills these token maps. Official plugins can safely use
|
||||
// their own fallback strings when the isolated panel has no Steam tokens.
|
||||
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en'],
|
||||
m_mapTokens:new Map(), m_mapFallbackTokens:new Map([
|
||||
['Settings_Bluetooth_Connected','Connected'],
|
||||
['Settings_Bluetooth_NotConnected','Not connected'],
|
||||
['Settings_Bluetooth_Devices','Devices'],
|
||||
])};
|
||||
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 currentParams = {};
|
||||
let nextId = 0;
|
||||
let plugin;
|
||||
const eventListeners = new Map();
|
||||
let eventCursor = 0, eventRevision = 0, subscribedRevision = -1;
|
||||
let eventRequest = null, eventTimer = null, eventClosed = false;
|
||||
// A sandboxed iframe has an opaque origin, so its native localStorage getter
|
||||
// throws SecurityError. Keep the familiar synchronous API inside the iframe;
|
||||
// the parent stores only this plugin's bounded snapshot under its own origin.
|
||||
const cache = new Map();
|
||||
const cacheLimit = 128 * 1024;
|
||||
const cacheSession = window.location?.hash.slice(1) || '';
|
||||
let acceptSeed;
|
||||
const seeded = new Promise(resolve => {acceptSeed = resolve;});
|
||||
const storage = {
|
||||
get length() {return cache.size;},
|
||||
key(index) {return [...cache.keys()][index] ?? null;},
|
||||
getItem(key) {return cache.get(String(key)) ?? null;},
|
||||
setItem(key, value) {
|
||||
const name = String(key), data = String(value);
|
||||
const next = new Map(cache); next.set(name, data);
|
||||
if (next.size > 64 || name.length > 256 || data.length > 65536 ||
|
||||
JSON.stringify(Object.fromEntries(next)).length > cacheLimit)
|
||||
throw new DOMException('Framey plugin cache is full', 'QuotaExceededError');
|
||||
cache.set(name, data); persistCache();
|
||||
},
|
||||
removeItem(key) {cache.delete(String(key)); persistCache();},
|
||||
clear() {cache.clear(); persistCache();},
|
||||
};
|
||||
function persistCache() {
|
||||
parent.postMessage({type:'framey:cache:save', session:cacheSession,
|
||||
entries:Object.fromEntries(cache)}, '*');
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', {configurable:true, value:storage});
|
||||
// localForage otherwise chooses the browser's exposed-but-denied IndexedDB
|
||||
// driver in an opaque iframe. Let it detect the bounded localStorage driver.
|
||||
Object.defineProperty(window, 'indexedDB', {configurable:true, value:undefined});
|
||||
parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*');
|
||||
setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable.
|
||||
|
||||
function request(method, args, legacy=false, type='framey:call') {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(Error('Decky backend timed out')); }, 30000);
|
||||
pending.set(id, {resolve, reject, timer});
|
||||
parent.postMessage({type, id, method, args, legacy, session:cacheSession}, '*');
|
||||
});
|
||||
}
|
||||
function syncEvents() {
|
||||
if (eventClosed) return Promise.resolve();
|
||||
if (eventRequest) return eventRequest.then(() =>
|
||||
subscribedRevision !== eventRevision ? syncEvents() : undefined);
|
||||
const revision = eventRevision;
|
||||
eventRequest = request('events', [[...eventListeners.keys()], eventCursor], false, 'framey:events')
|
||||
.then(data => {
|
||||
subscribedRevision = revision;
|
||||
if (!data || !Array.isArray(data.events) || !Number.isSafeInteger(data.cursor))
|
||||
throw Error('Invalid Decky event response');
|
||||
eventCursor = data.cursor;
|
||||
for (const event of data.events) {
|
||||
if (!Array.isArray(event.args)) continue;
|
||||
for (const listener of eventListeners.get(event.event) || [])
|
||||
Promise.resolve().then(() => listener(...event.args)).catch(failure =>
|
||||
console.warn('Decky event listener failed', failure));
|
||||
}
|
||||
}).finally(() => {eventRequest = null;});
|
||||
return eventRequest.then(() => subscribedRevision !== eventRevision ? syncEvents() : undefined);
|
||||
}
|
||||
function refreshEvents() {
|
||||
clearTimeout(eventTimer);
|
||||
syncEvents().catch(failure => console.warn('Decky event bridge unavailable', failure.message))
|
||||
.finally(() => {
|
||||
clearTimeout(eventTimer);
|
||||
if (!eventClosed && eventListeners.size)
|
||||
eventTimer = setTimeout(refreshEvents, 1000);
|
||||
});
|
||||
}
|
||||
function addPluginEvent(event, listener) {
|
||||
if (typeof event !== 'string' || !/^[A-Za-z0-9_:/.-]{1,128}$/.test(event) || typeof listener !== 'function')
|
||||
throw Error('Invalid Decky event listener');
|
||||
if (!eventListeners.has(event)) {
|
||||
if (eventListeners.size >= 32) throw Error('Too many Decky event names');
|
||||
eventListeners.set(event, new Set());
|
||||
}
|
||||
eventListeners.get(event).add(listener);
|
||||
eventRevision++; refreshEvents();
|
||||
return listener; // Decky returns the registered callback, not a disposer.
|
||||
}
|
||||
function removePluginEvent(event, listener) {
|
||||
const listeners = eventListeners.get(event);
|
||||
if (!listeners?.delete(listener)) return;
|
||||
if (!listeners.size) eventListeners.delete(event);
|
||||
eventRevision++; refreshEvents();
|
||||
}
|
||||
async function ask(method, args, legacy=false) {
|
||||
// Subscribe before a backend call can emit its immediate response events.
|
||||
if (eventListeners.size && (eventRequest || subscribedRevision !== eventRevision)) await syncEvents();
|
||||
return request(method, args, legacy);
|
||||
}
|
||||
addEventListener('message', event => {
|
||||
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
|
||||
event.data.session === cacheSession && acceptSeed) {
|
||||
const entries = event.data.entries;
|
||||
if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
|
||||
for (const [key, value] of Object.entries(entries))
|
||||
if (key.length <= 256 && typeof value === 'string' && value.length <= 65536)
|
||||
cache.set(key, value);
|
||||
}
|
||||
acceptSeed(); acceptSeed = null;
|
||||
return;
|
||||
}
|
||||
if (event.source !== parent || !event.data || event.data.type !== 'framey:reply') return;
|
||||
const item = pending.get(event.data.id);
|
||||
if (!item) return;
|
||||
pending.delete(event.data.id); clearTimeout(item.timer);
|
||||
if (event.data.error) item.reject(Error(event.data.error)); else item.resolve(event.data.result);
|
||||
});
|
||||
addEventListener('unload', () => {
|
||||
eventClosed = true; clearTimeout(eventTimer);
|
||||
try { plugin?.onDismount?.(); } catch (_) {}
|
||||
// Parent tears down the remote session on close; CEF also expires it after 15s.
|
||||
parent.postMessage({type:'framey:events:close', session:cacheSession}, '*');
|
||||
});
|
||||
addEventListener('pointerup', event => {
|
||||
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
|
||||
}, {passive:true});
|
||||
|
||||
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, disabled, onChange}) => R.createElement(Field, {label, description},
|
||||
R.createElement('input', {type:'checkbox', checked:!!checked, disabled:!!disabled,
|
||||
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}));
|
||||
const DropdownItem = ({label, description, rgOptions=[], selectedOption, disabled, strDefaultLabel, onChange}) => {
|
||||
const choices=[];
|
||||
const option = entry => {
|
||||
const index=choices.push(entry)-1;
|
||||
return R.createElement('option', {key:index, value:String(index)}, entry.label);
|
||||
};
|
||||
const entries=(Array.isArray(rgOptions) ? rgOptions : []).map(entry =>
|
||||
Array.isArray(entry.options) ? R.createElement('optgroup', {key:String(entry.label), label:String(entry.label)},
|
||||
entry.options.map(option)) : option(entry));
|
||||
const selected=choices.findIndex(entry=>Object.is(entry.data, selectedOption));
|
||||
return R.createElement(Field, {label, description}, R.createElement('select', {
|
||||
value:selected < 0 ? '' : String(selected), disabled:!!disabled,
|
||||
onChange:e=>{const entry=choices[Number(e.target.value)];if(!disabled && entry)onChange?.(entry);}},
|
||||
selected < 0 && R.createElement('option', {value:'', disabled:true}, strDefaultLabel || 'Choose an option'),
|
||||
entries));
|
||||
};
|
||||
const ButtonItem = ({label, description, children, disabled, onClick}) => R.createElement(Field, {label, description},
|
||||
R.createElement('button', {type:'button', disabled:!!disabled,
|
||||
onClick:e=>{if(!disabled)onClick?.(e);}}, children || label));
|
||||
const DialogCheckbox = ({label, checked, defaultChecked, disabled, onChange}) =>
|
||||
R.createElement('label', null,
|
||||
R.createElement('input', {type:'checkbox', checked, defaultChecked, disabled:!!disabled,
|
||||
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}), label);
|
||||
const TextField = ({label, description, value, placeholder, disabled, bIsPassword,
|
||||
mustBeNumeric, mustBeURL, mustBeEmail, onChange}) => R.createElement(Field, {label, description},
|
||||
R.createElement('input', {type:bIsPassword?'password':mustBeNumeric?'number':mustBeURL?'url':mustBeEmail?'email':'text',
|
||||
value, placeholder, disabled:!!disabled, onChange:e=>{if(!disabled)onChange?.(e);}}));
|
||||
const SliderField = ({label, description, value, min=0, max=100, step=1, disabled, showValue,
|
||||
valueSuffix='', onChange}) => R.createElement(Field, {label, description},
|
||||
R.createElement('input', {type:'range', value, min, max, step, disabled:!!disabled,
|
||||
'aria-label':typeof label==='string'?label:undefined,
|
||||
onChange:e=>{if(!disabled)onChange?.(Number(e.target.value));}}),
|
||||
showValue && R.createElement('span', null, String(value) + valueSuffix));
|
||||
const PanelSection = ({title, children}) => R.createElement('section', null,
|
||||
title && R.createElement('h2', null, title), children);
|
||||
const Spinner = ({children}) => R.createElement('p', {role:'status'}, children || 'Loading…');
|
||||
const Tabs = ({tabs=[], activeTab, onShowTab}) => {
|
||||
const [selected, setSelected] = R.useState(tabs[0]?.id);
|
||||
const current = tabs.find(tab=>tab.id === (activeTab ?? selected)) || tabs[0];
|
||||
return R.createElement('div', null,
|
||||
R.createElement('div', {role:'tablist'}, ...tabs.map(tab=>R.createElement('button', {
|
||||
type:'button', key:tab.id, role:'tab', 'aria-selected':tab===current,
|
||||
disabled:!!tab.disabled, onClick:()=>{if(!tab.disabled){setSelected(tab.id);onShowTab?.(tab.id);}}}, tab.title))),
|
||||
R.createElement('div', {role:'tabpanel'}, current?.content));
|
||||
};
|
||||
const SidebarNavigation = ({pages=[], title}) => R.createElement('section', null,
|
||||
title && R.createElement('h2', null, title), R.createElement(Tabs, {
|
||||
tabs:pages.filter(page=>page.visible!==false).map((page,index)=>({
|
||||
id:page.route || String(index), title:page.title, content:page.content}))}));
|
||||
const ProgressBar = ({nProgress=0, value, max=100}) => R.createElement('progress', {
|
||||
value:Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max});
|
||||
const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) =>
|
||||
R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled),
|
||||
onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children);
|
||||
const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null,
|
||||
R.createElement('h2', null, strTitle), children,
|
||||
R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close'));
|
||||
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
|
||||
const closeModal = () => modalRoot.render(null);
|
||||
const localPath = path => typeof path === 'string' && path.length <= 120 &&
|
||||
/^\/[A-Za-z0-9/_-]+$/.test(path) && !path.includes('//');
|
||||
const routePattern = path => typeof path === 'string' && path.length <= 120 &&
|
||||
/^\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??)(?:\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??))*$/.test(path) &&
|
||||
!/\?\//.test(path) &&
|
||||
new Set([...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].map(match => match[1])).size ===
|
||||
[...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].length;
|
||||
function matchRoute(pattern, path) {
|
||||
const expected = pattern.slice(1).split('/');
|
||||
const actual = path.slice(1).split('/');
|
||||
if (actual.length > expected.length) return null;
|
||||
const params = {};
|
||||
for (let index = 0; index < expected.length; index++) {
|
||||
const segment = expected[index], value = actual[index];
|
||||
if (segment[0] === ':') {
|
||||
const optional = segment.endsWith('?'), name = segment.slice(1, optional ? -1 : undefined);
|
||||
if (value === undefined) { if (!optional) return null; }
|
||||
else params[name] = value;
|
||||
} else if (segment !== value) return null;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
function activeRoute(path) {
|
||||
if (routes.has(path)) return {component:routes.get(path), params:{}};
|
||||
for (const [pattern, component] of routes) {
|
||||
const params = matchRoute(pattern, path);
|
||||
if (params) return {component, params};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function navigate(path) {
|
||||
if (path !== null && !localPath(path)) return false;
|
||||
const match = path === null ? null : activeRoute(path);
|
||||
if (path !== null && !match) return false;
|
||||
currentRoute = path;
|
||||
currentParams = match?.params || {};
|
||||
for (const listener of routeListeners) listener(path);
|
||||
return true;
|
||||
}
|
||||
const routerHook = {
|
||||
addRoute(path, component) {
|
||||
if (!routePattern(path) ||
|
||||
(typeof component !== 'function' && !R.isValidElement(component)))
|
||||
throw Error('Framey route needs a local path and React component');
|
||||
routes.set(path, component);
|
||||
return () => routerHook.removeRoute(path);
|
||||
},
|
||||
removeRoute(path) {routes.delete(path); if(currentRoute && !activeRoute(currentRoute)) navigate(null);},
|
||||
// Steam routes and global components have no equivalent in the isolated panel.
|
||||
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
|
||||
};
|
||||
const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;};
|
||||
const showModal = element => {
|
||||
modalRoot.render(R.createElement('section', null,
|
||||
R.createElement('button', {type:'button',onClick:closeModal}, 'Close'), element));
|
||||
return {Close:closeModal};
|
||||
};
|
||||
const DFL = window.DFL = {
|
||||
definePlugin: init => init,
|
||||
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem,
|
||||
Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField,
|
||||
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
|
||||
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
|
||||
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
|
||||
Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
|
||||
SuspensefulImage:({alt='', ...props})=>R.createElement('img', {...props, alt}),
|
||||
Marquee:passthrough('span'), DialogBody:passthrough('div'), DialogBodyText:passthrough('p'),
|
||||
DialogHeader:passthrough('h2'), DialogSubHeader:passthrough('h3'), DialogFooter:passthrough('footer'),
|
||||
DialogLabel:passthrough('label'), DialogControlsSection:passthrough('section'),
|
||||
DialogControlsSectionHeader:passthrough('h3'), ModalRoot:passthrough('section'),
|
||||
Menu:passthrough('div'), MenuGroup:passthrough('section'), MenuItem,
|
||||
ProgressBar, ProgressBarWithInfo:ProgressBar,
|
||||
gamepadDialogClasses:{}, quickAccessMenuClasses:{}, gamepadTabbedPageClasses:{},
|
||||
gamepadSliderClasses:{}, gamepadContextMenuClasses:{}, scrollPanelClasses:{},
|
||||
quickAccessControlsClasses:{}, mainMenuAppRunningClasses:{}, libraryAssetImageClasses:{},
|
||||
joinClassNames:(...names)=>names.filter(Boolean).join(' '),
|
||||
sleep:ms=>new Promise(resolve=>setTimeout(resolve,Math.max(0,Number(ms)||0))),
|
||||
staticClasses: {Title:'framey-title'},
|
||||
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), CloseSideMenus:()=>navigate(null),
|
||||
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
|
||||
showModal, showContextMenu:showModal,
|
||||
// Steam app, library, and store patches have no target inside this panel.
|
||||
afterPatch:noPatch, beforePatch:noPatch, replacePatch:noPatch, createReactTreePatcher:noPatch,
|
||||
findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null,
|
||||
findModule:()=>null, findModuleByExport:()=>null, findClassModule:()=>null,
|
||||
findClass:()=>null, findSP:()=>null, fakeRenderComponent:()=>null,
|
||||
appDetailsClasses: {}, appDetailsHeaderClasses: {},
|
||||
useParams: () => currentParams,
|
||||
};
|
||||
// No Steam game is attached to an isolated panel. Subscriptions have a
|
||||
// disposable handle but emit no fabricated game, power, or input events.
|
||||
const subscription = () => {const dispose=()=>{};dispose.unregister=dispose;return dispose;};
|
||||
const register = names => Object.fromEntries(names.map(name=>[name,subscription]));
|
||||
const steamLanguage = () => {
|
||||
const locale = (navigator.language || 'en').toLowerCase();
|
||||
if (/^zh-(tw|hk|mo|hant)/.test(locale)) return 'tchinese';
|
||||
if (locale.startsWith('pt-br')) return 'brazilian';
|
||||
if (locale.startsWith('es-419')) return 'latam';
|
||||
return ({en:'english',de:'german',fr:'french',es:'spanish',it:'italian',ja:'japanese',
|
||||
ko:'koreana',zh:'schinese',ru:'russian',pl:'polish',pt:'portuguese',nl:'dutch',
|
||||
da:'danish',fi:'finnish',sv:'swedish',no:'norwegian',cs:'czech',hu:'hungarian',
|
||||
ro:'romanian',th:'thai',tr:'turkish',vi:'vietnamese',uk:'ukrainian',id:'indonesian'
|
||||
})[locale.split('-')[0]] || 'english';
|
||||
};
|
||||
window.SteamClient = {
|
||||
Settings:{GetCurrentLanguage:async()=>steamLanguage(),
|
||||
...register(['RegisterForSettingsChanges'])},
|
||||
Apps:register(['RegisterForAppDetails','RegisterForGameActionTaskChange',
|
||||
'RegisterForGameActionStart','RegisterForGameActionEnd','RegisterForGameActionUserRequest']),
|
||||
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
|
||||
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
|
||||
UI:register(['RegisterForFocusChangeEvents','RegisterForSystemKeyEvents'])},
|
||||
Input:register(['RegisterForControllerInputMessages','RegisterForControllerStateChanges']),
|
||||
User:register(['RegisterForLoginStateChange','RegisterForPrepareForSystemSuspendProgress',
|
||||
'RegisterForResumeSuspendedGamesProgress','RegisterForCurrentUserChanges']),
|
||||
Overlay:register(['RegisterForOverlayActivated']),
|
||||
UI:register(['RegisterForUIModeChanged']),
|
||||
// This is private plugin cache, not Steam's account/global JSON store.
|
||||
Storage:{
|
||||
GetJSON:async key=>{
|
||||
const value=storage.getItem('steam-client:'+String(key));
|
||||
if(value===null)throw Error('Not found');
|
||||
return value;
|
||||
},
|
||||
SetObject:async(key,value)=>{
|
||||
const encoded=JSON.stringify(value);
|
||||
if(encoded===undefined)throw Error('SteamClient.Storage requires a JSON value');
|
||||
storage.setItem('steam-client:'+String(key),encoded);
|
||||
},
|
||||
},
|
||||
};
|
||||
DFL.Router = {...DFL.Navigation, ...window.SteamUIStore};
|
||||
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
|
||||
const toaster = {toast: ({title, body, subtext, icon, logo, duration, showToast=true, onClick}={}) => {
|
||||
const data = {title, body, subtext, icon, logo};
|
||||
if (!showToast) return {data, dismiss(){}};
|
||||
const item = document.createElement('section');
|
||||
item.setAttribute('role','status');
|
||||
const roots = [];
|
||||
const addContent = (tag, value) => {
|
||||
if (value === null || value === undefined || value === false) return;
|
||||
const node = document.createElement(tag);
|
||||
if (R.isValidElement(value) || Array.isArray(value)) {
|
||||
const root = window.ReactDOM.createRoot(node);
|
||||
roots.push(root);
|
||||
root.render(value);
|
||||
} else if (typeof value === 'string' || typeof value === 'number') {
|
||||
node.textContent = String(value);
|
||||
} else return;
|
||||
item.append(node);
|
||||
};
|
||||
addContent('span', logo);
|
||||
addContent('span', icon);
|
||||
addContent('strong', title || 'Plugin notice');
|
||||
addContent('p', body);
|
||||
addContent('small', subtext);
|
||||
const close = document.createElement('button'); close.type='button'; close.textContent='Dismiss';
|
||||
let dismissed = false;
|
||||
const dismiss = () => {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
clearTimeout(timer);
|
||||
for (const root of roots) root.unmount();
|
||||
item.remove();
|
||||
};
|
||||
close.onclick=dismiss; item.append(close);
|
||||
if (typeof onClick === 'function') {
|
||||
const open = document.createElement('button'); open.type='button'; open.textContent='Open';
|
||||
open.onclick=()=>{onClick(); dismiss();}; item.append(open);
|
||||
}
|
||||
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 activeRoute(route)?.component === 'function' ?
|
||||
R.createElement(activeRoute(route).component) : activeRoute(route)?.component) :
|
||||
(view === 'settings' ? plugin.settings : plugin.content)));
|
||||
}
|
||||
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
|
||||
connect: (version, name) => ({
|
||||
_version: Math.min(version, 2),
|
||||
call: (method, ...args) => ask(method, args),
|
||||
callable: method => (...args) => ask(method, args),
|
||||
addEventListener:addPluginEvent, removeEventListener:removePluginEvent,
|
||||
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')),
|
||||
removeCssFromTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
|
||||
getExternalResourceURL: () => null,
|
||||
useQuickAccessVisible: () => true,
|
||||
}),
|
||||
};
|
||||
const legacyServerAPI = {
|
||||
routerHook, toaster,
|
||||
callPluginMethod: (method, kwargs={}) => {
|
||||
if (!kwargs || typeof kwargs !== 'object' || Array.isArray(kwargs))
|
||||
return Promise.resolve({success:false, result:'Legacy plugin arguments must be an object'});
|
||||
// Decky's legacy route takes keyword arguments and returns this envelope.
|
||||
return ask(method, [kwargs], true).catch(failure=>({success:false, result:String(failure.message || failure)}));
|
||||
},
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
};
|
||||
async function start() {
|
||||
try {
|
||||
await seeded;
|
||||
const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0));
|
||||
// Old Decky builds are expressions returning the initializer, rather than
|
||||
// ES modules. A module wrapper preserves the sandbox and avoids eval.
|
||||
const legacy = /^\s*\(function\s*\(/.test(new TextDecoder().decode(bytes));
|
||||
const url = URL.createObjectURL(new Blob(legacy ?
|
||||
['const __frameyLegacy =\n', bytes, '\nexport default __frameyLegacy;'] : [bytes], {type:'text/javascript'}));
|
||||
let module;
|
||||
try { module = await import(url); } finally { URL.revokeObjectURL(url); }
|
||||
if (typeof module.default !== 'function') throw Error('The Decky frontend has no plugin entry point');
|
||||
plugin = await module.default(legacyServerAPI);
|
||||
if (legacy && plugin?.title && !plugin.titleView) plugin.titleView = plugin.title;
|
||||
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
|
||||
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
|
||||
R.createElement(PluginSurface, {plugin})));
|
||||
parent.postMessage({type:'framey:ready'}, '*');
|
||||
} catch (failure) {
|
||||
error.textContent = `This Decky panel is not compatible with Framey: ${failure.message}`;
|
||||
parent.postMessage({type:'framey:error', message:failure.message}, '*');
|
||||
}
|
||||
}
|
||||
start();
|
||||
})();
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Digest-scoped observations, never a trust or execution guarantee."""
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DATA = Path(__file__).with_name('framey-compatibility.json')
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _records(path):
|
||||
return json.loads(path.read_text())['plugins']
|
||||
|
||||
|
||||
def lookup(plugin_id, digest, version):
|
||||
unknown = {'status': 'unverified', 'reason': 'This exact store version has not been audited on Frame.',
|
||||
'verification': [], 'blockInstall': False}
|
||||
if type(plugin_id) is not int or not isinstance(digest, str) or not isinstance(version, str):
|
||||
return unknown
|
||||
try:
|
||||
records = _records(DATA)
|
||||
matches = [p for p in records if p['id'] == plugin_id and p['sha256'] == digest and p['version'] == version]
|
||||
if len(matches) != 1:
|
||||
return unknown
|
||||
record = matches[0]
|
||||
return {key: record[key] for key in ('status', 'reason', 'verification', 'blockInstall')}
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return unknown
|
||||
@@ -1,141 +0,0 @@
|
||||
"""Opaque VR frontend host for plugins owned by Frame Control's Decky store."""
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
|
||||
import frame_decky
|
||||
|
||||
|
||||
METHOD = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,63}\Z')
|
||||
EVENT = re.compile(r'[A-Za-z0-9_:/.-]{1,128}\Z')
|
||||
SESSION = re.compile(r'[A-Za-z0-9_.-]{1,100}\Z')
|
||||
PLUGINS = Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
|
||||
|
||||
class PanelError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def local_bundle(plugin_id):
|
||||
"""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():
|
||||
if folder.is_symlink() or not folder.is_dir():
|
||||
continue
|
||||
receipt = folder/'.frame-control-store.json'
|
||||
manifest = folder/'plugin.json'
|
||||
dist = folder/'dist'
|
||||
entry = dist/'index.js'
|
||||
if any(p.is_symlink() for p in (receipt, manifest, dist, entry)):
|
||||
continue
|
||||
try:
|
||||
if not receipt.is_file() or receipt.stat().st_size > 16_384:
|
||||
continue
|
||||
meta = json.loads(receipt.read_text())
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
if meta.get('storeId') != plugin_id:
|
||||
continue
|
||||
if not manifest.is_file() or manifest.stat().st_size > 16_384:
|
||||
raise FileNotFoundError('Framey plugin manifest unavailable')
|
||||
data = json.loads(manifest.read_text())
|
||||
if not isinstance(data, dict):
|
||||
raise FileNotFoundError('Framey plugin manifest unavailable')
|
||||
flags = data.get('flags', [])
|
||||
version = data.get('api_version')
|
||||
if ((version is not None and (type(version) is not int or version not in (1, 2))) or
|
||||
not isinstance(flags, list) or any(x in flags for x in ('root', '_root'))):
|
||||
raise FileNotFoundError('This Decky frontend has an unsupported API or needs root')
|
||||
if not entry.is_file() or entry.stat().st_size > 8*1024*1024:
|
||||
raise FileNotFoundError('Framey plugin frontend unavailable')
|
||||
bundle = entry.read_bytes()
|
||||
legacy = re.match(rb'\s*\(function\s*\(', bundle) is not None
|
||||
if version is None and not legacy:
|
||||
raise FileNotFoundError('This legacy frontend is not a supported Decky IIFE bundle')
|
||||
if version == 1 and not legacy and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle:
|
||||
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
|
||||
return bundle
|
||||
except (OSError, ValueError):
|
||||
raise FileNotFoundError('Framey plugin files are invalid')
|
||||
raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store')
|
||||
|
||||
|
||||
def panel(plugin_id):
|
||||
"""The plugin receives no Frame UI key and is sandboxed to an opaque origin."""
|
||||
encoded = base64.b64encode(local_bundle(plugin_id)).decode('ascii')
|
||||
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}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-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()
|
||||
|
||||
|
||||
METHOD_SCRIPT = r'''
|
||||
import json, pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
|
||||
from frame_steam import Page
|
||||
payload = json.loads(sys.argv[1])
|
||||
route = 'loader/call_legacy_plugin_method' if payload.get('legacy') else 'loader/call_plugin_method'
|
||||
arguments = [payload['name'], payload['method'], *payload['args']]
|
||||
expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call(" + ','.join(json.dumps(x) for x in [route, *arguments]) + ')})()'
|
||||
page = Page()
|
||||
try: print(json.dumps({'result':page.eval(expression)}))
|
||||
finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(method, str) or not METHOD.fullmatch(method):
|
||||
raise PanelError('Invalid plugin method')
|
||||
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args)) > 8192:
|
||||
raise PanelError('Plugin method arguments are too large')
|
||||
if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))):
|
||||
raise PanelError('Legacy plugin methods need one keyword argument object')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)),
|
||||
stdin=METHOD_SCRIPT, timeout=25))
|
||||
|
||||
|
||||
EVENT_SCRIPT = r'''
|
||||
import json, pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
|
||||
from frame_steam import Page
|
||||
payload = json.loads(sys.argv[1])
|
||||
source = __FRAMEY_EVENTS_SOURCE__
|
||||
page = Page()
|
||||
try: print(json.dumps(page.eval('(' + source + ')(' + json.dumps(payload) + ')')))
|
||||
finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def poll_events(ssh, plugin_id, session, events, cursor=0):
|
||||
"""Read only this store-owned plugin's subscribed events; never trust its claimed name."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(session, str) or not SESSION.fullmatch(session):
|
||||
raise PanelError('Invalid Framey event session')
|
||||
if (not isinstance(events, list) or len(events) > 32 or
|
||||
any(not isinstance(event, str) or not EVENT.fullmatch(event) for event in events)):
|
||||
raise PanelError('Invalid Decky event names')
|
||||
if type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
|
||||
raise PanelError('Invalid Decky event cursor')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'session':session, 'events':events, 'cursor':cursor}
|
||||
# Embed only our bundled source as a Python string; plugin data stays in JSON.
|
||||
source = Path(__file__).with_name('framey-events.js').read_text(encoding='utf-8')
|
||||
script = EVENT_SCRIPT.replace('__FRAMEY_EVENTS_SOURCE__', repr(source))
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)), stdin=script, timeout=25))
|
||||
+21
-205
@@ -13,12 +13,7 @@ button:focus-visible{outline:5px solid #f8d370;outline-offset:3px}button:disable
|
||||
button.secondary{background:#253c50;border-color:#617f97}button.danger{background:#78372d;border-color:#d78d79}
|
||||
small{color:#b9c9d6}#message,#actionStatus{min-height:1.5em;margin:12px 0}.game{display:flex;gap:14px;justify-content:space-between;align-items:center}
|
||||
iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid #617f97;border-radius:12px;margin-top:14px}
|
||||
#statusScreen[hidden]{display:none}#statusScreen{position:fixed;inset:0;z-index:10;overflow:auto;background:#07131f;padding:30px}
|
||||
#statusScreen h2{font-size:clamp(40px,6vw,76px);margin:10px 0 20px}#statusScreen .cards{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
#statusScreen .card{min-height:170px}#statusScreen .value{font-size:clamp(40px,6vw,76px)}
|
||||
#statusScreen .value.warn{color:#ffd27a}#statusScreen .value.bad{color:#ff9585}
|
||||
#statusScreen .meta{font-size:20px;margin-top:22px;color:#b9c9d6}
|
||||
@media(max-width:720px){.cards,#statusScreen .cards{grid-template-columns:1fr}body{padding:18px}.game{display:block}.game button{margin-top:12px}}
|
||||
@media(max-width:720px){.cards{grid-template-columns:1fr}body{padding:18px}.game{display:block}.game button{margin-top:12px}}
|
||||
</style></head><body>
|
||||
<h1>Frame Control</h1><p>Your Frame, from inside SteamVR. Select this panel again from the dashboard after switching away.</p>
|
||||
<div class="cards">
|
||||
@@ -27,36 +22,15 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
|
||||
<div class="card"><span class="label">SteamVR</span><span class="value" id="steamvr">…</span></div>
|
||||
</div>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
<div class="row"><button id="refresh">Refresh status</button><button id="statusScreenOpen" class="secondary">Large status screen</button><button id="gameMode" class="secondary">Return to Game Mode</button>
|
||||
<div class="row"><button id="refresh">Refresh status</button><button id="gameMode" class="secondary">Return to Game Mode</button>
|
||||
<button id="screenshot" class="secondary">Save screenshot</button><button id="record" class="secondary">Start recording</button></div>
|
||||
<p id="actionStatus" role="status" aria-live="polite"></p>
|
||||
<section id="statusScreen" role="dialog" aria-modal="true" aria-labelledby="statusScreenTitle" hidden>
|
||||
<div class="row"><button id="statusScreenClose">Close status screen</button><button id="statusScreenRefresh" class="secondary">Refresh</button></div>
|
||||
<h2 id="statusScreenTitle">Frame status</h2>
|
||||
<div class="cards">
|
||||
<div class="card"><span class="label">Battery</span><span class="value" id="bigBattery">…</span></div>
|
||||
<div class="card"><span class="label">SteamVR</span><span class="value" id="bigSteamvr">…</span></div>
|
||||
<div class="card"><span class="label">Home storage free</span><span class="value" id="bigStorage">…</span></div>
|
||||
<div class="card"><span class="label">Frame OS</span><span class="value" id="bigOs">…</span></div>
|
||||
</div>
|
||||
<p id="bigStatusMessage" class="meta" role="status" aria-live="polite">Read-only status. This view does not diagnose a crash.</p>
|
||||
</section>
|
||||
<h2>Running games</h2><div class="row"><button id="gamesRefresh" class="secondary">Refresh games</button></div>
|
||||
<div id="games" class="stack" style="margin-top:12px">Loading…</div>
|
||||
<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>
|
||||
<h2>Decky plugins</h2><p>Manage official store plugins here. Their backend can run on Frame, but Decky's Steam Deck Quick Access Menu is absent from this VR shell. Plugin pages, settings, routes, and toasts are not yet available in this panel.</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="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">No known install blocker</option><option value="installed">Installed plugins</option></select></label></div>
|
||||
<p id="deckyStoreSummary" role="status" aria-live="polite"></p><div id="deckyStoreList" class="stack"></div>
|
||||
<div class="row" style="margin-top:14px"><button id="deckyStorePrevious" class="secondary">Previous</button><span id="deckyStorePage"></span><button id="deckyStoreNext" class="secondary">Next</button></div></div>
|
||||
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button></div>
|
||||
<p id="frameyCompatibility"></p>
|
||||
<p id="frameyPanelStatus" role="status">Loading plugin panel…</p>
|
||||
<iframe id="frameyFrame" title="Framey Decky plugin panel" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer"></iframe></div>
|
||||
<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>
|
||||
<h2>Extensions</h2><p>Local additions run only when opened in the isolated panel below. They can read status but cannot change headset settings through the extension API.</p>
|
||||
<div class="row"><button id="extensionsRefresh" class="secondary">Refresh extensions</button><span id="extensionsFolder"><small></small></span></div>
|
||||
<div id="extensions" class="stack" style="margin-top:12px">Loading…</div>
|
||||
@@ -77,20 +51,7 @@ async function refresh(){try{const status=latestStatus=await api('/api/status');
|
||||
byId('storage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
|
||||
byId('steamvr').textContent=status.services?.steamvr?'Running':'Unavailable';
|
||||
byId('message').textContent=`Frame OS ${status.os?.version||'unknown'} · Updated ${new Date().toLocaleTimeString()}`;
|
||||
renderLargeStatus(status);
|
||||
}catch(error){latestStatus=null;byId('message').textContent=error.message;renderLargeStatus(null,error.message);}}
|
||||
function renderLargeStatus(status,error=''){
|
||||
const battery=status?.battery?.percent;
|
||||
byId('bigBattery').textContent=Number.isFinite(battery)?`${battery}%`:'Unavailable';
|
||||
byId('bigBattery').className='value'+(Number.isFinite(battery)&&battery<20?' bad':Number.isFinite(battery)&&battery<40?' warn':'');
|
||||
byId('bigSteamvr').textContent=status?.services?.steamvr?'Running':'Unavailable';
|
||||
byId('bigSteamvr').className='value'+(status?.services?.steamvr?'':' bad');
|
||||
const free=status?.disk?.home?.free;
|
||||
byId('bigStorage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
|
||||
byId('bigStorage').className='value'+(Number.isFinite(free)&&free<5e9?' warn':'');
|
||||
byId('bigOs').textContent=status?.os?.version||'Unknown';
|
||||
byId('bigStatusMessage').textContent=error||`Read-only status · Frame OS build ${status?.os?.build||'unknown'} · Updated ${new Date().toLocaleTimeString()}. This view does not diagnose a crash.`;
|
||||
}
|
||||
}catch(error){latestStatus=null;byId('message').textContent=error.message;}}
|
||||
async function act(action){const button=byId(action==='game-mode'?'gameMode':'screenshot');button.disabled=true;
|
||||
try{const result=await api('/api/headset/quick',{action});byId('actionStatus').textContent=result.message;}
|
||||
catch(error){byId('actionStatus').textContent=error.message;}finally{button.disabled=false;}}
|
||||
@@ -104,170 +65,34 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
|
||||
catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}};
|
||||
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
|
||||
}catch(error){list.textContent=error.message;}}
|
||||
function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
|
||||
activeFrameyId=null, frameySession=null, deckyBusy=false;
|
||||
let deckyState=null, deckyStore=[];
|
||||
async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
|
||||
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
|
||||
'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.':'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);
|
||||
const note=document.createElement('p');note.textContent=compatibilityText(plugin);row.append(note);
|
||||
if(plugin.storeId){const open=document.createElement('button');open.className='secondary';open.textContent='Open panel';
|
||||
open.onclick=()=>openFrameyPanel(plugin.storeId,plugin.name);row.append(open);
|
||||
const button=document.createElement('button');button.className='danger';button.textContent='Remove';
|
||||
button.disabled=deckyBusy;button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
|
||||
if(plugin.storeId){const button=document.createElement('button');button.className='danger';button.textContent='Remove';
|
||||
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(){const request=++deckyStoreRequest;byId('deckyStorePanel').hidden=false;
|
||||
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
|
||||
byId('deckyStoreSummary').textContent='Loading official Decky store…';
|
||||
try{const data=await api('/api/decky/store');if(request!==deckyStoreRequest)return;
|
||||
deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
|
||||
catch(error){if(request!==deckyStoreRequest)return;
|
||||
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
|
||||
byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
|
||||
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;}}
|
||||
function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren();
|
||||
const q=byId('deckySearch').value.trim().toLowerCase();
|
||||
const filter=byId('deckyStoreFilter').value;
|
||||
const found=deckyStore.filter(p=>(!q||`${p.name} ${p.author||''} ${p.description}`.toLowerCase().includes(q))&&
|
||||
(filter==='all'||(filter==='compatible'&&!p.root&&!p.compatibility?.blockInstall)||(filter==='installed'&&(deckyState?.plugins||[]).some(x=>x.storeId===p.id))));
|
||||
const size=20, pages=Math.max(1,Math.ceil(found.length/size));
|
||||
deckyStoreIndex=Math.min(deckyStoreIndex,pages-1);
|
||||
byId('deckyStoreSummary').textContent=`${found.length} plugin${found.length===1?'':'s'} found`;
|
||||
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)){
|
||||
for(const plugin of deckyStore.filter(p=>!q||`${p.name} ${p.description}`.toLowerCase().includes(q)).slice(0,30)){
|
||||
const row=document.createElement('div');row.className='block game';
|
||||
const label=document.createElement('div');label.textContent=`${plugin.name} ${plugin.version}${plugin.author?' · '+plugin.author:''} · ${plugin.description}`;
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version} · ${plugin.description}`;
|
||||
const button=document.createElement('button');button.textContent='Install';
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===plugin.id);
|
||||
if(installed)button.textContent=installed.version===plugin.version?'Reinstall':'Update';
|
||||
button.disabled=deckyBusy||!deckyState?.running||plugin.root||!!plugin.compatibility?.blockInstall;
|
||||
const note=document.createElement('p');note.textContent=compatibilityText(plugin);label.append(note);
|
||||
if(plugin.root && !plugin.compatibility?.blockInstall)label.append(' · Needs root; not supported on Frame');
|
||||
button.disabled=!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);}}
|
||||
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,
|
||||
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
|
||||
if(!started.job)throw Error('The server did not return a job ID');
|
||||
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
|
||||
await followDeckyJob(started.job,action,plugin.name);
|
||||
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){
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
|
||||
byId('frameyCompatibility').textContent=compatibilityText(installed);
|
||||
closeFrameyEvents();
|
||||
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
|
||||
byId('frameyPanelStatus').textContent='Loading plugin panel…';
|
||||
byId('frameyFrame').src=`/framey/panel/${id}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
|
||||
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
|
||||
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
|
||||
byId('frameyPanelStatus').textContent='No compatible panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
||||
}
|
||||
let frameyEventsActive=false;
|
||||
function closeFrameyEvents(){
|
||||
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
frameyEventsActive=false;
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{closeFrameyEvents();activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
function frameyCacheRead(id){
|
||||
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
|
||||
if(!data||typeof data!=='object'||Array.isArray(data))return {};
|
||||
const pairs=Object.entries(data);
|
||||
return pairs.length<=64&&pairs.every(([key,value])=>key.length<=256&&typeof value==='string'&&value.length<=65536)&&
|
||||
JSON.stringify(data).length<=128*1024?data:{};
|
||||
}catch(_){return {};}
|
||||
}
|
||||
function frameyCacheSave(id,entries){
|
||||
if(!entries||typeof entries!=='object'||Array.isArray(entries))return;
|
||||
const pairs=Object.entries(entries);
|
||||
if(pairs.length>64||pairs.some(([key,value])=>key.length>256||typeof value!=='string'||value.length>65536))return;
|
||||
const encoded=JSON.stringify(entries);
|
||||
if(encoded.length>128*1024)return;
|
||||
try{localStorage.setItem(frameyCacheKey(id),encoded);}catch(_){} // Private browsing can deny storage.
|
||||
}
|
||||
window.addEventListener('message',async event=>{
|
||||
if(activeFrameyId===null||event.source!==byId('frameyFrame').contentWindow)return;
|
||||
const req=event.data;if(!req||typeof req!=='object')return;
|
||||
if(req.type==='framey:cache:init'&&req.session===frameySession){
|
||||
event.source.postMessage({type:'framey:cache:seed',session:frameySession,
|
||||
entries:frameyCacheRead(activeFrameyId)},'*');return;
|
||||
}
|
||||
if(req.type==='framey:cache:save'){
|
||||
if(req.session===frameySession)frameyCacheSave(activeFrameyId,req.entries);
|
||||
return;
|
||||
}
|
||||
if(req.type==='framey:ready'){byId('frameyPanelStatus').textContent='Plugin panel ready';
|
||||
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
|
||||
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
||||
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type==='framey:events:close'){
|
||||
if(req.session===frameySession)closeFrameyEvents();return;
|
||||
}
|
||||
if(req.type==='framey:events'){
|
||||
if(req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||!Array.isArray(req.args)||
|
||||
req.args.length!==2||!Array.isArray(req.args[0])||req.args[0].length>32||
|
||||
req.args[0].some(name=>typeof name!=='string'||!/^[A-Za-z0-9_:/.-]{1,128}$/.test(name))||
|
||||
!Number.isSafeInteger(req.args[1])||req.args[1]<0)return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
frameyEventsActive=req.args[0].length>0;
|
||||
try{reply.result=await api('/api/framey/events',{id:pluginId,session,events:req.args[0],cursor:req.args[1]});}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
|
||||
event.source.postMessage(reply,'*');
|
||||
return;
|
||||
}
|
||||
if(req.type!=='framey:call'||req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
});
|
||||
window.addEventListener('pointerup',event=>{if(event.isTrusted)
|
||||
api('/api/framey/check',{kind:'input'}).catch(()=>{});},{passive:true});
|
||||
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});
|
||||
byId('deckyState').textContent=job.message||'Request queued. Refresh after it finishes.';
|
||||
setTimeout(refreshDecky,5000);}catch(error){byId('deckyState').textContent=error.message;}}
|
||||
async function refreshRecord(){try{const data=await api('/api/record');
|
||||
byId('record').textContent=['recording','starting'].includes(data.state)?'Stop recording':'Start recording';
|
||||
byId('record').disabled=recordBusy||['stopping','saving'].includes(data.state);
|
||||
@@ -290,16 +115,8 @@ async function refreshExtensions(){const list=byId('extensions');try{const data=
|
||||
if(data.errors.length){const errors=document.createElement('p');errors.textContent=data.errors.map(x=>`${x.folder}: ${x.error}`).join('; ');list.append(errors);}
|
||||
}catch(error){list.textContent=error.message;}}
|
||||
byId('refresh').onclick=refresh;byId('gameMode').onclick=()=>act('game-mode');byId('screenshot').onclick=()=>act('screenshot');
|
||||
byId('statusScreenOpen').onclick=()=>{byId('statusScreen').hidden=false;renderLargeStatus(latestStatus);byId('statusScreenClose').focus();};
|
||||
byId('statusScreenClose').onclick=()=>{byId('statusScreen').hidden=true;byId('statusScreenOpen').focus();};
|
||||
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=()=>{deckyStoreIndex=0;showDeckyStore();};
|
||||
byId('deckyStoreFilter').onchange=()=>{deckyStoreIndex=0;showDeckyStore();};
|
||||
byId('deckyStorePrevious').onclick=()=>{deckyStoreIndex--;showDeckyStore();};
|
||||
byId('deckyStoreNext').onclick=()=>{deckyStoreIndex++;showDeckyStore();};
|
||||
byId('deckyRefresh').onclick=refreshDecky;byId('deckyStoreLoad').onclick=loadDeckyStore;byId('deckySearch').oninput=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;
|
||||
@@ -309,6 +126,5 @@ 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>
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user