mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 08:01:53 +01:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587a7fc8c3 | ||
|
|
081d133084 | ||
|
|
94e050d5a0 | ||
|
|
cd6ecf073f | ||
|
|
3d6bd463ce | ||
|
|
981173a1f2 | ||
|
|
9146234758 | ||
|
|
c6e54041e0 | ||
|
|
e67a90b397 | ||
|
|
91f60c26e3 | ||
|
|
cb9ad1b6cb | ||
|
|
285871bc39 | ||
|
|
779f9c5a53 | ||
|
|
43112f5143 | ||
|
|
0e6c00525d | ||
|
|
416c574240 | ||
|
|
d77e2bd056 | ||
|
|
2a6736a02c | ||
|
|
c8ef81ad93 | ||
|
|
e529d60554 | ||
|
|
e9273beaa1 | ||
|
|
48b404bc3b | ||
|
|
aeb991bc1c | ||
|
|
63820d1909 | ||
|
|
1aaa3c8c06 | ||
|
|
3f4aaf539c | ||
|
|
3acc2d91e6 | ||
|
|
0867debebd |
No files matched your search
@@ -4,7 +4,7 @@ Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.31 candidate source `1255ef2`, Frame OS 0.5.6 build `20261022.6153909`: [sanitized results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.36 tag `1701e62`, Frame OS 0.5.6 build `20261022.6153909`: [release smoke evidence](evidence/v0436-release-smoke-2026-10-25.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
|
||||
+16
-5
@@ -44,9 +44,9 @@ For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-aut
|
||||
|
||||
### Which of these plugins can open a Framey panel?
|
||||
|
||||
The separate Framey panel accepts a Frame Control store install when its
|
||||
`plugin.json` declares API v1 or v2 and it has a modern Decky frontend bundle
|
||||
in `dist/index.js`. A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
The separate Framey panel accepts a Frame Control store install with a supported
|
||||
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
|
||||
A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
|
||||
catalogue. All 11 had a frontend bundle, but their manifests differed:
|
||||
|
||||
@@ -54,16 +54,27 @@ catalogue. All 11 had a frontend bundle, but their manifests differed:
|
||||
| --- | --- | --- |
|
||||
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
|
||||
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | Framey refuses these frontends. Audio Loader and PowerTools also request root. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
|
||||
|
||||
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
|
||||
has each store ID, exact version, SHA-256, manifest field, and bundle check.
|
||||
The archive audit itself is static PC inspection. Later local host and fake
|
||||
Frame tests cover the Storage Cleaner and HLTB cases noted above; none shows
|
||||
Frame tests cover the Storage Cleaner, HLTB, CSS Loader and SteamGridDB cases noted above; none shows
|
||||
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
|
||||
remain the only device evidence for these versions. A new store version needs a
|
||||
new check because its manifest and binaries can change.
|
||||
|
||||
Pinned CSS Loader 2.1.3 and SteamGridDB 1.8.1 archives now pass the fake Frame
|
||||
managed install/remove flow. CSS Loader's `plugin.json` has no `api_version` and
|
||||
its older IIFE bundle passes the Framey packaging check added in
|
||||
[#261](https://github.com/saphid/frame-control/pull/261). The fixture does not
|
||||
render its theme controls. SteamGridDB declares API v1 and its bundle passes Framey's packaging
|
||||
check, but it looks up Steam library modules while loading and patches game
|
||||
context menus. Those modules and menus do not exist in Framey's isolated
|
||||
iframe, so its artwork picker cannot be called a usable VR panel. Framey does
|
||||
not apply its Steam library changes. These checks did not run CSS themes or
|
||||
modify game artwork on the real Frame.
|
||||
|
||||
The new store transaction was also exercised on the same real Frame:
|
||||
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
|
||||
store update → store remove → managed loader remove**. Status reported the
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Legacy Decky frontend bridge
|
||||
|
||||
The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures)
|
||||
has no `api_version` field and returns its initializer from a Decky IIFE.
|
||||
Framey now wraps that expression in an ES module inside the existing
|
||||
opaque-origin iframe. It passes a legacy `serverAPI` to the initializer.
|
||||
|
||||
Local Chromium ran the unchanged official frontend, including React effects,
|
||||
with all backend calls rejected and network access blocked. It emitted
|
||||
`framey:ready` without a JavaScript startup error during the observation window.
|
||||
This proves initialization, not playback, MPRIS access, or a useful populated UI.
|
||||
|
||||
The fake Frame installed, packed, checked the frontend bytes and removed this
|
||||
exact archive. Its synthetic Sample Panel also read a legacy keyword argument,
|
||||
wrote a setting, and read that setting through the modern API. This exercised
|
||||
the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was
|
||||
checked against Decky's `handle_plugin_method_call_legacy`: it calls with a
|
||||
keyword object and returns `{success, result}`. Invalid argument shapes and
|
||||
unmanaged plugin IDs are rejected.
|
||||
|
||||
Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'`
|
||||
(21 passed), `node tests/page/framey_actions.mjs`, and
|
||||
`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or
|
||||
headset state was changed by these checks. Real Frame backend behavior,
|
||||
controller input, playback and worn VR use remain unverified.
|
||||
@@ -0,0 +1,119 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,2 @@
|
||||
{"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}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
[
|
||||
{
|
||||
"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.
|
After Width: | Height: | Size: 150 KiB |
@@ -0,0 +1,189 @@
|
||||
{
|
||||
"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.
|
After Width: | Height: | Size: 107 KiB |
@@ -0,0 +1,24 @@
|
||||
# v0.4.36 release smoke on a real Frame
|
||||
|
||||
On 25 October 2026 at 22:59 UTC, the release branch ran
|
||||
`scripts/frame-smoke.sh` from a physical Linux host against a real Steam Frame.
|
||||
The script and its Python implementation were byte-identical to the
|
||||
`v0.4.36` tag `1701e622dc1f9888560412a058fe816b19ca7e79`.
|
||||
The headset reported Frame OS 0.5.6, build `20261022.6153909`.
|
||||
|
||||
The test acquired `/tmp/frame-test.lock` with a unique owner marker before
|
||||
reading device status or changing test titles. Its first read showed SteamVR
|
||||
activity level 3 (standby), proximity raw 6.3125, and battery 99%. The test
|
||||
ran only while that lock was owned. It released only its own marker and lock
|
||||
after the final cleanup.
|
||||
|
||||
All **13 of 13** smoke steps passed: cleanup of reserved leftovers, devkit
|
||||
service read, status read, install/launch/remove for reserved ARM64, x86_64
|
||||
and Windows test titles, and final cleanup. The test did not pair a key. The
|
||||
test output was saved locally under `/tmp/frame-v0436-smoke-results/`; this
|
||||
summary omits the headset identifier and private device status fields.
|
||||
|
||||
The headset was in standby. This test does not show wearer-visible game or
|
||||
Framey pixels, controller input, live Decky settings, or plugin actions.
|
||||
See [release v0.4.36](https://github.com/saphid/frame-control/releases/tag/v0.4.36)
|
||||
for the tagged installer and checksum checks.
|
||||
@@ -36,7 +36,7 @@ remain unavailable. Wearer-visible use is not yet verified.
|
||||
The [settings sample](../examples/framey-settings/README.md) shows an official
|
||||
Decky JSX build, backend persistence, a toggle and a local toast. Framey offers
|
||||
only official-store-owned plugins in its VR panel.
|
||||
For persistent backend settings, use Decky's `decky.decky_SETTINGS_DIR` rather
|
||||
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.
|
||||
@@ -57,15 +57,20 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| Decky API or UI feature | Framey behavior | Verified in |
|
||||
| --- | --- | --- |
|
||||
| `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test |
|
||||
| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; older API v1 bundles still show an incompatibility message | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
|
||||
| API v1 manifest with the modern Decky API initializer | Attempts to render the plugin's panel; recognized legacy IIFE bundles also use the legacy bridge | Pinned Storage Cleaner 1.4.0 archive, fake Frame install/pack test, local Chromium with stub backend. Cleanup actions were not tested. |
|
||||
| Legacy Decky IIFE frontend, with no API manifest field | Wraps the returned initializer as an ES module inside the same opaque iframe; passes `serverAPI` and preserves the legacy title | MusicControl 1.1.6 exact official archive, local Chromium startup with backend methods blocked; fake Frame installation/pack/removal |
|
||||
| Legacy `serverAPI.callPluginMethod(method, kwargs)` | Uses Decky's `loader/call_legacy_plugin_method` RPC, preserving keyword arguments and `{success, result}` replies | Fake Frame HTTP/CDP read/write with a synthetic backend; no official plugin backend action or worn VR result |
|
||||
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
|
||||
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
|
||||
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
|
||||
| `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 for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification |
|
||||
| `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. |
|
||||
|
||||
+25
-2
@@ -24,7 +24,10 @@ and swaps one managed plugin
|
||||
folder with rollback on failure. It rejects non-ARM64 ELF and Windows binaries.
|
||||
The Decky user service restarts after a file change. Framey's panel host accepts
|
||||
API v2 bundles and API v1 bundles using Decky's modern frontend initializer,
|
||||
with `dist/index.js`; other installed plugins remain manageable but have no
|
||||
with `dist/index.js`. Legacy `(function (...) {...})(DFL, SP_REACT)` bundles are
|
||||
also accepted: their returned initializer receives the legacy `serverAPI`, and
|
||||
keyword backend calls use Decky's legacy RPC. The wrapper runs as a blob ES
|
||||
module inside the same sandbox; it does not require `eval`. Other installed plugins remain manageable but have no
|
||||
Framey panel. API v1 admission is a compatibility attempt, not a guarantee
|
||||
that the plugin's controls work in VR. The store manager does not currently
|
||||
screen a plugin's runtime network downloads.
|
||||
@@ -80,6 +83,15 @@ 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.
|
||||
|
||||
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
|
||||
@@ -159,7 +171,7 @@ The Decky and headset service manual removal commands are in
|
||||
## Fast worn-headset check
|
||||
|
||||
With an already installed Framey plugin and someone wearing the headset, run
|
||||
`python3 scripts/framey-worn-check.py` from a paired computer. The script
|
||||
`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.
|
||||
@@ -172,3 +184,14 @@ 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.
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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,7 +2,7 @@
|
||||
|
||||
This is a small Decky API v2 plugin for authors. Its panel uses the official
|
||||
`@decky/api`, `@decky/ui`, and `@decky/rollup` packages. A toggle calls a Python
|
||||
backend method, stores one boolean under `decky.decky_SETTINGS_DIR`,
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ import decky
|
||||
|
||||
def settings_file():
|
||||
# Decky provides this per-plugin path through its official Python module.
|
||||
return Path(decky.decky_SETTINGS_DIR) / 'settings.json'
|
||||
return Path(decky.DECKY_PLUGIN_SETTINGS_DIR) / 'settings.json'
|
||||
|
||||
|
||||
class Plugin:
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/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())
|
||||
@@ -4,6 +4,7 @@
|
||||
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
|
||||
@@ -18,10 +19,16 @@ 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, command], input=stdin, capture_output=True,
|
||||
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}')
|
||||
@@ -92,12 +99,14 @@ def color_count(text):
|
||||
return len(set(re.findall(r'#[0-9A-Fa-f]{6,8}', text)))
|
||||
|
||||
|
||||
def main():
|
||||
result = {'time':datetime.datetime.now(datetime.timezone.utc).isoformat(), 'proof':'worn-headset check'}
|
||||
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; }; }')
|
||||
@@ -126,11 +135,11 @@ def main():
|
||||
time.sleep(1)
|
||||
result['interaction'] = state
|
||||
result['after'] = probe()
|
||||
result['worn'] = isinstance(result['after']['activity'], int) and result['after']['activity'] < 3
|
||||
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['controllerInput'] = bool(state.get('input', {}).get('at', 0) > state.get('started', 0))
|
||||
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']:
|
||||
@@ -138,7 +147,7 @@ def main():
|
||||
local = Path('/tmp')/('framey-worn-' + token + '.png')
|
||||
try:
|
||||
with local.open('wb') as out:
|
||||
copy = subprocess.run([*SSH, 'cat ' + shlex.quote(shot)], stdout=out, stderr=subprocess.PIPE, timeout=20)
|
||||
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:-'],
|
||||
@@ -147,7 +156,7 @@ def main():
|
||||
finally:
|
||||
local.unlink(missing_ok=True)
|
||||
remote('rm -f ' + shlex.quote(shot))
|
||||
result['passed'] = (result['worn'] and result['panelVisible'] and result['controllerInput'] and
|
||||
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)
|
||||
@@ -167,4 +176,11 @@ def main():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(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))
|
||||
@@ -0,0 +1,117 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,140 @@
|
||||
"""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()
|
||||
@@ -10,7 +10,9 @@ import threading
|
||||
import zipfile
|
||||
|
||||
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh
|
||||
from framey_official import PLUGINS as OFFICIAL_PLUGINS, archive as official_archive
|
||||
from framey_official import LEGACY_PLUGINS, PLUGINS as PANEL_PLUGINS, archive as official_archive
|
||||
|
||||
OFFICIAL_PLUGINS = PANEL_PLUGINS + LEGACY_PLUGINS
|
||||
|
||||
require() # Skip the module outside the disposable fake Frame, like its peers.
|
||||
|
||||
@@ -122,6 +124,15 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}],
|
||||
'legacy':True})['result'], {'success':True,'result':'light'})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}],
|
||||
'legacy':True})['result'], {'success':True,'result':True})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
{'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved')
|
||||
|
||||
StoreFixture.release = 1
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':999,'acknowledged':True}))
|
||||
@@ -148,7 +159,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertIn('version changed', stale.get('error', ''))
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
|
||||
|
||||
def test_pinned_official_api_v1_and_v2_plugins_install_and_remove(self):
|
||||
def test_pinned_official_plugins_install_and_remove(self):
|
||||
self.backend()
|
||||
catalog = {plugin['id']:plugin for plugin in ok('GET', '/api/decky/store')['plugins']}
|
||||
for plugin in OFFICIAL_PLUGINS:
|
||||
@@ -165,7 +176,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
|
||||
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
|
||||
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['api_version'], plugin['api_version'])
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':plugin['id'],'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
|
||||
@@ -28,10 +28,20 @@ function build(steam) {
|
||||
// Synthetic Decky API for Framey's CDP bridge test. This does not model the
|
||||
// real loader or prove frontend visibility on a headset.
|
||||
const deckyBackend = {async call(route, plugin, method, ...args) {
|
||||
if (route !== 'loader/call_plugin_method' || plugin !== 'Sample Panel')
|
||||
if (!['loader/call_plugin_method','loader/call_legacy_plugin_method'].includes(route) || plugin !== 'Sample Panel')
|
||||
throw Error('unexpected Decky plugin route');
|
||||
const settings = (steam.decky_settings ||= {});
|
||||
const owned = (settings[plugin] ||= {});
|
||||
if (route === 'loader/call_legacy_plugin_method') {
|
||||
const kwargs = args[0];
|
||||
if (method === 'get_setting')
|
||||
return {success:true, result:Object.hasOwn(owned, kwargs.key) ? owned[kwargs.key] : kwargs.default};
|
||||
if (method === 'set_setting') {
|
||||
owned[kwargs.key] = kwargs.value;
|
||||
return {success:true, result:true};
|
||||
}
|
||||
return {success:false, result:'method unavailable in fake Decky backend'};
|
||||
}
|
||||
if (method === 'get_setting')
|
||||
return Object.hasOwn(owned, args[0]) ? owned[args[0]] : args[1];
|
||||
if (method === 'set_setting') {
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+2
@@ -7,6 +7,7 @@ from a local fixture so CI does not depend on the store being online.
|
||||
|
||||
| File | Official plugin | Version | Store SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
|
||||
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
|
||||
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
|
||||
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
|
||||
@@ -14,6 +15,7 @@ from a local fixture so CI does not depend on the store being online.
|
||||
| `26.zip` | HLTB for Deck | 2.0.12 | `bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558` |
|
||||
|
||||
The matching source projects are
|
||||
[MusicControl](https://github.com/nikurasu/MusicControl),
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky),
|
||||
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner),
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||
PLUGINS = (
|
||||
{'id': 12, 'name': 'MusicControl', 'version': '1.1.6', 'api_version': 'legacy', 'folder': 'MusicControl', 'sha256': '246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62'},
|
||||
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2,
|
||||
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
|
||||
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
|
||||
@@ -14,6 +15,14 @@ PLUGINS = (
|
||||
'folder': 'Bluetooth', 'sha256': 'f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5'},
|
||||
{'id': 26, 'name': 'HLTB for Deck', 'version': '2.0.12', 'api_version': 1,
|
||||
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
|
||||
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
|
||||
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
|
||||
)
|
||||
|
||||
# This archive has a legacy IIFE frontend with no manifest API version.
|
||||
LEGACY_PLUGINS = (
|
||||
{'id': 7, 'name': 'CSS Loader', 'version': '2.1.3', 'api_version': 'legacy',
|
||||
'folder': 'SDH-CssLoader', 'sha256': '99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface;\n})();');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI;\n})();');
|
||||
assert.notEqual(testSource,source);
|
||||
const states=[];
|
||||
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
@@ -12,7 +12,11 @@ class Element {constructor(){this.children=[];this.textContent='';this.attribute
|
||||
append(...children){this.children.push(...children);}setAttribute(key,value){this.attributes[key]=value;}
|
||||
remove(){this.removed=true;}}
|
||||
const elements=new Map();
|
||||
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
|
||||
const 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()},
|
||||
@@ -101,6 +105,31 @@ 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);
|
||||
@@ -111,6 +140,56 @@ 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.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);
|
||||
|
||||
@@ -21,6 +21,7 @@ const parent=vm.createContext({document:{getElementById:el,createElement:()=>({}
|
||||
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(){
|
||||
@@ -49,6 +50,11 @@ 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,1);
|
||||
|
||||
@@ -60,6 +60,7 @@ 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'}]} :
|
||||
@@ -81,6 +82,28 @@ await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"b".rep
|
||||
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'}));
|
||||
|
||||
@@ -17,10 +17,29 @@ import io
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_decky_store
|
||||
import framey_panel
|
||||
from framey_official import PLUGINS, archive
|
||||
from framey_official import LEGACY_PLUGINS, PLUGINS, archive
|
||||
|
||||
|
||||
class OfficialBundles(unittest.TestCase):
|
||||
def test_legacy_css_loader_store_archive_has_unversioned_frontend(self):
|
||||
plugin = LEGACY_PLUGINS[0]
|
||||
data = archive(plugin)
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
|
||||
self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']), plugin['folder'])
|
||||
with tempfile.TemporaryDirectory() as root, zipfile.ZipFile(io.BytesIO(data)) as bundle:
|
||||
manifest = json.loads(bundle.read(plugin['folder'] + '/plugin.json'))
|
||||
self.assertNotIn('api_version', manifest)
|
||||
frontend = bundle.read(plugin['folder'] + '/dist/index.js')
|
||||
self.assertIn(b'deckyFrontendLib.definePlugin', frontend)
|
||||
bundle.extractall(root)
|
||||
folder = Path(root) / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
with patch.object(framey_panel, 'PLUGINS', Path(root)):
|
||||
page = framey_panel.panel(plugin['id']).decode()
|
||||
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
|
||||
self.assertIsNotNone(encoded)
|
||||
self.assertEqual(base64.b64decode(encoded.group(1)), frontend)
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
|
||||
def test_bluetooth_initial_panel_uses_safe_token_fallback(self):
|
||||
with zipfile.ZipFile(io.BytesIO(archive(next(p for p in PLUGINS if p['id'] == 18)))) as bundle:
|
||||
@@ -49,7 +68,7 @@ class OfficialBundles(unittest.TestCase):
|
||||
plugin['folder'])
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as z:
|
||||
manifest = json.loads(z.read(plugin['folder'] + '/plugin.json'))
|
||||
self.assertEqual(manifest['api_version'], plugin['api_version'])
|
||||
self.assertEqual(manifest.get('api_version', 'legacy'), plugin['api_version'])
|
||||
z.extractall(plugins)
|
||||
folder = plugins / plugin['folder']
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
|
||||
@@ -58,6 +58,31 @@ class Panel(unittest.TestCase):
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.panel(15)
|
||||
|
||||
def test_legacy_iife_is_admitted_without_api_manifest(self):
|
||||
(self.folder/'plugin.json').write_text(json.dumps({'name':'Legacy', 'flags':[]}))
|
||||
(self.folder/'dist/index.js').write_text(
|
||||
'(function(DFL, React){return DFL.definePlugin(serverAPI=>({content:React.createElement("p",null,"Legacy")}));})(DFL, SP_REACT);')
|
||||
self.assertIn('framey-bundle', framey_panel.panel(14).decode())
|
||||
(self.folder/'dist/index.js').write_text('window.unrecognized = true;')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.panel(14)
|
||||
|
||||
def test_legacy_keyword_rpc_is_scoped_and_validated(self):
|
||||
import shlex
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
payload = json.loads(shlex.split(command)[2])
|
||||
self.assertEqual(payload, {'name':'Legacy', 'method':'read_setting',
|
||||
'args':[{'key':'mode'}], 'legacy':True})
|
||||
self.assertIn('loader/call_legacy_plugin_method', stdin)
|
||||
return json.dumps({'result':{'success':True, 'result':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'Legacy'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True),
|
||||
{'result':{'success':True, 'result':'dark'}})
|
||||
for args, legacy in [([],True), ([[]],True), ([{},{}],True), ([{}],'true')]:
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy)
|
||||
|
||||
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
|
||||
manifest = self.folder/'plugin.json'
|
||||
manifest.write_text(json.dumps({'name':'ProtonDB Badges','api_version':0}))
|
||||
|
||||
@@ -15,7 +15,7 @@ EXAMPLE = Path(__file__).resolve().parents[1] / 'examples/framey-settings/main.p
|
||||
|
||||
def backend(settings_dir):
|
||||
decky = types.ModuleType('decky')
|
||||
decky.decky_SETTINGS_DIR = settings_dir
|
||||
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}):
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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)
|
||||
@@ -20,9 +20,15 @@ class WornGate(unittest.TestCase):
|
||||
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()
|
||||
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}
|
||||
|
||||
+123
-13
@@ -57,16 +57,19 @@
|
||||
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 ask(method, args) {
|
||||
function ask(method, args, legacy=false) {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(Error('Decky backend timed out')); }, 30000);
|
||||
pending.set(id, {resolve, reject, timer});
|
||||
parent.postMessage({type:'framey:call', id, method, args}, '*');
|
||||
parent.postMessage({type:'framey:call', id, method, args, legacy}, '*');
|
||||
});
|
||||
}
|
||||
addEventListener('message', event => {
|
||||
@@ -133,6 +136,25 @@
|
||||
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'));
|
||||
@@ -189,34 +211,107 @@
|
||||
// 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,
|
||||
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: element => modalRoot.render(R.createElement('section', null,
|
||||
R.createElement('button', {onClick:closeModal}, 'Close'), element)),
|
||||
showModal, showContextMenu:showModal,
|
||||
// Steam app, library, and store patches have no target inside this panel.
|
||||
afterPatch: () => () => {}, createReactTreePatcher: () => () => {},
|
||||
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']),
|
||||
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
|
||||
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
|
||||
UI:register(['RegisterForFocusChangeEvents'])},
|
||||
Input:register(['RegisterForControllerInputMessages','RegisterForControllerStateChanges']),
|
||||
User:register(['RegisterForLoginStateChange','RegisterForPrepareForSystemSuspendProgress',
|
||||
'RegisterForResumeSuspendedGamesProgress','RegisterForCurrentUserChanges']),
|
||||
Overlay:register(['RegisterForOverlayActivated']),
|
||||
UI:register(['RegisterForUIModeChanged']),
|
||||
};
|
||||
DFL.Router = {...DFL.Navigation, MainRunningApp:undefined};
|
||||
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
|
||||
const toaster = {toast: ({title, body, duration, showToast=true, onClick}={}) => {
|
||||
const data = {title, body};
|
||||
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 heading = document.createElement('strong'); heading.textContent = String(title || 'Plugin notice');
|
||||
const detail = document.createElement('p'); detail.textContent = String(body || '');
|
||||
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';
|
||||
const dismiss = () => {item.remove(); clearTimeout(timer);};
|
||||
close.onclick=dismiss; item.append(heading, detail, close);
|
||||
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);
|
||||
@@ -281,15 +376,30 @@
|
||||
useQuickAccessVisible: () => true,
|
||||
}),
|
||||
};
|
||||
const legacyServerAPI = {
|
||||
routerHook, toaster,
|
||||
callPluginMethod: (method, kwargs={}) => {
|
||||
if (!kwargs || typeof kwargs !== 'object' || Array.isArray(kwargs))
|
||||
return Promise.resolve({success:false, result:'Legacy plugin arguments must be an object'});
|
||||
// Decky's legacy route takes keyword arguments and returns this envelope.
|
||||
return ask(method, [kwargs], true).catch(failure=>({success:false, result:String(failure.message || failure)}));
|
||||
},
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
};
|
||||
async function start() {
|
||||
try {
|
||||
await seeded;
|
||||
const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0));
|
||||
const url = URL.createObjectURL(new Blob([bytes], {type:'text/javascript'}));
|
||||
// Old Decky builds are expressions returning the initializer, rather than
|
||||
// ES modules. A module wrapper preserves the sandbox and avoids eval.
|
||||
const legacy = /^\s*\(function\s*\(/.test(new TextDecoder().decode(bytes));
|
||||
const url = URL.createObjectURL(new Blob(legacy ?
|
||||
['const __frameyLegacy =\n', bytes, '\nexport default __frameyLegacy;'] : [bytes], {type:'text/javascript'}));
|
||||
let module;
|
||||
try { module = await import(url); } finally { URL.revokeObjectURL(url); }
|
||||
if (typeof module.default !== 'function') throw Error('The Decky frontend has no plugin entry point');
|
||||
plugin = await module.default();
|
||||
plugin = await module.default(legacyServerAPI);
|
||||
if (legacy && plugin?.title && !plugin.titleView) plugin.titleView = plugin.title;
|
||||
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
|
||||
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
|
||||
R.createElement(PluginSurface, {plugin})));
|
||||
|
||||
+14
-6
@@ -44,12 +44,16 @@ def local_bundle(plugin_id):
|
||||
raise FileNotFoundError('Framey plugin manifest unavailable')
|
||||
flags = data.get('flags', [])
|
||||
version = data.get('api_version')
|
||||
if type(version) is not int or version not in (1, 2) or not isinstance(flags, list) or any(x in flags for x in ('root', '_root')):
|
||||
raise FileNotFoundError('This Decky frontend is not API v1/v2 or needs root')
|
||||
if ((version is not None and (type(version) is not int or version not in (1, 2))) or
|
||||
not isinstance(flags, list) or any(x in flags for x in ('root', '_root'))):
|
||||
raise FileNotFoundError('This Decky frontend has an unsupported API or needs root')
|
||||
if not entry.is_file() or entry.stat().st_size > 8*1024*1024:
|
||||
raise FileNotFoundError('Framey plugin frontend unavailable')
|
||||
bundle = entry.read_bytes()
|
||||
if version == 1 and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle:
|
||||
legacy = re.match(rb'\s*\(function\s*\(', bundle) is not None
|
||||
if version is None and not legacy:
|
||||
raise FileNotFoundError('This legacy frontend is not a supported Decky IIFE bundle')
|
||||
if version == 1 and not legacy and b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit' not in bundle:
|
||||
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
|
||||
return bundle
|
||||
except (OSError, ValueError):
|
||||
@@ -76,23 +80,27 @@ import json, pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
|
||||
from frame_steam import Page
|
||||
payload = json.loads(sys.argv[1])
|
||||
expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call('loader/call_plugin_method'," + json.dumps(payload['name']) + ',' + json.dumps(payload['method']) + ',' + ','.join(json.dumps(x) for x in payload['args']) + ')})()'
|
||||
route = 'loader/call_legacy_plugin_method' if payload.get('legacy') else 'loader/call_plugin_method'
|
||||
arguments = [payload['name'], payload['method'], *payload['args']]
|
||||
expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call(" + ','.join(json.dumps(x) for x in [route, *arguments]) + ')})()'
|
||||
page = Page()
|
||||
try: print(json.dumps({'result':page.eval(expression)}))
|
||||
finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def call_method(ssh, plugin_id, method, args):
|
||||
def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(method, str) or not METHOD.fullmatch(method):
|
||||
raise PanelError('Invalid plugin method')
|
||||
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args)) > 8192:
|
||||
raise PanelError('Plugin method arguments are too large')
|
||||
if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))):
|
||||
raise PanelError('Legacy plugin methods need one keyword argument object')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args}
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)),
|
||||
stdin=METHOD_SCRIPT, timeout=25))
|
||||
+11
-6
@@ -103,7 +103,8 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
|
||||
catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}};
|
||||
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
|
||||
}catch(error){list.textContent=error.message;}}
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, activeFrameyId=null, frameySession=null, deckyBusy=false;
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
|
||||
activeFrameyId=null, frameySession=null, deckyBusy=false;
|
||||
async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
|
||||
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
|
||||
@@ -117,10 +118,14 @@ async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
button.disabled=deckyBusy;button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
|
||||
list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.';
|
||||
showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}}
|
||||
async function loadDeckyStore(){byId('deckyStorePanel').hidden=false;
|
||||
async function loadDeckyStore(){const request=++deckyStoreRequest;byId('deckyStorePanel').hidden=false;
|
||||
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
|
||||
byId('deckyStoreSummary').textContent='Loading official Decky store…';
|
||||
try{const data=await api('/api/decky/store');deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
|
||||
catch(error){byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
|
||||
try{const data=await api('/api/decky/store');if(request!==deckyStoreRequest)return;
|
||||
deckyStore=data.plugins;deckyStoreIndex=0;showDeckyStore();}
|
||||
catch(error){if(request!==deckyStoreRequest)return;
|
||||
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
|
||||
byId('deckyStoreSummary').textContent=`Store unavailable: ${error.message}`;}}
|
||||
function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren();
|
||||
const q=byId('deckySearch').value.trim().toLowerCase();
|
||||
const filter=byId('deckyStoreFilter').value;
|
||||
@@ -189,7 +194,7 @@ function openFrameyPanel(id,name){
|
||||
if(!(deckyState?.plugins||[]).some(p=>p.storeId===id))return;
|
||||
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
|
||||
byId('frameyPanelStatus').textContent='Loading plugin panel…';
|
||||
byId('frameyFrame').src=`/framey/panel/${id}#${frameySession}`;
|
||||
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);
|
||||
@@ -229,7 +234,7 @@ window.addEventListener('message',async event=>{
|
||||
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type!=='framey:call'||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const pluginId=activeFrameyId,reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args});reply.result=data.result;}
|
||||
try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
});
|
||||
|
||||
+1
-1
@@ -581,7 +581,7 @@ def framey_method_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'))
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'), body.get('legacy', False))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
Reference in new issue
Block a user