mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 01:01:40 +01:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081d133084 | ||
|
|
94e050d5a0 | ||
|
|
cd6ecf073f | ||
|
|
3d6bd463ce | ||
|
|
981173a1f2 | ||
|
|
9146234758 | ||
|
|
c6e54041e0 | ||
|
|
e67a90b397 | ||
|
|
91f60c26e3 | ||
|
|
cb9ad1b6cb | ||
|
|
285871bc39 | ||
|
|
779f9c5a53 | ||
|
|
43112f5143 | ||
|
|
0e6c00525d | ||
|
|
416c574240 | ||
|
|
d77e2bd056 | ||
|
|
2a6736a02c | ||
|
|
c8ef81ad93 | ||
|
|
e529d60554 | ||
|
|
e9273beaa1 | ||
|
|
48b404bc3b | ||
|
|
aeb991bc1c | ||
|
|
63820d1909 | ||
|
|
1aaa3c8c06 | ||
|
|
be18ed1a8b | ||
|
|
3f4aaf539c | ||
|
|
3acc2d91e6 | ||
|
|
0867debebd | ||
|
|
e400cb1dcf | ||
|
|
1701e622dc | ||
|
|
31edf6b737 | ||
|
|
005ad9b71d | ||
|
|
aaf2668882 | ||
|
|
cfb0310e9b | ||
|
|
313d1ec9c8 | ||
|
|
6ee7bacc47 | ||
|
|
027bbfa701 | ||
|
|
0f8d69a263 | ||
|
|
2a9d4f886e | ||
|
|
734cd4f36c | ||
|
|
44ca81f527 | ||
|
|
3e5349ec39 | ||
|
|
3c184f5741 |
No files matched your search
@@ -10,7 +10,7 @@ on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
@@ -110,6 +110,9 @@ jobs:
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Launch the built Linux AppImage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.35",
|
||||
"version": "0.4.36",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.35",
|
||||
"version": "0.4.36",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.35",
|
||||
"version": "0.4.36",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.35**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| 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) |
|
||||
@@ -23,7 +23,7 @@ Current for Frame Control **v0.4.35**, 25 October 2026. “Real Frame + physical
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified. The new large read-only status screen has browser layout and packaging checks only | 25 Oct, v0.4.29 device check and v0.4.31 status screen: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [PR #220](https://github.com/saphid/frame-control/pull/220), [panel guide](headset-app.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. v0.4.35 treats `_root` store tags as root-required and hides duplicate IDs; this has test coverage, not a live store check. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.35 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [PR #246](https://github.com/saphid/frame-control/pull/246), [compatibility table](decky-frame.md) |
|
||||
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Official Steam Status rendered in local Chromium; a session-only cache avoided its earlier storage errors. A later local browser check restored a bounded per-plugin cache after panel reopen; live Decky and offline behavior were not tested. Fake Frame tests cover setting read/write through update and reinstall and install/remove two pinned official API v2 archives plus the modern API v1 Storage Cleaner archive. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle with a settings tab and read-only backend quick action. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set. Storage Cleaner 1.4.0 showed an empty-data panel and four disabled cleanup buttons in local Chromium with a stub backend; no cleanup action was tested. A hash match, local panel and fake Frame install do not prove VR use. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.35: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [#236](https://github.com/saphid/frame-control/pull/236), [#237](https://github.com/saphid/frame-control/pull/237), [#238](https://github.com/saphid/frame-control/pull/238), [#239](https://github.com/saphid/frame-control/pull/239), [#240](https://github.com/saphid/frame-control/pull/240), [#241](https://github.com/saphid/frame-control/pull/241), [#243](https://github.com/saphid/frame-control/pull/243), [#244](https://github.com/saphid/frame-control/pull/244), [#245](https://github.com/saphid/frame-control/pull/245), [#247](https://github.com/saphid/frame-control/pull/247), [#248](https://github.com/saphid/frame-control/pull/248), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. v0.4.35 treats `_root` store tags as root-required and hides duplicate IDs; this has test coverage, not a live store check. v0.4.36 stops a store install or update when the catalog hash changed after review; this was tested with a fake Frame, not with a live store change on the headset. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.36 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [PR #246](https://github.com/saphid/frame-control/pull/246), [PR #253](https://github.com/saphid/frame-control/pull/253), [compatibility table](decky-frame.md) |
|
||||
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Official Steam Status rendered in local Chromium; a session-only cache avoided its earlier storage errors. A later local browser check restored a bounded per-plugin cache after panel reopen; live Decky and offline behavior were not tested. Fake Frame tests cover setting read/write through update and reinstall and install/remove two pinned official API v2 archives plus the modern API v1 Storage Cleaner archive. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle with a settings tab and read-only backend quick action. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set. Storage Cleaner 1.4.0 showed an empty-data panel and four disabled cleanup buttons in local Chromium with a stub backend; no cleanup action was tested. HLTB for Deck 2.0.12 built a shallow settings view and Bluetooth 3.1.1 built its initial panel in local frontend tests without effects or backend calls. Their badges, search, pairing and Bluetooth changes remain unverified. A hash match, local panel and fake Frame install do not prove VR use. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.36: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [#236](https://github.com/saphid/frame-control/pull/236), [#237](https://github.com/saphid/frame-control/pull/237), [#238](https://github.com/saphid/frame-control/pull/238), [#239](https://github.com/saphid/frame-control/pull/239), [#240](https://github.com/saphid/frame-control/pull/240), [#241](https://github.com/saphid/frame-control/pull/241), [#243](https://github.com/saphid/frame-control/pull/243), [#244](https://github.com/saphid/frame-control/pull/244), [#245](https://github.com/saphid/frame-control/pull/245), [#247](https://github.com/saphid/frame-control/pull/247), [#248](https://github.com/saphid/frame-control/pull/248), [#251](https://github.com/saphid/frame-control/pull/251), [#252](https://github.com/saphid/frame-control/pull/252), [#253](https://github.com/saphid/frame-control/pull/253), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
+20
-8
@@ -44,25 +44,37 @@ For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-aut
|
||||
|
||||
### Which of these plugins can open a Framey panel?
|
||||
|
||||
The separate Framey panel accepts a Frame Control store install only when its
|
||||
`plugin.json` declares `api_version: 2` and it has `dist/index.js`. A backend
|
||||
load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
The separate Framey panel accepts a Frame Control store install with a supported
|
||||
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
|
||||
A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
|
||||
catalogue. All 11 had a frontend bundle, but their manifests differed:
|
||||
|
||||
| Manifest in the checked archive | Plugins | Framey panel result |
|
||||
| --- | --- | --- |
|
||||
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
|
||||
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Framey refuses these frontends. Wine Cellar and MagicPods also fail the ARM64 archive check. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | Framey refuses these frontends. Audio Loader and PowerTools also request root. |
|
||||
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
|
||||
|
||||
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
|
||||
has each store ID, exact version, SHA-256, manifest field, and bundle check.
|
||||
This is a static PC inspection. It does not show that any plugin is visible or
|
||||
usable in the headset. The earlier real Frame backend checks in the table above
|
||||
The archive audit itself is static PC inspection. Later local host and fake
|
||||
Frame tests cover the Storage Cleaner, 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
|
||||
@@ -83,7 +95,7 @@ API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
|
||||
with settings, local routes, and panel toasts where supported.
|
||||
Steam route patches and system notifications remain unavailable. The panel
|
||||
still needs a worn-headset visual and controller check. See the
|
||||
[current compatibility table](frame-plugin-author.md#framey-api-v2-compatibility).
|
||||
[current compatibility table](frame-plugin-author.md#framey-decky-api-compatibility).
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bluetooth 3.1.1 compatibility fixture, 25 October 2026
|
||||
|
||||
The unmodified official Decky store archive for Bluetooth 3.1.1 (store ID 18,
|
||||
SHA-256 `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5`)
|
||||
passes Frame Control's archive rules. The fake Frame e2e harness installed it
|
||||
through the store API, verified the managed receipt and frontend bytes, then
|
||||
removed it. An earlier real Frame check recorded the backend's `Bluetooth
|
||||
plugin loaded` line while SteamVR was in standby.
|
||||
|
||||
In a local Node host test, its official frontend starts and builds an initial
|
||||
panel tree. Framey supplies empty Steam token maps and readable English
|
||||
fallbacks for the three Bluetooth labels that lack plugin-owned fallbacks.
|
||||
The test leaves React effects and click
|
||||
handlers inert and sends no backend calls. It does not inspect paired devices,
|
||||
change Bluetooth state, exercise a controller, or prove the panel is visible in
|
||||
VR. No worn-headset check has passed for this plugin.
|
||||
@@ -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,23 +57,32 @@ 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 |
|
||||
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack` | Local routes inside this plugin panel only | Host unit test |
|
||||
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
|
||||
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
|
||||
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
|
||||
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
| `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
|
||||
Keep routes local to the panel and give every operation a visible result. A
|
||||
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
|
||||
Frame Control store; only paths registered by that same plugin can navigate.
|
||||
Optional route segments must be at the end. Framey accepts local path segments
|
||||
only; query strings, external URLs, Steam library routes and duplicate parameter
|
||||
names are rejected. Removing an active route returns to the plugin panel.
|
||||
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
|
||||
An API v1 manifest does not guarantee a working Framey panel. Test the exact
|
||||
store archive and do not assume SteamClient app details, library patches, or
|
||||
@@ -85,3 +94,9 @@ The Framey `localStorage` bridge is for disposable frontend caches, including
|
||||
the cache read attempted by Steam Status 1.2.3. It does not expose another
|
||||
plugin's cache or the Frame Control UI key. A real Steam Status panel reopen
|
||||
and a worn-headset settings check are still needed.
|
||||
|
||||
HLTB for Deck 2.0.12 demonstrates the limit of the panel shim: its official
|
||||
bundle can start and produce a settings view with Framey's Steam module lookup
|
||||
stub, but its game page and context menu patches have no Steam target in the
|
||||
isolated panel. The tests do not call its HLTB network search or Python backend.
|
||||
Do not claim game length badges work in VR from this result.
|
||||
+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.
|
||||
@@ -20,6 +20,12 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
|
||||
draft by its exact version title, restores the tag association and retries
|
||||
the affected upload; it refuses ambiguous matches.
|
||||
The Linux build runs `scripts/check-appimage-launch.py` before upload. It
|
||||
starts the built x86_64 AppImage on an isolated Xvfb display, checks that
|
||||
its bundled server answers, and checks that the server stops on exit. The
|
||||
check disables telemetry and update requests, and keeps extraction in its
|
||||
temporary profile. This CI test does not test a physical display, FUSE mount
|
||||
or a headset.
|
||||
|
||||
2. Download the draft's installers and test them. An installed copy of the
|
||||
previous version won't offer the draft, so install it directly.
|
||||
|
||||
@@ -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
|
||||
@@ -13,6 +13,14 @@ browser cache. A missing, damaged, or old non-boolean value reads as the
|
||||
caller's default;
|
||||
the next save atomically replaces the sample's settings file.
|
||||
|
||||
The **About this setting** button opens a local detail page at
|
||||
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
|
||||
`Navigation.Navigate`, `useParams`, `NavigateBack`, and removal of the route in
|
||||
`onDismount`. The `:topic?` segment is optional. Framey keeps this navigation
|
||||
inside the plugin panel; it does not open a Steam library page. Put optional
|
||||
segments at the end of the path and do not use query strings. This detail page
|
||||
has not been checked with headset controllers.
|
||||
|
||||
## Build
|
||||
|
||||
From this directory, run:
|
||||
@@ -35,7 +43,7 @@ plugin directory does not enable its VR panel.
|
||||
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
|
||||
repository root. It checks backend persistence and evaluates the built bundle
|
||||
against Framey's actual host script, including the quick action's scoped
|
||||
backend call. The fake Frame store flow is exercised by
|
||||
backend call and local route registration/navigation. The fake Frame store flow is exercised by
|
||||
the repository's e2e suite. No worn-headset visibility or controller-input test
|
||||
has passed for this sample yet.
|
||||
|
||||
|
||||
+26
-17
@@ -16,6 +16,7 @@ if (api._version != API_VERSION) {
|
||||
console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);
|
||||
}
|
||||
const callable = api.callable;
|
||||
const routerHook = api.routerHook;
|
||||
const toaster = api.toaster;
|
||||
const definePlugin = (fn) => {
|
||||
return (...args) => {
|
||||
@@ -59,24 +60,32 @@ function Content() {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
|
||||
}
|
||||
var index = definePlugin(() => Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
settings: SP_JSX.jsx(Content, {}),
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
function Details() {
|
||||
const { topic } = DFL.useParams();
|
||||
return SP_JSX.jsxs(DFL.PanelSection, { title: "Sample detail page", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsxs("p", { children: ["Topic: ", topic || 'overview'] }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { children: "The toggle is stored by this plugin's Decky backend in the user's settings directory." }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.NavigateBack(), children: "Back to setting" }) })] });
|
||||
}
|
||||
var index = definePlugin(() => {
|
||||
routerHook.addRoute('/framey-sample/details/:topic?', Details);
|
||||
return Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: SP_JSX.jsx("span", { "aria-hidden": "true", children: "\u2699" }),
|
||||
content: SP_JSX.jsx(Content, {}),
|
||||
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
settings: SP_JSX.jsx(Content, {}),
|
||||
frameyQuickActions: [{
|
||||
label: 'Read saved setting',
|
||||
onClick: async () => {
|
||||
const enabled = await getSetting('enabled', false);
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
export { index as default };
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, toaster} from '@decky/api';\nimport {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the main content\n // usable there too, while showing authors the extra VR settings view.\n settings: <Content />,\n frameyQuickActions: [{\n label: 'Read saved setting',\n onClick: async () => {\n const enabled = await getSetting('enabled', false);\n return `Saved setting is ${enabled ? 'on' : 'off'}`;\n },\n }],\n}));\n"],"names":[],"mappings":"AAEA,MAAA,QAAA,GAAiB,CAAA,MAAA,CAAA,wBAAA,CAAS;AAC1B,MAAA,WAAA,GAAA,CAAA;AAIA,MAAA,qBAAA,GAAA,MAAA,CAAA,2EAAA;AACA,IAAA,CAAA,qBAAA,EAAA;AACA,IAAA,MAAA,IAAA,KAAA,CAAA,+HAAA,CAAA;AACA;AACA,IAAA,GAAA;AACA,IAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,WAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA;AACA,MAAA;AACA,IAAA,GAAA,GAAA,qBAAA,CAAA,OAAA,CAAA,CAAA,EAAA,QAAA,CAAA,IAAA,CAAA;AACA,IAAA,OAAA,CAAA,IAAA,CAAA,CAAA,mCAAA,EAAA,WAAA,CAAA,4EAAA,CAAA,CAAA;AACA;AACA,IAAA,GAAA,CAAA,QAAA,IAAA,WLine truncated
|
||||
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n\n async function refresh() {\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return <PanelSection title=\"Sample detail page\">\n <PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>\n <PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => {\n routerHook.addRoute('/framey-sample/details/:topic?', Details);\n return Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the mainLine truncated
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {callable, definePlugin, toaster} from '@decky/api';
|
||||
import {ButtonItem, PanelSection, PanelSectionRow, ToggleField} from '@decky/ui';
|
||||
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
|
||||
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
|
||||
@@ -45,14 +45,27 @@ function Content() {
|
||||
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>
|
||||
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => Object.assign({
|
||||
function Details() {
|
||||
const {topic} = useParams<{topic?: string}>();
|
||||
return <PanelSection title="Sample detail page">
|
||||
<PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>
|
||||
<PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>
|
||||
<PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back to setting</ButtonItem></PanelSectionRow>
|
||||
</PanelSection>;
|
||||
}
|
||||
|
||||
export default definePlugin(() => {
|
||||
routerHook.addRoute('/framey-sample/details/:topic?', Details);
|
||||
return Object.assign({
|
||||
name: 'Framey Settings Sample',
|
||||
icon: <span aria-hidden="true">⚙</span>,
|
||||
content: <Content />,
|
||||
onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),
|
||||
}, {
|
||||
// Framey additions are ignored by Decky's own menu. Keep the main content
|
||||
// usable there too, while showing authors the extra VR settings view.
|
||||
@@ -64,4 +77,5 @@ export default definePlugin(() => Object.assign({
|
||||
return `Saved setting is ${enabled ? 'on' : 'off'}`;
|
||||
},
|
||||
}],
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch a built Linux AppImage on Xvfb and check its bundled server.
|
||||
|
||||
This checks the packaged app without a headset or a desktop login session.
|
||||
The profile and display are temporary, and every process started by this check
|
||||
is stopped before it exits.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def server_port(profile):
|
||||
for log in (profile / "config").rglob("server.log"):
|
||||
ports = re.findall(r"--port (\d+)", log.read_text(errors="replace"))
|
||||
if ports:
|
||||
return int(ports[-1])
|
||||
return None
|
||||
|
||||
|
||||
def port_closed(port):
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=1):
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def check(appimage):
|
||||
if not appimage.is_file() or not os.access(appimage, os.X_OK):
|
||||
raise RuntimeError(f"AppImage is missing or cannot run: {appimage}")
|
||||
if not shutil.which("xvfb-run"):
|
||||
raise RuntimeError("xvfb-run is required for the AppImage smoke check")
|
||||
digest = hashlib.sha256(appimage.read_bytes()).hexdigest()
|
||||
with tempfile.TemporaryDirectory(prefix="frame-appimage-smoke-") as directory:
|
||||
profile = Path(directory)
|
||||
(profile / "tmp").mkdir()
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
APPIMAGE_EXTRACT_AND_RUN="1", ELECTRON_DISABLE_SANDBOX="1",
|
||||
TMPDIR=str(profile / "tmp"),
|
||||
XDG_CONFIG_HOME=str(profile / "config"),
|
||||
XDG_CACHE_HOME=str(profile / "cache"),
|
||||
XDG_DATA_HOME=str(profile / "data"),
|
||||
FRAME_ALIAS="frame-control-appimage-smoke-no-headset",
|
||||
FRAME_CONTROL_TELEMETRY="0", FRAME_CONTROL_NO_UPDATE_CHECK="1",
|
||||
DO_NOT_TRACK="1",
|
||||
)
|
||||
output = profile / "app.log"
|
||||
port = None
|
||||
passed = False
|
||||
with output.open("w") as log:
|
||||
proc = subprocess.Popen(
|
||||
["xvfb-run", "-a", "-s", "-screen 0 1280x800x24 -nolisten tcp",
|
||||
str(appimage), "--no-sandbox"],
|
||||
cwd=profile, env=env, stdout=log, stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
try:
|
||||
deadline = time.monotonic() + 60
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError(f"AppImage exited {proc.returncode} before its server answered")
|
||||
port = server_port(profile)
|
||||
if port is not None:
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2) as response:
|
||||
if response.status == 200 and response.headers.get("Server", "").startswith("FrameControl/1"):
|
||||
passed = True
|
||||
break
|
||||
except (OSError, urllib.error.URLError):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
if not passed:
|
||||
raise RuntimeError("AppImage server did not answer within 60 seconds")
|
||||
except BaseException:
|
||||
log.flush()
|
||||
print(output.read_text(errors="replace")[-1500:], file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.wait(timeout=10)
|
||||
if port is not None:
|
||||
for _ in range(20):
|
||||
if port_closed(port):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise RuntimeError(f"AppImage server stayed on port {port} after shutdown")
|
||||
print(f"AppImage SHA-256 {digest}: HTTP 200, FrameControl/1, server stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit("usage: check-appimage-launch.py path/to/AppImage")
|
||||
check(Path(sys.argv[1]).resolve())
|
||||
@@ -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.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+15
-4
@@ -7,14 +7,25 @@ from a local fixture so CI does not depend on the store being online.
|
||||
|
||||
| File | Official plugin | Version | Store SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
|
||||
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
|
||||
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
|
||||
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
|
||||
| `18.zip` | Bluetooth | 3.1.1 | `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5` |
|
||||
| `26.zip` | HLTB for Deck | 2.0.12 | `bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558` |
|
||||
|
||||
The matching source projects are
|
||||
[MusicControl](https://github.com/nikurasu/MusicControl),
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky), and
|
||||
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner).
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky),
|
||||
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner),
|
||||
[Bluetooth](https://github.com/Outpox/Bluetooth), and
|
||||
[HLTB for Deck](https://github.com/morwy/hltb-for-deck).
|
||||
The test verifies the ZIP digest, archive policy, API v1/v2 manifest, installed
|
||||
frontend bytes, managed store receipt, and removal. It does **not** run their
|
||||
Python backends or prove their JavaScript panels render in the VR shell.
|
||||
frontend bytes, managed store receipt, and removal. Separate local host tests
|
||||
start Bluetooth's frontend and its initial panel without running effects or
|
||||
actions, and start HLTB's frontend and shallow settings view with a stub Steam
|
||||
module lookup. They do **not** run plugin Python backends, Bluetooth actions,
|
||||
HLTB searches, or prove JavaScript panels render in the VR shell. The
|
||||
Bluetooth result and limits are in
|
||||
[the fixture evidence](../../../docs/evidence/framey-bluetooth-fixture-2026-10-25.md).
|
||||
@@ -4,12 +4,25 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||
PLUGINS = (
|
||||
{'id': 12, 'name': 'MusicControl', 'version': '1.1.6', 'api_version': 'legacy', 'folder': 'MusicControl', 'sha256': '246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62'},
|
||||
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2,
|
||||
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
|
||||
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
|
||||
'folder': 'steamstatus-decky', 'sha256': '9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e'},
|
||||
{'id': 50, 'name': 'Storage Cleaner', 'version': '1.4.0', 'api_version': 1,
|
||||
'folder': 'decky-storage-cleaner', 'sha256': '1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591'},
|
||||
{'id': 18, 'name': 'Bluetooth', 'version': '3.1.1', 'api_version': 1,
|
||||
'folder': 'Bluetooth', 'sha256': 'f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5'},
|
||||
{'id': 26, 'name': 'HLTB for Deck', 'version': '2.0.12', 'api_version': 1,
|
||||
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
|
||||
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
|
||||
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
|
||||
)
|
||||
|
||||
# This archive has a legacy IIFE frontend with no manifest API version.
|
||||
LEGACY_PLUGINS = (
|
||||
{'id': 7, 'name': 'CSS Loader', 'version': '2.1.3', 'api_version': 'legacy',
|
||||
'folder': 'SDH-CssLoader', 'sha256': '99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231'},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}),
|
||||
@@ -107,4 +107,73 @@ const undo=api.routerHook.addRoute('/plugin/details',LocalRoute);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),true);
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/decky/store'),false);
|
||||
assert.equal(window.DFL.findModuleChild(()=>true),null);
|
||||
window.DFL.Navigation.CloseSideMenus();
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0);
|
||||
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
|
||||
|
||||
// Legacy backend methods preserve Decky keyword arguments and success envelopes.
|
||||
const legacyReply=window.__legacy.callPluginMethod('get_setting',{key:'theme'});
|
||||
const legacyCall=messages.filter(message=>message.type==='framey:call').at(-1);
|
||||
assert.equal(legacyCall.legacy,true);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(legacyCall.args)),[{key:'theme'}]);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:legacyCall.id,
|
||||
result:{success:true,result:'dark'}}});
|
||||
assert.equal((await legacyReply).result,'dark');
|
||||
assert.equal((await window.__legacy.callPluginMethod('get_setting',[])).success,false);
|
||||
const failedLegacy=window.__legacy.callPluginMethod('get_setting',{});
|
||||
const failedCall=messages.filter(message=>message.type==='framey:call').at(-1);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:failedCall.id,error:'Unavailable'}});
|
||||
assert.equal((await failedLegacy).success,false);
|
||||
|
||||
// Local UI controls work without Steam components or fabricated hardware data.
|
||||
const tabChanges=[];
|
||||
const tabs=window.DFL.Tabs({tabs:[{id:'one',title:'One',content:'First'},
|
||||
{id:'two',title:'Two',content:'Second'},{id:'locked',title:'Locked',disabled:true}],
|
||||
activeTab:'two',onShowTab:id=>tabChanges.push(id)});
|
||||
assert.equal(nodes(tabs,'button')[1].props['aria-selected'],true);
|
||||
assert.deepEqual(nodes(tabs,'div').at(-1).children,['Second']);
|
||||
nodes(tabs,'button')[0].props.onClick();
|
||||
nodes(tabs,'button')[2].props.onClick();
|
||||
assert.deepEqual(tabChanges,['one']);
|
||||
assert.equal(window.DFL.Spinner({}).props.role,'status');
|
||||
assert.equal(window.DFL.ProgressBar({nProgress:120}).props.value,100);
|
||||
assert.equal(window.DFL.joinClassNames('one',null,'two'),'one two');
|
||||
let eventCount=0;
|
||||
const unsubscribe=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(()=>eventCount++);
|
||||
assert.equal(typeof unsubscribe,'function');
|
||||
assert.equal(unsubscribe.unregister,unsubscribe);
|
||||
unsubscribe.unregister();unsubscribe();assert.equal(eventCount,0);
|
||||
assert.equal(window.SteamClient.System.ShutdownPC,undefined);
|
||||
assert.equal(window.DFL.Router.MainRunningApp,undefined);
|
||||
assert.equal(window.indexedDB,undefined); // localForage must use bounded plugin storage.
|
||||
const unpatch=window.DFL.beforePatch({},'method',()=>{});
|
||||
assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch();
|
||||
assert.equal(typeof window.DFL.showModal({type:'p'}).Close,'function');
|
||||
let selectedMenuItem=0;
|
||||
window.DFL.MenuItem({onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
window.DFL.MenuItem({disabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
window.DFL.MenuItem({bDisabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
window.DFL.MenuItem({onClick:()=>selectedMenuItem++,onSelected:()=>selectedMenuItem++}).props.onClick({});
|
||||
assert.equal(selectedMenuItem,2);
|
||||
for (const [locale,expected] of [['de-DE','german'],['zh-CN','schinese'],['zh-TW','tchinese'],
|
||||
['pt-BR','brazilian'],['en-US','english'],['xx-ZZ','english']]) {
|
||||
context.navigator.language=locale;
|
||||
assert.equal(await window.SteamClient.Settings.GetCurrentLanguage(),expected);
|
||||
}
|
||||
function GameRoute(){return React.createElement('p',null,window.DFL.useParams().appid);}
|
||||
const removeGame=api.routerHook.addRoute('/plugin/game/:appid/:view?',GameRoute);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
|
||||
assert.equal(window.DFL.useParams().appid,'123');
|
||||
assert.equal(window.DFL.useParams().view,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,1);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true);
|
||||
assert.equal(window.DFL.useParams().view,'art');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art/extra'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123?admin=1'),false);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/plugin/game//art'),false);
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute));
|
||||
assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
|
||||
removeGame();
|
||||
assert.equal(window.DFL.useParams().appid,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
|
||||
@@ -0,0 +1,43 @@
|
||||
// Start the unmodified official Bluetooth frontend and build its initial UI.
|
||||
// Effects and click handlers are inert: no Bluetooth/backend operation runs.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const bundle=readFileSync(process.argv[2],'utf8');
|
||||
const official=bundle.replace(/export \{ index as default \};\s*\/\/# sourceMappingURL=index.js.map\s*$/,
|
||||
'window.__official = index;');
|
||||
assert.notEqual(official,bundle);
|
||||
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const host=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
|
||||
assert.notEqual(host,source);
|
||||
const react=new Proxy({Fragment:'fragment',Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props,children}),
|
||||
createContext:value=>({Provider:()=>{},Consumer:()=>{},_currentValue:value}),
|
||||
useState:value=>[value,()=>{}],useReducer:(_,value)=>[value,()=>{}],
|
||||
useEffect(){},useRef:value=>({current:value}),useMemo:fn=>fn(),
|
||||
useCallback:fn=>fn,forwardRef:fn=>fn,memo:fn=>fn},
|
||||
{get:(target,key)=>target[key]??(()=>{})});
|
||||
const window={React:react,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
|
||||
const elements=new Map();
|
||||
const messages=[];
|
||||
const context=vm.createContext({window,navigator:{language:'en'},parent:{postMessage:message=>messages.push(message)},
|
||||
document:{getElementById:id=>{if(!elements.has(id))elements.set(id,{});return elements.get(id);}},
|
||||
addEventListener(){},setTimeout:()=>1,clearTimeout(){},console,URL,Blob});
|
||||
vm.runInContext(host,context);
|
||||
context.DFL=window.DFL;
|
||||
context.SP_REACT=window.SP_REACT;
|
||||
context.SP_JSX=window.SP_JSX;
|
||||
context.LocalizationManager=window.LocalizationManager;
|
||||
assert.equal(window.LocalizationManager.m_mapTokens.size,0);
|
||||
assert.equal(window.LocalizationManager.m_mapFallbackTokens.get('Settings_Bluetooth_Devices'),'Devices');
|
||||
vm.runInContext(official,context,{timeout:2000});
|
||||
const plugin=window.__official();
|
||||
assert.equal(plugin.content?.type?.name,'Content');
|
||||
const panel=plugin.content.type();
|
||||
assert.equal(panel.type,'div');
|
||||
const text=JSON.stringify(panel);
|
||||
assert.match(text,/Bluetooth/);
|
||||
assert.match(text,/Devices/); // Readable fallback when Steam tokens are absent.
|
||||
assert.doesNotMatch(text,/Settings_Bluetooth_Devices/);
|
||||
assert.equal(messages.filter(message=>message.type==='framey:call').length,0);
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Execute the pinned official HLTB frontend's entry point with Framey's API.
|
||||
// This checks module startup only; it never makes network or backend calls.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {inflateRawSync} from 'node:zlib';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const host=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const hostTest=host.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
|
||||
assert.notEqual(hostTest,host);
|
||||
const zip=readFileSync(new URL('../fixtures/framey-official/26.zip',import.meta.url));
|
||||
const eocd=zip.lastIndexOf(Buffer.from([0x50,0x4b,0x05,0x06]));
|
||||
assert.ok(eocd>0);
|
||||
let offset=zip.readUInt32LE(eocd+16),bundle;
|
||||
for(let i=0;i<zip.readUInt16LE(eocd+10);i++){
|
||||
assert.equal(zip.readUInt32LE(offset),0x02014b50);
|
||||
const nameLength=zip.readUInt16LE(offset+28),extraLength=zip.readUInt16LE(offset+30);
|
||||
const commentLength=zip.readUInt16LE(offset+32);
|
||||
const name=zip.subarray(offset+46,offset+46+nameLength).toString();
|
||||
if(name==='hltb-for-deck/dist/index.js'){
|
||||
const local=zip.readUInt32LE(offset+42);
|
||||
assert.equal(zip.readUInt32LE(local),0x04034b50);
|
||||
const start=local+30+zip.readUInt16LE(local+26)+zip.readUInt16LE(local+28);
|
||||
const compressed=zip.subarray(start,start+zip.readUInt32LE(offset+20));
|
||||
bundle=(zip.readUInt16LE(offset+10)===8?inflateRawSync(compressed):compressed).toString();
|
||||
}
|
||||
offset+=46+nameLength+extraLength+commentLength;
|
||||
}
|
||||
assert.ok(bundle);
|
||||
const official=bundle.replace(/export \{ index as default \};\s*\/\/# sourceMappingURL=index.js.map\s*$/,
|
||||
'window.__official = index;');
|
||||
assert.notEqual(official,bundle);
|
||||
const react=new Proxy({Fragment:'fragment',Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props,children}),
|
||||
createContext:value=>({Provider:()=>{},Consumer:()=>{},_currentValue:value}),
|
||||
forwardRef:fn=>fn,memo:fn=>fn,useState:value=>[value,()=>{}],
|
||||
useContext:ctx=>ctx?._currentValue,useMemo:fn=>fn(),useCallback:fn=>fn,
|
||||
useRef:value=>({current:value}),useEffect(){}},
|
||||
{get:(target,key)=>target[key]??(()=>{})});
|
||||
const window={React:react,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
|
||||
const elements=new Map();
|
||||
const context=vm.createContext({window,navigator:{language:'en'},parent:{postMessage(){}},
|
||||
document:{getElementById:id=>{if(!elements.has(id))elements.set(id,{});return elements.get(id);}},
|
||||
addEventListener(){},setTimeout:()=>1,clearTimeout(){},console,URL,Blob});
|
||||
vm.runInContext(hostTest,context);
|
||||
context.DFL=window.DFL;
|
||||
context.SP_REACT=window.SP_REACT;
|
||||
context.SP_JSX=window.SP_JSX;
|
||||
vm.runInContext(official,context,{timeout:2000});
|
||||
const plugin=window.__official();
|
||||
assert.equal(typeof window.DFL.findModuleChild,'function');
|
||||
assert.equal(window.DFL.findModuleChild(()=>{}),null); // Steam has no matching module in the VR panel.
|
||||
assert.equal(plugin.content?.type?.name,'QuickAccessView');
|
||||
const panel=plugin.content.type();
|
||||
assert.ok(panel?.type); // Read-only settings render with empty local data.
|
||||
assert.equal(typeof plugin.onDismount,'function');
|
||||
plugin.onDismount();
|
||||
@@ -36,3 +36,9 @@ assert.equal(plugin.frameyQuickActions[0].label,'Read saved setting');
|
||||
assert.equal(await plugin.frameyQuickActions[0].onClick(),'Saved setting is on');
|
||||
assert.equal(calls[0].method,'get_setting');
|
||||
assert.deepEqual(Array.from(calls[0].args),['enabled',false]);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),true);
|
||||
assert.equal(window.DFL.useParams().topic,'saved-setting');
|
||||
assert.equal(window.DFL.Navigation.Navigate('/library/home'),false);
|
||||
plugin.onDismount();
|
||||
assert.equal(window.DFL.useParams().topic,undefined);
|
||||
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),false);
|
||||
@@ -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'}));
|
||||
|
||||
@@ -5,6 +5,8 @@ import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -15,10 +17,46 @@ 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:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
frontend = Path(folder) / 'index.js'
|
||||
frontend.write_bytes(bundle.read('Bluetooth/dist/index.js'))
|
||||
script = Path(__file__).parent / 'page/framey_bluetooth.mjs'
|
||||
result = subprocess.run(['node', str(script), str(frontend)],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
|
||||
def test_hltb_frontend_starts_with_framey_module_stub(self):
|
||||
script = Path(__file__).parent / 'page/framey_hltb.mjs'
|
||||
result = subprocess.run(['node', str(script)], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_pinned_store_archives_install_and_pack_into_framey_panel(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
plugins = Path(root)
|
||||
@@ -30,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']}))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Framey hosts only store-owned API v2 frontends and scopes backend calls."""
|
||||
"""Framey hosts store-owned modern Decky frontends and scopes backend calls."""
|
||||
import sandbox # noqa: F401
|
||||
import gzip
|
||||
import http.client
|
||||
@@ -58,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}
|
||||
|
||||
+145
-17
@@ -11,7 +11,14 @@
|
||||
: R.createElement(type, elementProps);
|
||||
};
|
||||
window.SP_JSX = {jsx, jsxs:jsx, Fragment:R.Fragment};
|
||||
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en']};
|
||||
// Steam normally fills these token maps. Official plugins can safely use
|
||||
// their own fallback strings when the isolated panel has no Steam tokens.
|
||||
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en'],
|
||||
m_mapTokens:new Map(), m_mapFallbackTokens:new Map([
|
||||
['Settings_Bluetooth_Connected','Connected'],
|
||||
['Settings_Bluetooth_NotConnected','Not connected'],
|
||||
['Settings_Bluetooth_Devices','Devices'],
|
||||
])};
|
||||
const root = document.getElementById('framey-root');
|
||||
const error = document.getElementById('framey-error');
|
||||
const toastRoot = document.getElementById('framey-toasts');
|
||||
@@ -19,6 +26,7 @@
|
||||
const routes = new Map();
|
||||
const routeListeners = new Set();
|
||||
let currentRoute = null;
|
||||
let currentParams = {};
|
||||
let nextId = 0;
|
||||
let plugin;
|
||||
// A sandboxed iframe has an opaque origin, so its native localStorage getter
|
||||
@@ -49,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 => {
|
||||
@@ -125,46 +136,148 @@
|
||||
showValue && R.createElement('span', null, String(value) + valueSuffix));
|
||||
const PanelSection = ({title, children}) => R.createElement('section', null,
|
||||
title && R.createElement('h2', null, title), children);
|
||||
const Spinner = ({children}) => R.createElement('p', {role:'status'}, children || 'Loading…');
|
||||
const Tabs = ({tabs=[], activeTab, onShowTab}) => {
|
||||
const [selected, setSelected] = R.useState(tabs[0]?.id);
|
||||
const current = tabs.find(tab=>tab.id === (activeTab ?? selected)) || tabs[0];
|
||||
return R.createElement('div', null,
|
||||
R.createElement('div', {role:'tablist'}, ...tabs.map(tab=>R.createElement('button', {
|
||||
type:'button', key:tab.id, role:'tab', 'aria-selected':tab===current,
|
||||
disabled:!!tab.disabled, onClick:()=>{if(!tab.disabled){setSelected(tab.id);onShowTab?.(tab.id);}}}, tab.title))),
|
||||
R.createElement('div', {role:'tabpanel'}, current?.content));
|
||||
};
|
||||
const SidebarNavigation = ({pages=[], title}) => R.createElement('section', null,
|
||||
title && R.createElement('h2', null, title), R.createElement(Tabs, {
|
||||
tabs:pages.filter(page=>page.visible!==false).map((page,index)=>({
|
||||
id:page.route || String(index), title:page.title, content:page.content}))}));
|
||||
const ProgressBar = ({nProgress=0, value, max=100}) => R.createElement('progress', {
|
||||
value:Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max});
|
||||
const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) =>
|
||||
R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled),
|
||||
onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children);
|
||||
const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null,
|
||||
R.createElement('h2', null, strTitle), children,
|
||||
R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close'));
|
||||
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
|
||||
const closeModal = () => modalRoot.render(null);
|
||||
const localPath = path => typeof path === 'string' && path.length <= 120 &&
|
||||
/^\/[A-Za-z0-9/_-]+$/.test(path) && !path.includes('//');
|
||||
const routePattern = path => typeof path === 'string' && path.length <= 120 &&
|
||||
/^\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??)(?:\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*\??))*$/.test(path) &&
|
||||
!/\?\//.test(path) &&
|
||||
new Set([...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].map(match => match[1])).size ===
|
||||
[...path.matchAll(/:([A-Za-z][A-Za-z0-9_]*)\??/g)].length;
|
||||
function matchRoute(pattern, path) {
|
||||
const expected = pattern.slice(1).split('/');
|
||||
const actual = path.slice(1).split('/');
|
||||
if (actual.length > expected.length) return null;
|
||||
const params = {};
|
||||
for (let index = 0; index < expected.length; index++) {
|
||||
const segment = expected[index], value = actual[index];
|
||||
if (segment[0] === ':') {
|
||||
const optional = segment.endsWith('?'), name = segment.slice(1, optional ? -1 : undefined);
|
||||
if (value === undefined) { if (!optional) return null; }
|
||||
else params[name] = value;
|
||||
} else if (segment !== value) return null;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
function activeRoute(path) {
|
||||
if (routes.has(path)) return {component:routes.get(path), params:{}};
|
||||
for (const [pattern, component] of routes) {
|
||||
const params = matchRoute(pattern, path);
|
||||
if (params) return {component, params};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function navigate(path) {
|
||||
if (path !== null && (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) || !routes.has(path))) return false;
|
||||
if (path !== null && !localPath(path)) return false;
|
||||
const match = path === null ? null : activeRoute(path);
|
||||
if (path !== null && !match) return false;
|
||||
currentRoute = path;
|
||||
currentParams = match?.params || {};
|
||||
for (const listener of routeListeners) listener(path);
|
||||
return true;
|
||||
}
|
||||
const routerHook = {
|
||||
addRoute(path, component) {
|
||||
if (!/^\/[A-Za-z0-9/_-]{1,120}$/.test(path) ||
|
||||
if (!routePattern(path) ||
|
||||
(typeof component !== 'function' && !R.isValidElement(component)))
|
||||
throw Error('Framey route needs a local path and React component');
|
||||
routes.set(path, component);
|
||||
return () => {routes.delete(path); if(currentRoute === path) navigate(null);};
|
||||
return () => routerHook.removeRoute(path);
|
||||
},
|
||||
removeRoute(path) {routes.delete(path); if(currentRoute === path) navigate(null);},
|
||||
removeRoute(path) {routes.delete(path); if(currentRoute && !activeRoute(currentRoute)) navigate(null);},
|
||||
// Steam routes and global components have no equivalent in the isolated panel.
|
||||
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
|
||||
};
|
||||
const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;};
|
||||
const showModal = element => {
|
||||
modalRoot.render(R.createElement('section', null,
|
||||
R.createElement('button', {type:'button',onClick:closeModal}, 'Close'), element));
|
||||
return {Close:closeModal};
|
||||
};
|
||||
const DFL = window.DFL = {
|
||||
definePlugin: init => init,
|
||||
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem,
|
||||
Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField,
|
||||
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
|
||||
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
|
||||
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
|
||||
Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
|
||||
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),
|
||||
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: () => () => {},
|
||||
findInReactTree: () => null, findModuleExport: () => null,
|
||||
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: () => ({}),
|
||||
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};
|
||||
@@ -221,8 +334,8 @@
|
||||
...actions.map((action, index) => R.createElement('button', {key:index, type:'button',
|
||||
disabled:running !== null, onClick:()=>runAction(action,index)}, action.label))),
|
||||
message && R.createElement('p', {role:'status'}, message),
|
||||
R.createElement('div', null, route ? (typeof routes.get(route) === 'function' ?
|
||||
R.createElement(routes.get(route)) : routes.get(route)) :
|
||||
R.createElement('div', null, route ? (typeof activeRoute(route)?.component === 'function' ?
|
||||
R.createElement(activeRoute(route).component) : activeRoute(route)?.component) :
|
||||
(view === 'settings' ? plugin.settings : plugin.content)));
|
||||
}
|
||||
window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit = {
|
||||
@@ -240,15 +353,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