diff --git a/docs/evidence/framey-vr-2026-10-25/README.md b/docs/evidence/framey-vr-2026-10-25/README.md new file mode 100644 index 0000000..3d157a0 --- /dev/null +++ b/docs/evidence/framey-vr-2026-10-25/README.md @@ -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. + +![Actual compositor panel, room masked](stereo-left-panel-redacted.png) + +![Actual mirror panel, room masked](mirror-panel-redacted.png) + +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. diff --git a/docs/evidence/framey-vr-2026-10-25/cleanup-verification.json b/docs/evidence/framey-vr-2026-10-25/cleanup-verification.json new file mode 100644 index 0000000..5e546e1 --- /dev/null +++ b/docs/evidence/framey-vr-2026-10-25/cleanup-verification.json @@ -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} + diff --git a/docs/evidence/framey-vr-2026-10-25/images.json b/docs/evidence/framey-vr-2026-10-25/images.json new file mode 100644 index 0000000..cf68fa0 --- /dev/null +++ b/docs/evidence/framey-vr-2026-10-25/images.json @@ -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" + } +] diff --git a/docs/evidence/framey-vr-2026-10-25/mirror-panel-redacted.png b/docs/evidence/framey-vr-2026-10-25/mirror-panel-redacted.png new file mode 100644 index 0000000..840d2f9 Binary files /dev/null and b/docs/evidence/framey-vr-2026-10-25/mirror-panel-redacted.png differ diff --git a/docs/evidence/framey-vr-2026-10-25/result.json b/docs/evidence/framey-vr-2026-10-25/result.json new file mode 100644 index 0000000..7221d00 --- /dev/null +++ b/docs/evidence/framey-vr-2026-10-25/result.json @@ -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,<%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.floatingfooter' -- 'SteamVR - valve.steam.gamepadui.floatingfooter (85370004)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001ac0000001c\n'XRService-CameraOverlay' -- 'XRService-CameraOverlay', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x000000790000001d\n'XRService-LiveCalibrationSummaryOverlay' -- 'XRService-LiveCalibrationSummaryOverlay', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x0000007a0000001e\n'XRService-PerformanceSummaryOverlay' -- 'XRService-PerformanceSummaryOverlay', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x0000007b0000001f\n'XRService - LHTrackedOverlay' -- 'XRService - LHTrackedOverlay', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x0000007c00000020\n'XRService-InternalSLAMState' -- 'XRService-InternalSLAMState', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x0000007d00000021\n'valve.steam.gamepadui.frame.menu.85370005' -- 'SteamVR - valve.steam.gamepadui.frame.menu.85370005 (85370005)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001ad00000022\n'valve.steam.gamepadui.frame.menu.85370006' -- 'SteamVR - valve.steam.gamepadui.frame.menu.85370006 (85370006)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001c400000023\n'valve.steam.gamepadui.notifications' -- 'SteamVR - Notifications', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001c500000024", + "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,<%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.floatingfooter' -- 'SteamVR - valve.steam.gamepadui.floatingfooter (85430004)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001ce0000001c\n'XRService-CameraOverlay' -- 'XRService-CameraOverlay', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x000000790000001d\n'XRService-LiveCalibrationSummaryOverlay' -- 'XRService-LiveCalibrationSummaryOverlay', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x0000007a0000001e\n'XRService-PerformanceSummaryOverlay' -- 'XRService-PerformanceSummaryOverlay', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x0000007b0000001f\n'XRService - LHTrackedOverlay' -- 'XRService - LHTrackedOverlay', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x0000007c00000020\n'XRService-InternalSLAMState' -- 'XRService-InternalSLAMState', not_visible VROverlayType_Basic \n flags=0x20000000 overlayhandle=0x0000007d00000021\n'valve.steam.gamepadui.frame.menu.85430005' -- 'SteamVR - valve.steam.gamepadui.frame.menu.85430005 (85430005)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cf00000022\n'valve.steam.gamepadui.frame.menu.85430006' -- 'SteamVR - valve.steam.gamepadui.frame.menu.85430006 (85430006)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001e600000023\n'valve.steam.gamepadui.notifications' -- 'SteamVR - Notifications', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001e700000024", + "pause": "power.pauseCompositorOnStandby=false", + "timeout": "power.turnOffScreensTimeout=5.000000" + }, + "powerRecoveryRemoved": true, + "lockReleased": true, + "cleanupErrors": [] +} diff --git a/docs/evidence/framey-vr-2026-10-25/stereo-left-panel-redacted.png b/docs/evidence/framey-vr-2026-10-25/stereo-left-panel-redacted.png new file mode 100644 index 0000000..ecb25dd Binary files /dev/null and b/docs/evidence/framey-vr-2026-10-25/stereo-left-panel-redacted.png differ diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index 0e5db81..3046e12 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -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. diff --git a/docs/framey-loader.md b/docs/framey-loader.md index e9dc97d..c65ac46 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -162,7 +162,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. @@ -175,3 +175,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. diff --git a/docs/framey-vr-check.md b/docs/framey-vr-check.md new file mode 100644 index 0000000..291388b --- /dev/null +++ b/docs/framey-vr-check.md @@ -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. diff --git a/examples/framey-settings/README.md b/examples/framey-settings/README.md index 52e1233..5f8365c 100644 --- a/examples/framey-settings/README.md +++ b/examples/framey-settings/README.md @@ -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 diff --git a/examples/framey-settings/main.py b/examples/framey-settings/main.py index f2be57b..4c491d1 100644 --- a/examples/framey-settings/main.py +++ b/examples/framey-settings/main.py @@ -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: diff --git a/scripts/framey-vr-check.py b/scripts/framey-vr-check.py new file mode 100644 index 0000000..d64377b --- /dev/null +++ b/scripts/framey-vr-check.py @@ -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()) diff --git a/scripts/framey-worn-check.py b/scripts/framey-worn-check.py index 71b25d0..57c7713 100644 --- a/scripts/framey-worn-check.py +++ b/scripts/framey-worn-check.py @@ -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)) diff --git a/scripts/framey_openvr.py b/scripts/framey_openvr.py new file mode 100644 index 0000000..913e053 --- /dev/null +++ b/scripts/framey_openvr.py @@ -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() diff --git a/scripts/framey_vr_input.py b/scripts/framey_vr_input.py new file mode 100644 index 0000000..aac34a5 --- /dev/null +++ b/scripts/framey_vr_input.py @@ -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() diff --git a/tests/page/framey_cache.mjs b/tests/page/framey_cache.mjs index c20486c..c251969 100644 --- a/tests/page/framey_cache.mjs +++ b/tests/page/framey_cache.mjs @@ -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); diff --git a/tests/test_framey_sample.py b/tests/test_framey_sample.py index ffdd50a..a25d45e 100644 --- a/tests/test_framey_sample.py +++ b/tests/test_framey_sample.py @@ -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}): diff --git a/tests/test_framey_vr_check.py b/tests/test_framey_vr_check.py new file mode 100644 index 0000000..175e6be --- /dev/null +++ b/tests/test_framey_vr_check.py @@ -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) diff --git a/tests/test_framey_worn.py b/tests/test_framey_worn.py index 7d71e65..8bb154c 100644 --- a/tests/test_framey_worn.py +++ b/tests/test_framey_worn.py @@ -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} diff --git a/ui/headset.html b/ui/headset.html index 4dae1e4..d4599d3 100644 --- a/ui/headset.html +++ b/ui/headset.html @@ -194,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);