mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 07:01:01 +01:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daee952873 | ||
|
|
98909cb543 | ||
|
|
1903153d7b | ||
|
|
7172be17fc | ||
|
|
27af46dd20 | ||
|
|
761e952d8f | ||
|
|
0237e342d2 | ||
|
|
33d85dff1c | ||
|
|
ad57662d40 | ||
|
|
5381667947 | ||
|
|
7cdff4285a | ||
|
|
fe1da43171 | ||
|
|
1c1d941a1c | ||
|
|
c2f94c242d | ||
|
|
e6e42a93f0 | ||
|
|
10b196beaa | ||
|
|
3f74ff4bb0 | ||
|
|
f2f7a3f4c1 | ||
|
|
5c4c447d2d | ||
|
|
63f1f7fb39 | ||
|
|
c88736c6ce | ||
|
|
fa354ce365 | ||
|
|
fcdd1f22f5 | ||
|
|
d43e760aef | ||
|
|
a3e0ec01d4 | ||
|
|
240288df65 | ||
|
|
a68c5584a9 | ||
|
|
b5d50a8d89 | ||
|
|
0c9bed1254 | ||
|
|
741735aa98 | ||
|
|
4133ea1c7d | ||
|
|
3aa510a0e0 | ||
|
|
637813024f | ||
|
|
c87e5c6e8a | ||
|
|
c9777851c9 | ||
|
|
af03081f27 | ||
|
|
ef99d4953b | ||
|
|
17a7b0b80e | ||
|
|
c38e7e8da4 | ||
|
|
fc00878a23 | ||
|
|
657b4449f7 | ||
|
|
30a65facde | ||
|
|
9135f355f4 | ||
|
|
784118b71e | ||
|
|
e0115f356d | ||
|
|
56bde24be1 | ||
|
|
59c270a7ad | ||
|
|
a4d8073e31 | ||
|
|
c0275b483a | ||
|
|
ff49b44350 | ||
|
|
7e6fb7e342 | ||
|
|
57c1c4564b | ||
|
|
9aa26a8322 | ||
|
|
38c388f3f3 | ||
|
|
27e3c42ed0 | ||
|
|
c2be12fa75 | ||
|
|
c164606fe3 | ||
|
|
8d9d4807c3 | ||
|
|
7f8a743cb5 | ||
|
|
40df801cdb | ||
|
|
35d53e6010 | ||
|
|
1b02d7f558 | ||
|
|
edfa48d1f5 | ||
|
|
c89ddffcbd | ||
|
|
5183f30068 | ||
|
|
37e2faacc7 | ||
|
|
b0c4647b85 | ||
|
|
ff3b0dd6f9 | ||
|
|
5c9ca110ed | ||
|
|
e3540cab5e | ||
|
|
0eaa9f2088 | ||
|
|
59f7348aa5 | ||
|
|
67a95d4f4c |
No files matched your search
@@ -128,6 +128,7 @@ Choose the operating system and processor of the computer you will use.
|
||||
| | Download | Needs |
|
||||
|---|---|---|
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
| **macOS** (Intel, v0.4.39+) | [Frame-Control-mac-x64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-x64.dmg) | Nothing extra |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
@@ -150,8 +151,8 @@ The [source](https://github.com/saphid/frame-control) and [release build workflo
|
||||
|
||||
日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
|
||||
|
||||
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
|
||||
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
|
||||
**Which build?** On a Mac, check Apple menu → About This Mac: use `mac-arm64`
|
||||
for Apple Silicon (M-series), or `mac-x64` for Intel (available from v0.4.39). On Windows,
|
||||
check Settings → System → About → System type: the release is x64, not a native
|
||||
Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64`
|
||||
(arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions.
|
||||
@@ -178,20 +179,25 @@ notarization. The DMG is unsigned. First verify the download against the
|
||||
`SHA256SUMS` from the same release, then open the DMG and drag **Frame Control**
|
||||
to **Applications**.
|
||||
|
||||
Check your processor before opening: v0.4.36 and earlier publish only
|
||||
Check your processor before opening: v0.4.38 and earlier publish only
|
||||
`mac-arm64` for Apple Silicon. On an Intel Mac, this build cannot run;
|
||||
removing quarantine or installing Rosetta does not make an ARM app run on Intel.
|
||||
On a real Intel MacBook Air with macOS 15.7.7, opening v0.4.35 and v0.4.36
|
||||
reported: **“The application cannot be opened because it has an incorrect
|
||||
executable format.”** This is an architecture failure, not an observed
|
||||
Gatekeeper warning. See the [real-Mac test record](docs/evidence/mac-desktop-2026-10-26.md).
|
||||
Gatekeeper warning. The official **v0.4.39 Intel DMG** was then verified,
|
||||
installed and launched on that Air. Both CLI and T3 browser downloads lacked
|
||||
quarantine metadata; no Gatekeeper dialog appeared, while `spctl` rejected the
|
||||
ad hoc signed app. This does not verify Safari/Chrome warnings or Open Anyway.
|
||||
See the [released Intel test](docs/evidence/mac-v0439-2026-10-26.md) and
|
||||
[earlier architecture failure](docs/evidence/mac-desktop-2026-10-26.md).
|
||||
|
||||
On a compatible Mac, if macOS blocks an unverified developer, try opening the
|
||||
app once, then go to **System Settings → Privacy & Security → Open Anyway**
|
||||
and confirm **Open**. Only do this for a download you verified and trust.
|
||||
[Apple's opening instructions](https://support.apple.com/102445) describe the
|
||||
current security controls. The exact warning and this override flow have not
|
||||
yet been verified here on Apple Silicon.
|
||||
yet been verified here on either Mac architecture.
|
||||
|
||||
If a trusted, verified copy still reports “damaged” and offers no Open Anyway
|
||||
option, the Terminal fallback removes only this app's download quarantine
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.39",
|
||||
"version": "0.4.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.39",
|
||||
"version": "0.4.42",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.39",
|
||||
"version": "0.4.42",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.39**, 26 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.
|
||||
Release baseline: Frame Control **v0.4.41**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “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 |
|
||||
|---|---|---|---|---|
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): v0.4.35/v0.4.36 DMGs verified and installed; both fail to launch because they are ARM64-only. Separate Intel candidate launched: UI, native preference read/write (restored), bundled Python logs, latest-version check and offline OBS Start/Stop checked; released UI browsed 116 Decky store entries | No | Candidate results are not released-build acceptance. Native Open at Login state, Gatekeeper override, real OBS/Frame stream, native settings across restart, headset transfer and wearer interaction remain unverified | 26 Oct, v0.4.35/v0.4.36 and Intel candidate `6ea9eb2`: [real-Mac evidence](evidence/mac-desktop-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 installed and removed reserved native/Proton titles on the real Frame; all 13 steps passed. ARM64 and Windows titles launched | No | The x86_64 title could not launch because Steam Linux Runtime 4.0 was not installed. The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, v0.4.38 exact tag `d87b863`, Frame OS 0.5.6 build `20261022.6153909`: [latest release smoke](evidence/v0438-release-smoke-2026-10-26.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): official v0.4.39 x64 DMG and ZIP hashes/architectures verified; clean DMG app installed and launched. Native UI, preferences across restart, update discovery of 0.4.40, offline OBS Start/Stop and 116-entry Decky store checked. Actual Open at Login menu toggled on, persisted across Quit/relaunch, then restored off. A 54-byte relay upload/readback/delete passed under coordinator/headset locks | No | Ordinary browser quarantine, right-click Open and Open Anyway remain unverified; the synthetic quarantine probe was inconclusive. Actual login startup untested. Direct frame.local had no route and app SSH trust failed; relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Earlier shutdown race fix #284 shipped in v0.4.40, whose Intel installer was not tested here | 26 Oct, official v0.4.39 tag `fd0b0a9`: [repeat Intel acceptance evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); [earlier release test](evidence/mac-v0439-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 ran the exact v0.4.41 tagged source against the real Frame; 13/13 reserved title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Optional Steam Linux Runtime 4 was absent, so the x86_64 test executable did not actually launch. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.41 tag `b5d50a8`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0441-release-2026-10-26.md); [earlier candidate smoke](evidence/v0441-candidate-frame-2026-10-26.md) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30.0.2 on a Linux host: 187.367 s, 720p30 H.264/AAC stereo, 5,621 frames, no encoder skips; room passthrough appeared in the recording | No | Synthetic tone was measured, not real game audio or audible listening. Default audio led by about 0.43 s in this setup; a measured +480 ms OBS offset removed residual skew at test resolution. Source-image latency was 1.431 s in one trial; other hosts and long sessions are unverified | 25 Oct, v0.4.30 package plus PR #219 runtime: [validation](evidence/obs-validation-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | Windows 11 VM helper fixture passed, but a native bridge to the real Frame sent no packets | In-app OBS controls remain hidden; native Windows OBS and firewall path are unverified | 25 Oct, PR #219: [validation](evidence/obs-validation-2026-10-25.md), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | Physical Intel Air: official v0.4.39 native Start/Stop returned to idle offline | No | Real headset stream, OBS receiver and audio/video remain unverified; the app rejected its stale SSH identity | 26 Oct, v0.4.39: [Mac evidence](evidence/mac-v0439-2026-10-26.md), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
|
||||
@@ -21,10 +21,10 @@ Current for Frame Control **v0.4.39**, 26 October 2026. “Real Frame + physical
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
|
||||
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Open at login | No Frame needed; official v0.4.39 on the physical Intel Air toggled the actual native menu on, retained its checkmark after Quit/relaunch and restored it off. Linux autostart file previously toggled on a physical Linux host with an isolated config directory | No | A full OS login session and physical Windows host login remain untested | 26 Oct, v0.4.39: [native Intel toggle evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); Linux 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 | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.39: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [new standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. The exact-version official catalog covers 116 plugins: 1 works (Notebook default flow only), 45 partial, 59 broken and 11 not applicable. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Three official audio plugins had local real-backend controls and independent virtual-audio readbacks, but their ARM64 Frame audio sessions and physical output remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.39: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; 98 local Chromium initializers were ready and 18 failed. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.39: [latest unworn sample evidence](evidence/framey-vr-standby-gate-2026-10-26.md), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.40: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [post-#292 unworn sample exercise](evidence/v0440-release-2026-10-26.md#post-tag-292-panel-regression), [earlier standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. v0.4.41 classifies the 116 exact-version plugins as 3 works, 48 partial, 54 broken and 11 not applicable. Notebook works for its default flow; Volume Mixer and Mixer work for tested per-app silent-stream volume control only. TabMaster is partial. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.41: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [ARM64 audio evidence](evidence/framey-audio-arm64-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; v0.4.40 recorded 101 local Chromium initializers ready and 15 failed, up from 98/18 in v0.4.39. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.40: [post-#294 unworn sample and scroll evidence](evidence/framey-scroll-probe-2026-10-26.md#post-294-exact-main-sample-check), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [post-release sample exercise](evidence/v0439-release-2026-10-26.md#post-release-framey-library-change-282), [worn checklist](framey-vr-check.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.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Headset smoke: unavailable x86 runtime is a skip
|
||||
|
||||
The v0.4.41 tagged smoke result recorded 13/13 passed even though its x86_64 launch outcome was `runtime missing`: Steam had acknowledged the title but had not started it. This follow-up separates an unavailable optional runtime from a verified launch. It keeps the smoke usable on a Frame without Steam Linux Runtime 4 while making the missing coverage explicit in its console summary and saved JSON.
|
||||
|
||||
Focused local `test_smoke_cleanup.py` ran **22 tests successfully**. The new regression starts an x86_64 title cycle with a fresh missing-runtime Steam log, verifies `skipped=true` and `outcome=runtime missing`, and verifies that the title is still removed. A result test checks separate passed/skipped/failed counts and a zero exit code when an optional runtime is absent but nothing fails.
|
||||
|
||||
The revised `scripts/frame-smoke.sh` then ran on the real Frame from this branch. It acquired its unique owner in `/tmp/frame-test.lock` before any headset read. Locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, the result was **12 passed / 1 skipped / 0 failed**. ARM64 and Proton test executables ran; x86_64 was explicitly skipped because Steam Linux Runtime 4 was not installed. Both title cleanup steps passed. The command exited 0, including release of its owned shared lock. The detailed result remains private at `tests/smoke/results/2026-10-26T050711Z-20261022.6153909.json` in the test checkout.
|
||||
|
||||
The headset was in standby and nobody wore it for this test. Title smoke does not prove wearer-visible pixels, physical controller input, official Decky panels, audio output or comfort. It also does not prove x86_64 runtime execution on this Frame; that path needs the optional runtime present. The hourly worn check remains enabled.
|
||||
@@ -0,0 +1,384 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"timeUtc": "2026-10-26T04:05:15.145624+00:00",
|
||||
"baseCommit": "56bde24",
|
||||
"frameOS": "0.5.6",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"scope": "Local Chromium opaque Framey panels, production SSH/CDP bridge and actual unmodified official backends in ARM64 Decky; real user audio session, silent test stream only; no worn VR",
|
||||
"plugins": [
|
||||
{
|
||||
"id": 38,
|
||||
"name": "Volume Mixer",
|
||||
"version": "1.2.1-1",
|
||||
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"name": "Volume Boost",
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc"
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"name": "Mixer",
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106"
|
||||
}
|
||||
],
|
||||
"readOnlySession": {
|
||||
"timeUtc": "2026-10-26T04:03:54.509107+00:00",
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"volumeMixerLogExists": false,
|
||||
"defaultSinkVolume": {
|
||||
"code": 0,
|
||||
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pactlInfoExit": 0,
|
||||
"pwDumpExit": 0
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"volumeMixerLogExists": false,
|
||||
"defaultSinkVolume": {
|
||||
"code": 0,
|
||||
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pactlInfoExit": 0,
|
||||
"pwDumpExit": 0
|
||||
},
|
||||
"calls": [
|
||||
{
|
||||
"id": 38,
|
||||
"method": "get_sink_inputs",
|
||||
"args": [
|
||||
{}
|
||||
],
|
||||
"legacy": true,
|
||||
"version": "1.2.1-1",
|
||||
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
|
||||
"result": {
|
||||
"success": true,
|
||||
"streamCount": 1,
|
||||
"volumes": [
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"legacy": true,
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"legacy": true,
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "get_settings",
|
||||
"args": [],
|
||||
"legacy": false,
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
|
||||
"result": {
|
||||
"result": {
|
||||
"filters": [],
|
||||
"boostLimit": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "get_playbacks",
|
||||
"args": [],
|
||||
"legacy": false,
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
|
||||
"result": {
|
||||
"count": 6,
|
||||
"internalAudioNodeCount": 5,
|
||||
"steamStreamCount": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"fixedLogAbsent": true
|
||||
},
|
||||
"lockReleased": true
|
||||
},
|
||||
"liveSilentStream": {
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"volumeMixerLogExists": false,
|
||||
"defaultSinkVolume": {
|
||||
"code": 0,
|
||||
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pactlInfoExit": 0,
|
||||
"pwDumpExit": 0
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"uid": 1000,
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"volumeMixerLogExists": false,
|
||||
"defaultSinkVolume": {
|
||||
"code": 0,
|
||||
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pactlInfoExit": 0,
|
||||
"pwDumpExit": 0
|
||||
},
|
||||
"source": "paplay --raw --format=s16le --rate=48000 --channels=2 --client-name=FrameySilentARM64 --stream-name=\"Framey ARM64 silent verification\" /dev/zero",
|
||||
"writes": [
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 31400,
|
||||
"volume": 0.5
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentReadback": [
|
||||
{
|
||||
"index": 31400,
|
||||
"name": "FrameySilentARM64",
|
||||
"volume": {
|
||||
"front-left": {
|
||||
"value": 32768,
|
||||
"value_percent": "50%",
|
||||
"db": "-18.06 dB"
|
||||
},
|
||||
"front-right": {
|
||||
"value": 32768,
|
||||
"value_percent": "50%",
|
||||
"db": "-18.06 dB"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 31400,
|
||||
"volume": 0.45
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentReadback": [
|
||||
{
|
||||
"index": 31400,
|
||||
"name": "FrameySilentARM64",
|
||||
"volume": {
|
||||
"front-left": {
|
||||
"value": 29491,
|
||||
"value_percent": "45%",
|
||||
"db": "-20.81 dB"
|
||||
},
|
||||
"front-right": {
|
||||
"value": 29491,
|
||||
"value_percent": "45%",
|
||||
"db": "-20.81 dB"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
199,
|
||||
37
|
||||
],
|
||||
"result": true,
|
||||
"independentReadback": [
|
||||
{
|
||||
"index": 31400,
|
||||
"name": "FrameySilentARM64",
|
||||
"volume": {
|
||||
"front-left": {
|
||||
"value": 24248,
|
||||
"value_percent": "37%",
|
||||
"db": "-25.91 dB"
|
||||
},
|
||||
"front-right": {
|
||||
"value": 24248,
|
||||
"value_percent": "37%",
|
||||
"db": "-25.91 dB"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
199,
|
||||
38
|
||||
],
|
||||
"result": true,
|
||||
"independentReadback": [
|
||||
{
|
||||
"index": 31400,
|
||||
"name": "FrameySilentARM64",
|
||||
"volume": {
|
||||
"front-left": {
|
||||
"value": 24904,
|
||||
"value_percent": "38%",
|
||||
"db": "-25.21 dB"
|
||||
},
|
||||
"front-right": {
|
||||
"value": 24904,
|
||||
"value_percent": "38%",
|
||||
"db": "-25.21 dB"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"volumeMixerReopen": {
|
||||
"id": 38,
|
||||
"method": "get_sink_inputs",
|
||||
"args": [
|
||||
{}
|
||||
],
|
||||
"result": {
|
||||
"result": [
|
||||
{
|
||||
"name": "FrameySilentARM64",
|
||||
"index": 31400,
|
||||
"volume": 0.3800048828125
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
},
|
||||
"mixerReadAfterChange": {
|
||||
"id": 144,
|
||||
"method": "get_playbacks",
|
||||
"args": [],
|
||||
"result": [
|
||||
{
|
||||
"id": 199,
|
||||
"name": "FrameySilentARM64",
|
||||
"mediaName": "Framey ARM64 silent verification",
|
||||
"volume": 38
|
||||
}
|
||||
]
|
||||
},
|
||||
"volumeBoostReads": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "get_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"fixedLogAbsent": true
|
||||
},
|
||||
"lockReleased": true
|
||||
},
|
||||
"independentCleanupAudit": {
|
||||
"testPathsAbsent": true,
|
||||
"units": {
|
||||
"framey-audio-kp0z857q58-32363fd7": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
|
||||
"framey-audio-kp0z857q58-c08f3f5d": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
|
||||
"framey-audio-kp0z857q58-c08f3f5d-silence": "MainPID=0\nLoadState=not-found\nActiveState=inactive"
|
||||
},
|
||||
"silentStreamsAbsent": true
|
||||
},
|
||||
"limitations": [
|
||||
"Physical audible output, default-sink writes, boost above 100%, Bluetooth playback controls and worn VR unverified.",
|
||||
"Mixer also exposes internal Frame PipeWire nodes and Reset; neither was changed or invoked.",
|
||||
"Private Decky/installed-plugin outer status fixture; real RPC used production framey_panel.call_method over SSH and live Steam CEF, not a deployed headset HTTP endpoint test.",
|
||||
"Mixer filter save/restart was verified on amd64 in the earlier evidence, not repeated on ARM64."
|
||||
],
|
||||
"catalogRefresh": {
|
||||
"timeUtc": "2026-10-26T04:11:59.449535+00:00",
|
||||
"visibleCount": 116,
|
||||
"idsVersionsDigestsUnchanged": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Official audio plugins on the ARM64 Frame
|
||||
|
||||
Volume Mixer 1.2.1-1 (38) and Mixer 0.1.0 (144) now qualify as **works for
|
||||
per-app volume control**. Their exact official, unmodified Python backends ran
|
||||
in published Decky 3.3.0 ARM64 on Frame OS 0.5.6. Local Chromium rendered the
|
||||
actual Framey panels and changed only a deliberately created silent playback
|
||||
stream in the Frame's real user audio session. Independent Pulse readbacks
|
||||
confirmed the changes. This is useful backend behavior, not initializer-only
|
||||
or fake-backend evidence.
|
||||
|
||||
Volume Boost 0.0.7 (58) remains **partial**. Its real ARM64 default-sink reads
|
||||
matched independent `pactl` reads, and its controls rendered. Default-sink writes
|
||||
were denied throughout the real-Frame run. Its earlier slider/numeric write
|
||||
checks used an isolated amd64 virtual sink.
|
||||
|
||||
[Digest-pinned observations and cleanup](framey-audio-arm64-2026-10-26.json)
|
||||
record both runs. No physical audible output or worn-VR result is claimed.
|
||||
|
||||
| Plugin | Real Frame result | Remaining limits |
|
||||
| --- | --- | --- |
|
||||
| Volume Mixer | `pulsectl`/`ravel` import; real Pulse session discovery; silent-stream slider click 100 → 50%, ArrowDown → 45%; reopen reads back the 38% subsequently set by Mixer | Bluetooth discovery/playback commands, audible output and worn-VR input unverified. The range input rounds the reopened 0.38000488 backend value to its configured step; no extra write occurred. |
|
||||
| Volume Boost | `pactl` default sink and stereo channel parsing; both backend reads return 0 and agree with independent 0% / 0% sink reads; local panel shows 0 | No real default-sink writes or boosts; no audible output or worn-VR result. Zero alone is not proof: upstream returns 0 when its command fails. |
|
||||
| Mixer | `pw-dump`/`wpctl` available; real playbacks/settings reads; exact silent-stream numeric input 37%, ArrowUp → 38%, independently read back | Exposes internal Frame PipeWire nodes as well as apps. Internal-node controls and Reset were not exercised. Filter persistence passed on amd64 earlier, not ARM64. Audible output and worn-VR input unverified. |
|
||||
|
||||
## Runtime and transport
|
||||
|
||||
The normal atomic `/tmp/frame-test.lock` was acquired separately for each run.
|
||||
An existing owner would abort testing; the lock was never stolen. The headset
|
||||
was not woken, and no power setting was changed.
|
||||
|
||||
The actual Frame UID is 1000. The legacy plugins' hard-coded
|
||||
`XDG_RUNTIME_DIR=/run/user/1000` therefore reaches this Frame's existing audio
|
||||
session. Volume Mixer's bundled pure Python wrappers successfully loaded the
|
||||
system ARM64 Pulse and D-Bus libraries. No native rebuild was needed. It also
|
||||
sets `HOME=/home/deck` inside its own plugin process; this test does not establish
|
||||
that arbitrary Deck paths work on the Frame.
|
||||
|
||||
The upstream `PluginLoader-arm64` release was verified against GitHub's
|
||||
published SHA-256 and ELF machine 183 before transfer. Two private, bounded,
|
||||
removable user-service installs used their own Decky plugins/settings bases.
|
||||
Neither service was enabled. The normal managed Decky service was already
|
||||
failed, and the normal headset-panel service inactive; both states remained
|
||||
unchanged. A pre-existing `/tmp/VolumeMixer-decky.log` would have aborted the
|
||||
run because the upstream plugin truncates that fixed path during import.
|
||||
|
||||
The local relay served actual `ui/headset.html`, vendored React, `framey-host.js`
|
||||
and digest-checked official frontend bytes in an opaque `allow-scripts` iframe.
|
||||
Only outer status/install responses were fixtures. Plugin RPC used production
|
||||
`framey_panel.call_method`, SSH through lxso2, `frame_steam.Page`, the live Steam
|
||||
CEF `DeckyBackend.call`, and actual Decky plugin subprocesses. Private install
|
||||
paths were redirected to the temporary base. No second Decky websocket client
|
||||
was attached. This does not verify the deployed headset HTTP endpoint or a VR
|
||||
compositor interaction.
|
||||
|
||||
## Safe control check
|
||||
|
||||
The first run was read-only. Independent `pactl info`, default-sink volume and
|
||||
`pw-dump` commands exited successfully, so empty/zero fallback values were not
|
||||
accepted on their own. Volume Mixer saw a live Steam stream; Mixer also saw
|
||||
Frame spatialization, echo-cancellation and ALSA internal nodes.
|
||||
|
||||
For the second run, a separate task-owned transient user service ran:
|
||||
|
||||
```sh
|
||||
paplay --raw --format=s16le --rate=48000 --channels=2 \
|
||||
--client-name=FrameySilentARM64 \
|
||||
--stream-name='Framey ARM64 silent verification' /dev/zero
|
||||
```
|
||||
|
||||
This sends zero-valued samples to the existing user server. It is an explicit
|
||||
silent client, not the earlier private null-server fixture. No server, device,
|
||||
default-route or physical-sink configuration was changed. The relay resolved
|
||||
its live stream IDs before every write and allowed only this exact application
|
||||
name, bounded to 80% volume. The spatialization node with a similar prefix was
|
||||
excluded. All Volume Boost writes, Mixer Reset/settings writes and Bluetooth
|
||||
commands were denied. Existing streams and internal nodes were never write
|
||||
targets. This broader real-session check supersedes the earlier suggested
|
||||
null-server-only ARM64 follow-up; that older fixture could not prove access to
|
||||
the Frame's real session.
|
||||
|
||||
Every browser-triggered volume RPC was followed by independent
|
||||
`pactl --format=json list sink-inputs` channel readback. Only the named silent
|
||||
stream's rows are published. Background streams can appear/disappear naturally;
|
||||
no whole-session stability claim is made.
|
||||
|
||||
## Cleanup and catalog interpretation
|
||||
|
||||
Both runs removed their private Decky units/bases and the test-created fixed
|
||||
plugin log. The live run also stopped its transient silent client. Decky's main
|
||||
process exited during shutdown but left children waiting; those remaining
|
||||
processes were killed only within the test service's systemd cgroup before
|
||||
cleanup completed. Both locks were released after cleanup. An independent
|
||||
fresh-lock audit confirmed all three test units unloaded/inactive with PID 0,
|
||||
private paths absent, and no named silent stream. Battery was 100 → 100 and
|
||||
independent default-sink reads stayed 0% / 0% in both runs.
|
||||
|
||||
The canonical store data and generated compatibility table now report
|
||||
**3 works / 48 partial / 54 broken / 11 N/A**. “Works” describes the tested
|
||||
per-app control behavior; read each row's remaining limits. Historical
|
||||
[fake-Frame archive checks](framey-module-lookup-2026-10-26.md) cover transport
|
||||
with plugin execution stubbed, and the
|
||||
[earlier local audio checks](framey-audio-backends-2026-10-26.md) cover isolated
|
||||
amd64 controls/filter persistence. Neither is relabelled as a real ARM64 test.
|
||||
|
||||
## Repository checks
|
||||
|
||||
The full Python suite passed 1,621 tests (16 skips), and the focused Framey
|
||||
suite passed all 43 tests. `scripts/framey-compatibility-doc.py --check` and
|
||||
`git diff --check` passed. A fresh official-store fetch at 04:11:59 UTC found
|
||||
all 116 IDs, versions and digests unchanged. This PR changes catalog/evidence
|
||||
and generated documentation; it adds no backend shim or plugin patch.
|
||||
@@ -1,5 +1,9 @@
|
||||
# Official audio plugins: local real-backend checks
|
||||
|
||||
Historical amd64 run. The [later ARM64 Frame check](framey-audio-arm64-2026-10-26.md)
|
||||
supersedes the ARM64 gaps and follow-up plan below; it promotes the two per-app
|
||||
mixers to works for their verified scope. Volume Boost remains partial.
|
||||
|
||||
Volume Mixer 1.2.1-1 (38), Volume Boost 0.0.7 (58) and Mixer 0.1.0 (144)
|
||||
passed useful audio controls in local Chromium with their **unmodified official
|
||||
Python backends running in actual Decky 3.3.0**. All three remain **partial**:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"dateUtc": "2026-10-26",
|
||||
"plugin": {
|
||||
"storeId": 135,
|
||||
"name": "Deck Shelves",
|
||||
"version": "2.0.0",
|
||||
"sha256": "b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91"
|
||||
},
|
||||
"mode": "opaque allow-scripts iframe in local Chromium, exact archived frontend, backend and network denied",
|
||||
"baselineError": "relative JavaScript chunk imports cannot resolve from a blob URL",
|
||||
"result": {
|
||||
"initializerReady": true,
|
||||
"error": null,
|
||||
"moduleResponses200": 9,
|
||||
"settingsVisible": [
|
||||
"Enable shelves on Home",
|
||||
"Create default shelves",
|
||||
"Behavior",
|
||||
"Shelves"
|
||||
],
|
||||
"attemptedBackendMethods": [
|
||||
"get_settings"
|
||||
],
|
||||
"backendMethodsExecuted": [],
|
||||
"steamLibraryRead": "denied by audit",
|
||||
"pluginNetwork": "denied by audit"
|
||||
},
|
||||
"limits": [
|
||||
"No Steam Home shelf injection or settings write was verified",
|
||||
"No official backend executed",
|
||||
"No official Deck Shelves interaction occurred on the real Frame or in worn VR",
|
||||
"Settings visibility alone does not prove the plugin works"
|
||||
],
|
||||
"realFrameGate": {
|
||||
"sourceCommit": "b0c4647b856035c970ae27ea4e2ddbf6e51f6963",
|
||||
"headsetOsBuild": "20261022.6153909",
|
||||
"preflightActivity": 3,
|
||||
"preflightBatteryPercent": 100,
|
||||
"postBatteryPercent": 99,
|
||||
"sampleExercisePassed": true,
|
||||
"sampleSettingPersistedAfterFreshIframe": true,
|
||||
"syntheticOpenVrPointerTrusted": true,
|
||||
"stereoPixelsChanged": true,
|
||||
"mirrorCapturePresent": true,
|
||||
"nativeScrollMoved": false,
|
||||
"powerValuesRestored": true,
|
||||
"recoveryTimerRemoved": true,
|
||||
"lockReleased": true,
|
||||
"cleanupErrors": [],
|
||||
"genericFrameSmokePassed": 13,
|
||||
"genericFrameSmokeFailed": 0,
|
||||
"officialDeckShelvesBackendOrVrTested": false,
|
||||
"physicalControllerVerified": false,
|
||||
"wornVisualComfortVerified": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Deck Shelves chunked frontend, 26 October 2026
|
||||
|
||||
The exact official Deck Shelves 2.0.0 archive (store ID 135, SHA-256 `b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91`) contains `dist/index.js` and eight JavaScript chunks. Its API v1 `connect(1, "Deck Shelves")` bridge sits in `chunks/decky-api-WgbNuFYS.js`, not in the entry file; the original static scanner therefore reported its frontend API as unknown. Framey's prior blob import failed before initialization because a blob URL has no directory in which to resolve `./chunks/...`. The [numeric observation](framey-deck-shelves-browser-2026-10-26.json) and [local Chromium capture](framey-deck-shelves-browser-2026-10-26.png) record a targeted follow-up using the unchanged official frontend.
|
||||
|
||||
Framey now serves JavaScript files from the managed plugin's `dist` directory using hierarchical module URLs. Its opaque `allow-scripts` iframe still has no Frame UI key. Local Chromium received HTTP 200 for the entry and eight chunks, reported the initializer ready, and rendered the settings panel with “Enable shelves on Home,” “Create default shelves,” “Behavior,” and “Shelves.” The browser audit denied the attempted `get_settings` backend call, Steam library read and plugin network access. No backend method executed.
|
||||
|
||||
The catalog moves this exact version from broken to **partial** for visible local settings only. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Settings writes, official backend behavior, real Frame module serving, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
|
||||
|
||||
## Exact source real Frame gates
|
||||
|
||||
The exact source commit `b0c4647b856035c970ae27ea4e2ddbf6e51f6963` ran the unattended `scripts/framey-vr-check.py --exercise` on SteamOS build `20261022.6153909` under its unique shared-lock owner. Preflight found SteamVR activity 3 (standby), battery 100%, and proximity 6.25. The repository sample panel appeared, stereo compositor pixels changed, and `/dev/video99` supplied a mirror frame. Synthetic OpenVR pointer input changed the sample setting through actual Decky; the value survived a fresh iframe reopen. Native scroll did not move. The runner removed its temporary services and recovery timer, read back the original `false`/`5` power values, found battery 99%, reported no cleanup errors, and released its lock.
|
||||
|
||||
The same source then passed `scripts/frame-smoke.sh` **13/13** on the real Frame, starting from standby at battery 99%. Its final cleanup step passed. The [sanitized result fields](framey-deck-shelves-browser-2026-10-26.json) omit room pixels, raw overlays, identifiers, and settings contents; private captures were not committed. These gates exercise the Framey repository sample and generic install/launch/remove paths. They do **not** exercise Deck Shelves' official ARM64 backend or prove its settings or shelves in VR. No person wore the headset and no physical controller was checked.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,222 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"timeUtc": "2026-10-26T03:29:05.328492+00:00",
|
||||
"baseCommit": "c89ddff",
|
||||
"scope": "Finite Framey-local DFL component/CSS registry and explicit unsupported optional UI-audio export",
|
||||
"upstreamReferences": [
|
||||
"https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/webpack.ts",
|
||||
"https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/class-mapper.ts"
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"id": 16,
|
||||
"name": "Audio Loader",
|
||||
"version": "1.6.1",
|
||||
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
|
||||
"status": "broken",
|
||||
"reason": "This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel.",
|
||||
"browser": {
|
||||
"id": 16,
|
||||
"name": "Audio Loader",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_AudioPlaybackManager')",
|
||||
"logs": [
|
||||
{
|
||||
"kind": "warn",
|
||||
"message": "Resolve failed: [object Object] promise [object Promise]"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"store_read"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 36,
|
||||
"name": "SteamGridDB",
|
||||
"version": "1.8.1",
|
||||
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
|
||||
"status": "broken",
|
||||
"reason": "Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel.",
|
||||
"browser": {
|
||||
"id": 36,
|
||||
"name": "SteamGridDB",
|
||||
"ready": false,
|
||||
"error": "Cannot convert undefined or null to object",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 61,
|
||||
"name": "TabMaster",
|
||||
"version": "2.15.1",
|
||||
"sha256": "e08333859a32b0c177b8e9a7959c5eeeafe38f946578f7f1d161c721f0e05da5",
|
||||
"status": "partial",
|
||||
"reason": "Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified.",
|
||||
"browser": {
|
||||
"id": 61,
|
||||
"name": "TabMaster",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c TabMaster %c ERROR %c background: #ff6d05; color: black; background: #c70808; color: black; background: transparent; Could not find mobx.reaction"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"log_message"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 84,
|
||||
"name": "Junk-Store",
|
||||
"version": "1.1.13",
|
||||
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
|
||||
"status": "broken",
|
||||
"reason": "Local CSS lookup fixes the loading view crash. Actual Decky 3.3.0 amd64 startup/static InitActions return a script execution/JSON error; the published scripts are not executable after extraction. Store install/login/game launch and ARM64 remain unverified.",
|
||||
"browser": {
|
||||
"id": 84,
|
||||
"name": "Junk-Store",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"fetch_rss_feed",
|
||||
"execute_action"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"name": "DeckSP",
|
||||
"version": "1.1.3",
|
||||
"sha256": "d6cbedcd64430e1f94a2b12caf936ba270f1ceafe77c6252808ff39c88640b40",
|
||||
"status": "broken",
|
||||
"reason": "Optional UI-audio lookup succeeds, but the published frontend raises \"Cannot access PluginManager before initialization\" when Steam MobX reaction lookup is unavailable. JamesDSP installation/audio routing and ARM64 remain unverified.",
|
||||
"browser": {
|
||||
"id": 102,
|
||||
"name": "DeckSP",
|
||||
"ready": false,
|
||||
"error": "Cannot access 'PluginManager' before initialization",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"fullCatalogFrontend": {
|
||||
"attempted": 116,
|
||||
"ready": 102,
|
||||
"failed": 14,
|
||||
"previousReady": 101,
|
||||
"newlyReady": [
|
||||
61
|
||||
],
|
||||
"regressions": [],
|
||||
"backendAndNetworkDenied": true,
|
||||
"library": "Private detached real Frame snapshot replay, not live Steam or backend verification"
|
||||
},
|
||||
"focusedBrowser": {
|
||||
"host": "Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe, exact official bundles",
|
||||
"installedPluginAndStatusResponses": "Fixture-shaped; no headset HTTP endpoint exercised",
|
||||
"backend": "Actual official Decky 3.3.0 amd64 in unprivileged network-none container; no host audio/device/data mounts",
|
||||
"deckySha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
|
||||
"allowedMethods": {
|
||||
"TabMaster": [
|
||||
"log_message",
|
||||
"get_setting",
|
||||
"get_users_dict",
|
||||
"get_tags"
|
||||
],
|
||||
"Junk-Store": [
|
||||
"execute_action only init/InitActions"
|
||||
]
|
||||
},
|
||||
"checks": [
|
||||
"TabMaster QAM renders; library editing controls remain disabled",
|
||||
"TabMaster Open Docs -> Overview renders",
|
||||
"TabMaster Filters tab renders",
|
||||
"Back to plugin returns to QAM",
|
||||
"Junk-Store static InitActions via real backend returns Type Error; not promoted"
|
||||
],
|
||||
"denied": [
|
||||
"External RSS/network",
|
||||
"Other execute_action arguments",
|
||||
"Login, installation, game launch, library patching, audio playback"
|
||||
],
|
||||
"directBackendReads": {
|
||||
"TabMaster.get_setting": "default string returned",
|
||||
"TabMaster.get_users_dict": {},
|
||||
"TabMaster.get_tags": [],
|
||||
"Junk-Store.scriptMode": "0644, Python shebang present"
|
||||
}
|
||||
},
|
||||
"headset": {
|
||||
"newPluginDeviceExecution": false,
|
||||
"lockProbe": "Normal atomic lock acquired/released for read-only dependency availability probe; no service or power changes",
|
||||
"ARM64OfficialBackend": "Not exercised in this theme",
|
||||
"wornVR": false
|
||||
},
|
||||
"verification": {
|
||||
"frameyTests": 42,
|
||||
"fullServerTests": {
|
||||
"count": 1620,
|
||||
"skipped": 16,
|
||||
"result": "OK"
|
||||
},
|
||||
"siteTestFilesPassed": 2,
|
||||
"fakeFrame": {
|
||||
"attempted": 116,
|
||||
"installedRemoved": 60,
|
||||
"expectedBlockers": 56,
|
||||
"backendExecuted": false,
|
||||
"privateProject": "framey-catalog-6456d3d939",
|
||||
"projectRemoved": true,
|
||||
"baseCommit": "c89ddff"
|
||||
}
|
||||
},
|
||||
"storeRefresh": {
|
||||
"timeUtc": "2026-10-26T03:29:46.606326+00:00",
|
||||
"currentCount": 116,
|
||||
"pinnedCount": 116,
|
||||
"unchanged": true,
|
||||
"changedIds": []
|
||||
},
|
||||
"postMainMerge": {
|
||||
"mainCommit": "38c388f",
|
||||
"mergedPRs": [
|
||||
286,
|
||||
287,
|
||||
288
|
||||
],
|
||||
"frameyTests": 43,
|
||||
"fullCatalogFrontend": {
|
||||
"attempted": 116,
|
||||
"ready": 103,
|
||||
"failed": 13,
|
||||
"additionalReadyFromUpstream": [
|
||||
135
|
||||
],
|
||||
"regressionsComparedWithPreMergeAudit": []
|
||||
},
|
||||
"catalog": {
|
||||
"works": 1,
|
||||
"partial": 50,
|
||||
"broken": 54,
|
||||
"na": 11
|
||||
},
|
||||
"fullServerTests": {
|
||||
"count": 1621,
|
||||
"skipped": 16,
|
||||
"result": "OK"
|
||||
}
|
||||
},
|
||||
"cleanup": {
|
||||
"focusedBackendContainerAbsent": true,
|
||||
"loopback18770Closed": true,
|
||||
"loopback18771Closed": true,
|
||||
"fakeProjectAndVolumesRemoved": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
# Framey-local module lookup: official plugin checks
|
||||
|
||||
TabMaster 2.15.1 now renders its QAM and bundled documentation in local Chromium.
|
||||
Opening its docs, selecting Overview and Filters, and returning to the panel
|
||||
passed. Its status moves from **broken to partial**: library editing controls
|
||||
remain disabled because Steam login, live MobX reactions and library-page
|
||||
patches are unavailable. This is not working library-tab customization.
|
||||
|
||||
The [observations](framey-module-lookup-2026-10-26.json) pin each tested official
|
||||
archive and the actual Decky 3.3.0 amd64 binary. The complete current official
|
||||
store was refreshed: all 116 IDs, versions and digests match the catalog.
|
||||
|
||||
| Plugin | Result | Remaining limitation |
|
||||
| --- | --- | --- |
|
||||
| TabMaster 61 | Partial: QAM and documentation navigation render | Steam library editing unavailable; ARM64 and worn VR unverified |
|
||||
| Junk-Store 84 | Loading and backend-error views render without the CSS lookup crash | Actual startup/static InitActions return an execution/JSON error; extracted scripts lack executable permission; remains broken |
|
||||
| SteamGridDB 36 | Module mapping is callable | Still needs Steam's LibraryContextMenu implementation; remains broken |
|
||||
| DeckSP 102 | Optional UI-audio lookup succeeds | Published bundle accesses PluginManager before initialization when MobX reaction is absent; remains broken |
|
||||
| Audio Loader 16 | Optional UI-audio lookup succeeds | Still requires Steam's mutable audio playback store; remains broken |
|
||||
|
||||
## What ran
|
||||
|
||||
The host exposes a finite registry of its own components and styled CSS maps,
|
||||
with DFL module/export/class lookup and mapping semantics. Unknown matches return
|
||||
undefined or an empty mapping. An optional audio export explicitly reports
|
||||
unavailable; invoking playback throws. No Steam webpack cache, function-source
|
||||
signature, audio store or library patch was manufactured.
|
||||
|
||||
The full exact-bundle browser pass ran with backend and network calls denied.
|
||||
It completed 102 initializers and failed 14, versus 101/15 before this theme.
|
||||
Initializer readiness alone does not establish rendered or useful behavior.
|
||||
After integrating main’s #286/#287/#288, all 116 were retried: 103 initializers
|
||||
completed and 13 failed. The additional completion is Deck Shelves from #286;
|
||||
no previously completed initializer regressed.
|
||||
|
||||
Focused checks used actual `ui/headset.html`, React 18.3.1 and opaque
|
||||
`allow-scripts` iframes. Status/installed-plugin responses were fixture-shaped,
|
||||
and the private detached library snapshot from the earlier Frame SSH check was
|
||||
replayed. The relay allowed only TabMaster log/settings reads and Junk-Store's
|
||||
static `init/InitActions` action. TabMaster's official backend returned the
|
||||
default setting, empty users dictionary and empty tags. Junk-Store returned a
|
||||
structured Type Error, which its panel displayed. RSS/network, login, installs,
|
||||
launch actions and other backend methods were denied.
|
||||
|
||||
The unmodified Python backends ran in actual Decky 3.3.0 inside a disposable
|
||||
unprivileged amd64 container with no network, host audio, devices or user-data
|
||||
mounts. This bypasses Steam CEF and is not a headset HTTP/RPC check. The normal
|
||||
headset lock was briefly acquired and released for read-only dependency/service
|
||||
availability; no official plugin ran there in this theme. No ARM64 or worn-VR
|
||||
result is claimed.
|
||||
|
||||
## Regression checks
|
||||
|
||||
- 1,621 server tests passed, 16 skipped; all 43 focused Framey tests passed
|
||||
after integrating the latest main.
|
||||
- Both site test files passed.
|
||||
- All 116 exact archives passed the fake-Frame audit: 60 install/remove flows
|
||||
and 56 expected blockers. Official backend execution remained stubbed.
|
||||
- The unique fake-Frame project and its volumes were removed. The focused
|
||||
backend container and both loopback helpers were removed after the checks.
|
||||
|
||||
The catalog and generated compatibility table use the same status/reasons as
|
||||
both store views. The result is **1 works / 50 partial / 54 broken / 11 N/A** (including main’s independent Deck Shelves improvement);
|
||||
the majority goal remains open.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Framey receipt guard, unworn Frame exercise (26 October 2026)
|
||||
|
||||
The bounded `scripts/framey-vr-check.py --exercise` runner acquired `/tmp/frame-test.lock` with its own random owner before reading the real Frame. Its 05:26 UTC run used source commit `c2f94c242dac670e18259193311a7375f5a87942`. Preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. No wearer was confirmed.
|
||||
|
||||
The temporary Frame Control dashboard overlay became visible to OpenVR. The stereo compositor image changed, and the `/dev/video99` mirror produced a frame. The sample settings panel displayed its stored off value, accepted a trusted synthetic OpenVR pointer event, saved on through the real Decky Python backend, read the setting from disk, and showed on after a fresh iframe reopen. The quick action read on. A native slider reached 50. Text entry used CDP after OpenVR focus. The runner exited 0 with `input.passed=true` and `compositorChanged=true`.
|
||||
|
||||
The private capture files remain outside the repository at `/tmp/frame-pr301-unworn-vr-fixed/`. The before/after stereo SHA-256 values were `1683bf1dab70c1a0a1e8f313c8317dc04f992070b5b0de232de9008700130711` and `684a27eecf7bcbbc5308092ddbb1517c4596fb283e061e73dd6bb7f025954e8b`; the mirror frame SHA-256 was `5939149ea9c85fd2d86d3f8879c348f006c421a386bd66a93fe`. Room pixels are not committed.
|
||||
|
||||
An earlier locked run at 05:24 UTC exposed a missing SHA-256 in the runner's temporary sample receipt: the new guard correctly asked to reopen the panel. Commit `c2f94c2` gives that sample a content digest; the second run passed. Both runs reported no cleanup errors, restored `power.pauseCompositorOnStandby=false` and `power.turnOffScreensTimeout=5`, removed their power recovery timers, and released only their own locks. Battery remained 99%.
|
||||
|
||||
This is an **unworn sample-plugin result**. It does not prove wearer-visible pixels, physical controller input, native scrolling (the attempted scroll offset stayed 0), VR keyboard behavior, comfort, or any official Decky plugin's VR panel or backend. The hourly worn check remains enabled.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,78 @@
|
||||
# Running-game context and Discord Status, 2026-10-26
|
||||
|
||||
Framey now projects running apps and the selected main app from Steam CEF into
|
||||
detached `SteamUIStore` / `DFL.Router` getters. It relays actual Steam lifetime
|
||||
callbacks while the panel is open. Public Steam CDN capsule URLs are projected
|
||||
for running apps; local artwork paths and URL query data never cross the bridge.
|
||||
The [machine-readable evidence](framey-running-context-2026-10-26.json) records
|
||||
the exact archives, fixtures, browser observations and cleanup results.
|
||||
|
||||
| Plugin / scope | Result | Verification |
|
||||
| --- | --- | --- |
|
||||
| Discord Status 1.5.0 | **Broken → partial.** Disconnected panel renders; activity construction accepts the synthetic running game without the previous missing-store/artwork errors. | Exact unmodified official frontend in opaque Chromium iframe; backend/network denied. Actual official `is_connected` returns false in real Decky 3.3.0 ARM64, with no Discord IPC present. |
|
||||
| Pause Games | **Partial.** Running-list initialization reaches `pid_from_appid` for the synthetic game. | Local Chromium fixture; all backend calls, including settings writes and process actions, denied. |
|
||||
| Notebook | **Works for its previously verified default notebook scope.** Per-game context is now supplied, but real per-game selection remains unverified. | Synthetic game causes the official frontend to request `loadPage`; backend calls denied in this pass. Earlier default save/reopen ARM64 evidence remains separate. |
|
||||
| PlayTime | **Partial.** Running snapshots do not satisfy its private MobX observation. | Local Chromium explicitly logs that its running-app observer is unavailable. No automatic tracking claim. |
|
||||
| 14 other focused panels | Labels retained. Initialization alone does not establish backend functionality. | Exact frontends with synthetic nonempty running/library snapshots and backend/network denied; Game Sync still stops on the denied `get_config` read. |
|
||||
|
||||
The bridge addresses a shared dependency detected in 34 official frontend
|
||||
bundles, including hardware-inapplicable plugins whose installation remains
|
||||
blocked. This is infrastructure coverage, not 34 working plugins. Catalog after
|
||||
this pass: **3 works / 49 partial / 53 broken / 11 N/A**.
|
||||
|
||||
## Real Frame checks and cleanup
|
||||
|
||||
Every device probe acquired the atomic `/tmp/frame-test.lock` and released only
|
||||
its matching owner. Read-only probes verified the actual RunningApps and
|
||||
MainRunningApp getter implementations, the lifetime API's object with an
|
||||
`unregister()` method, and a public `https://shared.steamstatic.com` capsule URL.
|
||||
|
||||
A private removable user service ran the published Decky 3.3.0 ARM64 executable
|
||||
and the exact unmodified Discord Status 1.5.0 store archive. Production
|
||||
`framey_panel.call_method` returned false from `is_connected`. Production
|
||||
`poll_running` verified two empty running-list reads, an actual lifetime
|
||||
subscription and explicit close. The device had no running apps: **nonempty
|
||||
reads and launch/exit callbacks were not observed on hardware**. Decky startup
|
||||
reinjected its frontend/restarted Steam CEF. No plugin game, power, audio or process-control
|
||||
action was invoked, and no Discord connection/activity mutation was allowed.
|
||||
|
||||
After close, CEF reported zero sessions and a null timer. The test service,
|
||||
unit and private base were removed. A fresh independently locked audit read
|
||||
`LoadState=not-found`, `ActiveState=inactive`, `MainPID=0`, absent paths and zero
|
||||
CEF sessions/timers. Battery stayed **99 → 99**; normal Decky stayed failed and
|
||||
the normal panel service stayed inactive. No headset wipe or system service
|
||||
installation was used.
|
||||
|
||||
## Local verification and limits
|
||||
|
||||
- Full current official store fetch: 116 IDs/versions/digests unchanged at
|
||||
2026-10-26 05:18:22 UTC.
|
||||
- Fake Frame: 116 transport cases, 60 install/remove cycles and 56 expected
|
||||
blockers; private Compose project and volumes removed. Plugin execution was
|
||||
stubbed.
|
||||
- Full local Chromium sweep: 103 of 116 initializer-ready with explicitly
|
||||
synthetic library/running data. No backend/network request was allowed.
|
||||
The focused Discord Status observation lasted eight seconds and constructed
|
||||
`Test Running Game` activity; the visible UI correctly remained disconnected.
|
||||
- Node contract tests cover actual-event queue projection, retry/deduplication,
|
||||
bounds, expiry, unregister, reconnect, update-before-callback ordering,
|
||||
invalid data, public-artwork filtering, stale sessions, overlapping reads,
|
||||
failure clearing stale state and late replies after close. Nonempty app rows
|
||||
and launch/exit notifications in these tests are explicit synthetic fixtures.
|
||||
|
||||
The queue retains at most 64 real notifications per session; overflow can lose
|
||||
old callbacks. Polling runs about every two seconds with no overlap. At most four
|
||||
CEF sessions survive; unreachable cleanup expires after 15 seconds, checked
|
||||
every five seconds. The bridge does not implement private MobX internals,
|
||||
suspend/input events, game actions or closed-panel automation.
|
||||
|
||||
Discord shortcut detection, real IPC connection/activity, local custom artwork,
|
||||
actual running-game transitions, worn VR and physical controller/keyboard use
|
||||
remain unverified. None of the new fixture checks promotes a plugin to works.
|
||||
|
||||
After #301 merged, running polls and both close paths adopted its pinned SHA-256
|
||||
receipt guard. Local Python/Node tests verify the parent carries that receipt,
|
||||
missing/stale receipts are rejected before CEF after plugin replacement, and a
|
||||
reopened panel can poll and close with the new receipt. Rejected stale closes
|
||||
retain the tested finite-lease cleanup. This authorization-only follow-up did
|
||||
not repeat the device checks above; their recorded hardware scope is unchanged.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Unworn Framey scroll transport probe, 26 October 2026
|
||||
|
||||
The previous `scripts/framey-vr-check.py --exercise` runs reported `scrollY=0` before and after one injected smooth-scroll event. That was inconclusive because the repository sample may fit inside the iframe. This probe changed only the **test script**: it created a temporary 160-pixel-high scroll region with 716 pixels of content, moved the native OpenVR pointer over it, and measured its own `scrollTop` and captured wheel events. No production Framey panel code changed.
|
||||
|
||||
Two local diagnostic versions ran on the real Frame from base main `784118b` after PR #292. The final version in this PR tried OpenVR `VREvent_ScrollSmooth` (309) and `VREvent_ScrollDiscrete` (305), using Valve's documented `VREvent_Scroll_t` ABI. The overlay listing showed the temporary Frame Control dashboard visible with `+smoothscroll`; a trusted native pointer move reached the iframe. Neither scroll event reached Chromium's capture listener and the region stayed at `scrollTop=0`. This establishes only that `IVROverlayView_003.PostOverlayEvent` did not deliver these synthetic scroll events in this run. A physical controller may follow a different path.
|
||||
|
||||
| Check | First probe | Final probe |
|
||||
| --- | --- | --- |
|
||||
| Unique shared lock and standby gate | Acquired; activity 3, battery 99% | Acquired; activity 3, battery 100% |
|
||||
| Scroll region | 160 px viewport, 716 px content | 160 px viewport, 716 px content |
|
||||
| Native pointer | Moved over region | Trusted pointer move reached iframe |
|
||||
| Injected scroll | Smooth 309 | Smooth 309, then discrete 305 |
|
||||
| Wheel events / offset | 0 / 0 → 0 | 0 / 0 → 0 |
|
||||
| Other sample checks | Passed | Passed |
|
||||
| Cleanup | Power restored to `false/5`, recovery timer removed, owned lock released; battery 99% | Same cleanup; battery 99% |
|
||||
|
||||
Both runs captured changed stereo compositor frames and a `/dev/video99` mirror frame, saved only in private folders `/tmp/framey-native-scroll-probe/` and `/tmp/framey-native-scroll-events/`. No room pixels are committed. The final run's `result.json` reported `passed=true`, `cleanupErrors=[]`, `powerRecoveryRemoved=true` and `lockReleased=true`. Here `passed` covers the sample setting, quick action, slider, text and cleanup checks; **native scroll did not pass**. The final immediate SteamVR activity was 0 after the temporary wake; original power settings were restored and read back. No person wore the headset, and physical controller scrolling, rays, haptics, keyboard, optical readability and comfort remain unverified. The hourly worn-check schedule remains enabled.
|
||||
|
||||
## Post-#294 exact-main sample check
|
||||
|
||||
After PR #294 merged, the **unmodified** `scripts/framey-vr-check.py --exercise` from exact main `c38e7e8da459e44a9a717d15e6a07ab8a8e8ef8d` exited 0 on the real Frame. Locked preflight found activity 3 standby, proximity 6.3125 and battery 99%. The sample saved `{"enabled": true}` through trusted synthetic OpenVR input and the live Decky backend, read the file independently, then reopened a fresh iframe with the value retained. Quick action, slider value 50 and text entry completed; compositor frames changed and the mirror capture returned a frame. The old scroll diagnostic still reported no offset movement.
|
||||
|
||||
The run removed its temporary services and recovery timer, restored/read back power `false/5`, reported no cleanup errors, and released only its owned lock. Its immediate activity was 0 after the temporary wake. Private captures/result are under `/tmp/framey-post294/` outside Git. This was one setting activation, so it does not prove the new rapid-double-input guard on physical controllers. No person wore the headset.
|
||||
@@ -0,0 +1,288 @@
|
||||
{
|
||||
"release": "v0.4.39",
|
||||
"tagCommit": "fd0b0a946f11d92fd1cc480cd9d1a83be795bf71",
|
||||
"job": "1vbt29ptkw",
|
||||
"date": "2026-10-26",
|
||||
"host": {
|
||||
"model": "MacBookAir9,1",
|
||||
"cpu": "Intel Core i3-1000NG4",
|
||||
"arch": "x86_64",
|
||||
"macOS": "15.7.7",
|
||||
"build": "24G720"
|
||||
},
|
||||
"artifacts": {
|
||||
"Frame-Control-mac-x64.dmg": {
|
||||
"bytes": 169230398,
|
||||
"sha256": "f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8",
|
||||
"matchesSHA256SUMS": true
|
||||
},
|
||||
"Frame-Control-mac-x64.zip": {
|
||||
"bytes": 169141014,
|
||||
"sha256": "bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7",
|
||||
"matchesSHA256SUMS": true
|
||||
}
|
||||
},
|
||||
"native": {
|
||||
"title": "Apps \u00b7 Frame Control",
|
||||
"bridge": true,
|
||||
"preferencesBefore": {},
|
||||
"views": [
|
||||
{
|
||||
"view": "apps",
|
||||
"title": "Apps \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Apps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "games",
|
||||
"title": "Games \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Games"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "tools",
|
||||
"title": "Tools \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Tools"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "settings",
|
||||
"title": "Settings \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "privacy",
|
||||
"title": "Privacy & updates \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Privacy & updates"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "capture",
|
||||
"title": "Capture \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Capture & Live"
|
||||
]
|
||||
},
|
||||
{
|
||||
"view": "decky",
|
||||
"title": "Decky Loader \u00b7 Frame Control",
|
||||
"heading": [
|
||||
"Decky Loader"
|
||||
]
|
||||
}
|
||||
],
|
||||
"decky": {
|
||||
"state": "116 plugins in the official store. Select a plugin to install it.",
|
||||
"rows": 40,
|
||||
"search": "ProtonDB Badges \u00b7 1.3.4 Display tappable ProtonDB badges on your game pages\n\npartial: Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. Verified: static archive, local browser, fake-Frame, real Frame SSH.\n\nInstall",
|
||||
"installDisabled": [
|
||||
{
|
||||
"text": "Install",
|
||||
"disabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"preferenceWritten": "0",
|
||||
"preferencesAfter": {
|
||||
"mvSeparate": "0"
|
||||
},
|
||||
"update": {
|
||||
"status": "available",
|
||||
"current": "0.4.39",
|
||||
"error": null,
|
||||
"canInstall": true,
|
||||
"latest": "0.4.40"
|
||||
},
|
||||
"obs": {
|
||||
"before": {
|
||||
"state": "idle",
|
||||
"alias": "",
|
||||
"port": 15099,
|
||||
"error": "",
|
||||
"supported": true
|
||||
},
|
||||
"start": {
|
||||
"state": "starting",
|
||||
"alias": "frame",
|
||||
"port": 15139,
|
||||
"error": "",
|
||||
"supported": true
|
||||
},
|
||||
"stop": {
|
||||
"state": "stopping",
|
||||
"alias": "frame",
|
||||
"port": 15139,
|
||||
"error": "",
|
||||
"supported": true
|
||||
},
|
||||
"after": {
|
||||
"state": "idle",
|
||||
"alias": "frame",
|
||||
"port": 15139,
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
"preferencesAfterRestart": {
|
||||
"mvSeparate": "0"
|
||||
}
|
||||
},
|
||||
"loginItem": {
|
||||
"enabledBeforeQuit": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"nativeQuit": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\nPRESS 0\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"restartPid": 50393,
|
||||
"enabledAfterRestart": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"beforeRestore": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"restoreOff": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\nPRESS 0\n",
|
||||
"finalMenu": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
|
||||
"restoredOriginalOff": true
|
||||
},
|
||||
"macHelper": {
|
||||
"permissions": {
|
||||
"accessibility": false,
|
||||
"screen": false
|
||||
},
|
||||
"displays": {
|
||||
"displays": [
|
||||
{
|
||||
"h": 900,
|
||||
"id": 69734464,
|
||||
"main": true,
|
||||
"name": "Built-in Retina Display",
|
||||
"src": "display:69734464",
|
||||
"w": 1440
|
||||
}
|
||||
]
|
||||
},
|
||||
"windows": {
|
||||
"screen": false,
|
||||
"count": 3,
|
||||
"frameControlCount": 2
|
||||
}
|
||||
},
|
||||
"headset": {
|
||||
"timestamp": "2026-10-26T04:56:52.808203+00:00",
|
||||
"route": "Mac SSH -> pinned lxso2 -> trusted frame SSH",
|
||||
"owner": "intel-v0439-1vbt29ptkw-20261026",
|
||||
"payloadBytes": 54,
|
||||
"coordinatorLock": "locked\n",
|
||||
"headset": {
|
||||
"os": {
|
||||
"VERSION_ID": "0.5.6",
|
||||
"BUILD_ID": "20261022.6153909",
|
||||
"VARIANT_ID": "vr"
|
||||
},
|
||||
"battery": [
|
||||
{
|
||||
"percent": 99,
|
||||
"status": "Not charging"
|
||||
}
|
||||
],
|
||||
"homeStorage": {
|
||||
"total": 996984774656,
|
||||
"free": 769012412416
|
||||
},
|
||||
"screenshotJpegs": 7,
|
||||
"transfer": {
|
||||
"path": "/tmp/intel-v0439-1vbt29ptkw-20261026-vtynzyky/intel-mac-check.txt",
|
||||
"bytes": 54,
|
||||
"sha256": "82029b7e8b519bac65e43a6e90386a06da9988b23330aa7c48fea0abdfac0a8b",
|
||||
"deleted": true
|
||||
}
|
||||
},
|
||||
"headsetLockReleased": true,
|
||||
"coordinatorLockRelease": "released\n"
|
||||
},
|
||||
"gatekeeper": {
|
||||
"downloadMethod": "gh release download",
|
||||
"downloadQuarantinePresent": false,
|
||||
"controlledSyntheticQuarantine": "inconclusive",
|
||||
"cleanQuarantinedOpen": "NSPOSIXErrorDomain Code=16 Resource busy",
|
||||
"cleanSpctl": "timeout after 15 seconds",
|
||||
"rightClickOpenVerified": false,
|
||||
"openAnywayVerified": false,
|
||||
"appSpecificXattrFallbackUsed": true,
|
||||
"globalGatekeeperChanged": false
|
||||
},
|
||||
"headsetCleanupReadback": {
|
||||
"headset": {
|
||||
"testFoldersAbsent": true,
|
||||
"lockAbsent": false,
|
||||
"subsequentLockOwner": "frame-smoke-92bb2294f1d9cd4b7084",
|
||||
"subsequentLockLeftUntouched": true
|
||||
},
|
||||
"coordinatorLockAbsent": true
|
||||
},
|
||||
"cleanup": {
|
||||
"debugPortClosed": true,
|
||||
"originalPreferencesRestored": true,
|
||||
"ownedAppRemaining": [],
|
||||
"desk": {
|
||||
"foreground_apps": [
|
||||
"Finder",
|
||||
"Messages",
|
||||
"cmux"
|
||||
],
|
||||
"display_on": false,
|
||||
"notifications_found": 0,
|
||||
"attention": []
|
||||
},
|
||||
"dmgejected": true,
|
||||
"installedSignatureValidAfterRuntime": true,
|
||||
"finalInstalledSignature": {
|
||||
"removedOnlyGeneratedBytecodeFiles": 36,
|
||||
"finalSignatureExit": 0,
|
||||
"finalSignatureStderr": ""
|
||||
}
|
||||
},
|
||||
"validation": {
|
||||
"sourceBase": "741735aa98e615fb51bb1a5adb665c9d12e3fd2a",
|
||||
"initialMacFullSuite": {
|
||||
"python": "system 3.9",
|
||||
"tests": 1621,
|
||||
"failures": 6,
|
||||
"skipped": 20,
|
||||
"seconds": 818.353
|
||||
},
|
||||
"affectedMacModulesAfterFix": {
|
||||
"python": "official bundled 3.12",
|
||||
"tests": 87,
|
||||
"skipped": 2,
|
||||
"seconds": 37.605,
|
||||
"result": "OK"
|
||||
},
|
||||
"focusedAppTests": {
|
||||
"tests": 14,
|
||||
"passed": 14
|
||||
},
|
||||
"websiteTests": {
|
||||
"tests": 18,
|
||||
"passed": 18
|
||||
},
|
||||
"testFixes": [
|
||||
"Skip local Frame recording shell fixtures on Bash 3.2, whose read timeout status differs from Frame Bash 5",
|
||||
"Require actual XCTest.framework for standalone XCTest contracts",
|
||||
"Separate OBS fixture interpreter startup from stream-stall deadlines and synchronize signal-handler readiness",
|
||||
"Leave margin between panel fixture delays and individual X tool timeouts"
|
||||
],
|
||||
"linuxFullSuiteAfterFix": {
|
||||
"tests": 1621,
|
||||
"skipped": 16,
|
||||
"seconds": 170.313,
|
||||
"result": "OK",
|
||||
"isolation": "temporary archived checkout on lxso2"
|
||||
},
|
||||
"testedFileSHA256": {
|
||||
"tests/test_clips.py": "00ba70866cda2b6cacaa0dd9bd19e9f2a9532a035cef66c9d8e2bd0021b1f489",
|
||||
"tests/test_ios_contracts.py": "f1cc22e54873617e381025233c6b422245e6943ba7432331fc2d01e7a9ae4fc1",
|
||||
"tests/test_obs_headset_source.py": "62462cbadbfcf67d4c200edf54ee45697b6f8d6547cd4c538f0344d71cf70241",
|
||||
"tests/test_touch.py": "c34c646d134e57130c4fb43cd3d53e6c859369f774f9b7fd1dd8c3e718b72b6f"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# Intel Mac acceptance test: official v0.4.39, 26 October 2026
|
||||
|
||||
Tested on the physical **MacBookAir9,1, Intel Core i3-1000NG4 1.10 GHz,
|
||||
8 GB RAM, macOS 15.7.7 (24G720), x86_64**. Job `1vbt29ptkw` repeated the
|
||||
[earlier release test](mac-v0439-2026-10-26.md) and added ZIP verification,
|
||||
native menu actions and Open at Login persistence. The app under test was
|
||||
the official [v0.4.39 release](https://github.com/saphid/frame-control/releases/tag/v0.4.39),
|
||||
tag commit `fd0b0a946f11d92fd1cc480cd9d1a83be795bf71`.
|
||||
It was not rebuilt or patched. Current source validation is reported separately.
|
||||
|
||||
## Download and clean DMG installation
|
||||
|
||||
Downloaded both Intel assets and `SHA256SUMS` from that exact tag with
|
||||
`gh release download`. Independently calculated SHA-256 values matched the
|
||||
same-release manifest:
|
||||
|
||||
| Asset | Bytes | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `Frame-Control-mac-x64.dmg` | 169,230,398 | `f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8` |
|
||||
| `Frame-Control-mac-x64.zip` | 169,141,014 | `bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7` |
|
||||
|
||||
Mounted the DMG read-only with `hdiutil attach -readonly -nobrowse` and
|
||||
unpacked the ZIP separately. For each app, `file` reported
|
||||
`Mach-O 64-bit executable x86_64` and `lipo -info` reported:
|
||||
|
||||
```text
|
||||
Non-fat file: …/Contents/MacOS/Frame Control is architecture: x86_64
|
||||
```
|
||||
|
||||
The DMG's bundled Python and `frame-mac-view` are also x86_64; `adb` is
|
||||
universal with x86_64 and arm64 slices. Both fresh bundles passed
|
||||
`codesign --verify --deep --strict --verbose=2`. Signing is ad hoc, with no
|
||||
TeamIdentifier. This verifies the resource seal, not Apple notarization.
|
||||
|
||||
Preserved the existing `/Applications/Frame Control.app` in the task's
|
||||
temporary directory. An initial `ditto` overlay retained that installation's
|
||||
extra Python bytecode caches and failed signature verification. Moved that
|
||||
copy aside and copied the DMG app into an empty destination. The clean copy
|
||||
passed `codesign --verify --strict`; the release bundles themselves passed.
|
||||
The final installed app is the clean DMG copy.
|
||||
|
||||
## Gatekeeper: tested fallback, incomplete first-open coverage
|
||||
|
||||
The CLI downloads had **no `com.apple.quarantine` attribute**. This Air had
|
||||
already launched the same app in the earlier job, so it is not a pristine
|
||||
first-install trust environment. A controlled probe added only this app's
|
||||
quarantine attribute (`0083;<timestamp>;FrameControlIntelReleaseTest;`).
|
||||
That is synthetic metadata, not a Safari or Chrome download.
|
||||
|
||||
The overlaid copy's first `open` returned 0 and later produced a translocated
|
||||
app process. After making the clean copy, a quarantined `open` returned:
|
||||
|
||||
```text
|
||||
The application cannot be opened, error=Error Domain=NSPOSIXErrorDomain Code=16 "Resource busy"
|
||||
```
|
||||
|
||||
Assessment of the clean copy with `spctl --assess --type execute -vv` did
|
||||
not finish within 15 seconds. No readable Gatekeeper warning was captured;
|
||||
the controlled probe is **inconclusive**. Do not interpret `open` returning
|
||||
0 as proof of passing Gatekeeper. Native screen capture was unavailable.
|
||||
|
||||
The exact app-specific fallback used for the verified clean copy was:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
|
||||
The installed app then launched successfully. No global Gatekeeper setting
|
||||
was changed. For a normal user, verify the Intel download, mount the DMG,
|
||||
drag the app to Applications and open it. If macOS blocks it, Apple's
|
||||
documented route is **System Settings → Privacy & Security → Open Anyway →
|
||||
Open**, after the initial attempt. **Right-click Open and Open Anyway were
|
||||
not exercised in this run**; neither is certified here. The Terminal command
|
||||
above is the fallback actually exercised, limited to this trusted app.
|
||||
|
||||
## Official native app checks
|
||||
|
||||
Controlled runs disabled telemetry and used a task-local server data
|
||||
directory. A temporary loopback CDP port inspected the actual Electron
|
||||
renderer and its real `window.frameApp` bridge. This was not a web-only
|
||||
substitute. Native menu actions used Apple's Accessibility API through an
|
||||
existing authorized SSH session; no Accessibility grant was added. Direct
|
||||
System Events/AppleScript calls timed out. The API route is why this run can
|
||||
verify native menu actions that the earlier evidence left untested.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Launch | **Pass.** Native window, real IPC bridge and bundled Python server loaded. Version remained 0.4.39 |
|
||||
| Offline UI | **Pass.** Apps, Games, Tools, Settings, Privacy & updates, Capture & Live and Decky rendered their expected headings |
|
||||
| Decky store | **Pass.** Official store returned 116 plugins, with 40 initial rows. Search displayed ProtonDB Badges 1.3.4 and its compatibility text. Install stayed disabled; no loader or plugin was installed |
|
||||
| Settings save | **Pass.** Native preferences initially `{}`; `mvSeparate="0"` saved, read back and survived a real app restart. Restored the original absence of the preferences file afterward |
|
||||
| Update check | **Pass.** Native IPC returned `available`, current 0.4.39, latest 0.4.40, `error=null`, `canInstall=true`. The actual Check for Updates menu action also succeeded. No update installed |
|
||||
| OBS Start/Stop | **Partial.** Native status supported/idle → Start on port 15139 returned starting → Stop returned stopping → idle. This checks local helper lifecycle; no OBS receiver or headset stream was exercised |
|
||||
| Open at Login | **Pass for toggle and persistence.** Native menu began unchecked. AXPress returned success, then the checkmark appeared. Native Quit/relaunch preserved it. Toggled off and independently read the unchecked menu back. Actual logout/login execution remains untested |
|
||||
| Show Server Log | **Partial.** Native menu AXPress succeeded and launched Console. The log contains startup with the installed bundled Python/server paths. The viewer's displayed document was not independently read back |
|
||||
| Intel Mac helper | **Pass for read-only commands.** `permissions`, `displays` and `windows` returned JSON. One 1440×900 built-in display was listed; screen/accessibility permissions were false. No capture or permission request was initiated by these commands |
|
||||
|
||||
[Native searched Decky store screenshot](intel-mac-v0.4.39-2026-10-26.png).
|
||||
[Structured observations](intel-mac-v0.4.39-2026-10-26.json).
|
||||
|
||||
## Headset: read-only checks and one temporary transfer
|
||||
|
||||
`frame.local` resolved, but its LAN port 22 returned **No route to host**.
|
||||
The Tailscale endpoint answered TCP 22, but direct SSH and the app refused
|
||||
the Air's stale/missing host trust. No pin was replaced to bypass that
|
||||
failure. Used the already-pinned `saphid@lxso2` coordinator relay and its
|
||||
trusted `frame` SSH connection instead.
|
||||
|
||||
Atomically acquired `/tmp/frame-test.lock` on the coordinator and headset
|
||||
with owner `intel-v0439-1vbt29ptkw-20261026`. Under those locks:
|
||||
|
||||
- Read SteamOS **0.5.6**, variant `vr`, build **20261022.6153909**.
|
||||
- Read battery **99%, Not charging**.
|
||||
- Read home storage: **996,984,774,656 bytes total**, **769,012,412,416 free**.
|
||||
- Listed **7** JPEGs matching Steam's
|
||||
`userdata/*/760/remote/*/screenshots/*.jpg`; no screenshots were copied.
|
||||
- Sent a **54-byte** payload originating on this Mac through the relay into
|
||||
an exclusively created `/tmp/intel-v0439-1vbt29ptkw-20261026-…/` folder.
|
||||
Its readback SHA-256 matched
|
||||
`82029b7e8b519bac65e43a6e90386a06da9988b23330aa7c48fea0abdfac0a8b`.
|
||||
Deleted the exact file and its temporary folder.
|
||||
- Released only matching owned locks. Separate readback confirmed the
|
||||
headset lock was absent immediately after release. A later independent check
|
||||
found the transfer folder absent and coordinator lock absent, while the
|
||||
headset lock had been acquired by another job (`frame-smoke-92bb2294f1d9cd4b7084`);
|
||||
that different owner was left untouched.
|
||||
|
||||
This verifies a relay transfer, **not the native app's direct file-transfer
|
||||
path**. No existing headset content, installs, services, settings, power
|
||||
state or SteamVR state was changed. No wipe or reboot was attempted.
|
||||
|
||||
## Bugs, validation and remaining limits
|
||||
|
||||
No new Intel-specific product bug was isolated in these runtime checks.
|
||||
The earlier v0.4.39 shutdown race already has a merged fix in
|
||||
[PR #284](https://github.com/saphid/frame-control/pull/284), shipped in
|
||||
v0.4.40. This run did not install or validate the v0.4.40 Intel app.
|
||||
|
||||
The initial full source suite on system Python 3.9 ran **1,621 tests in
|
||||
818.353 seconds**, with **six failed assertions and 20 skips**. These were
|
||||
source-test issues, separate from the released native checks:
|
||||
|
||||
- The recording fixture's time limit ended with `q`, because macOS Bash 3.2
|
||||
reports read timeout as status 1, the same as EOF. Frame Bash 5 reports a
|
||||
value above 128. Local recording-shell tests now explicitly require Bash 4+.
|
||||
- The Foundation XCTest test found `swiftc` but failed `import XCTest`:
|
||||
the active Command Line Tools installation has no XCTest framework.
|
||||
It now checks for the actual framework and skips when unavailable.
|
||||
- Two OBS tests (three assertions, including subtests) started 0.15/0.3-second
|
||||
watchdog clocks before the fixture's Python interpreter and signal handler
|
||||
were ready. Fixture startup is now synchronized or separately budgeted;
|
||||
the short stream-stall checks remain. Production watchdog limits did not change.
|
||||
- The panel timeout fixture's 4.6-second sleeps were too close to each tool's
|
||||
five-second timeout once process startup was included, causing the wrong
|
||||
failure path. Three-second sleeps still exhaust the 12-second overall
|
||||
deadline before a panel is complete, with margin for process startup.
|
||||
|
||||
All four affected modules then passed on this physical Air with the official
|
||||
app's bundled Python 3.12: **87 tests, two explicit prerequisite skips,
|
||||
37.605 seconds**. Focused native app tests passed **14/14** and website tests
|
||||
**18/18**. An isolated Linux checkout of base `741735a` plus the exact four
|
||||
test-file changes passed the **full 1,621-test suite, 16 skips, 170.313 seconds**,
|
||||
including recording-shell fixtures under modern Bash. No full Mac suite pass
|
||||
after these corrections is claimed.
|
||||
|
||||
Final native cleanup confirmed the app processes and loopback debug port were
|
||||
closed, original preferences restored, Open at Login off, the DMG ejected and
|
||||
its installed signature still valid. External test subprocesses had generated
|
||||
36 Python bytecode files inside the bundle; removed only those absent from
|
||||
the verified clean release before the final signature check. The native app
|
||||
uses `-B` itself. The installed desk cleanup tool reported
|
||||
only **Finder, Messages and cmux** in front, **zero notifications**, no items
|
||||
needing attention and the **display asleep**. The official app remains installed;
|
||||
previous copies and raw test logs are retained in `/tmp/frame-control-v0439-intel`.
|
||||
The desk hold is released at the end of the task.
|
||||
|
||||
Actual login startup, ordinary browser quarantine/first-open override,
|
||||
direct app/headset transfer, real OBS audio/video, Mac screen capture,
|
||||
headset VR interaction, wearer comfort and controller input remain untested.
|
||||
No social post was made. PR #113 was left untouched; merging and releasing
|
||||
remain with the lxso2 coordinator.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 560 KiB |
@@ -5,6 +5,10 @@ Host: **MacBook Air (MacBookAir9,1), Intel Core i3 1.1 GHz, macOS 15.7.7
|
||||
server with display/automation restrictions. No Apple Silicon runtime result
|
||||
is inferred from this test.
|
||||
|
||||
**Follow-up:** the official v0.4.39 Intel installer later launched on this Air.
|
||||
See the [released-build test](mac-v0439-2026-10-26.md); the results below remain
|
||||
the earlier ARM64 failure and separate candidate test.
|
||||
|
||||
## Released installers: verified, installed, cannot run on Intel
|
||||
|
||||
GitHub's latest release was v0.4.35 at the start; v0.4.36 became latest during
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Official Intel Mac release test, 26 October 2026
|
||||
|
||||
Physical **MacBookAir9,1, Intel Core i3 1.1 GHz, macOS 15.7.7 (24G720)**.
|
||||
The earlier ARM64 failure led to [#262](https://github.com/saphid/frame-control/pull/262),
|
||||
now merged. This test uses the published **v0.4.39**, tag `fd0b0a9`, rather
|
||||
than the locally built candidate. [Earlier results](mac-desktop-2026-10-26.md)
|
||||
remain a separate historical record.
|
||||
|
||||
## Download, installation and Gatekeeper observations
|
||||
|
||||
Downloaded `Frame-Control-mac-x64.dmg` and its same-release `SHA256SUMS`
|
||||
with `gh release download`. `shasum -a 256 -c SHA256SUMS --ignore-missing`
|
||||
passed for the downloaded asset:
|
||||
|
||||
```text
|
||||
f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8 Frame-Control-mac-x64.dmg
|
||||
```
|
||||
|
||||
Also clicked that asset in T3's Chromium browser. Its 169,230,398-byte download
|
||||
had the same hash. **Neither download had `com.apple.quarantine`.** T3's
|
||||
browser is not evidence of Safari/Chrome download metadata.
|
||||
|
||||
Mounted the verified CLI DMG read-only, preserved the task's earlier ARM64
|
||||
installation, and copied `Frame Control.app` into `/Applications` with `ditto`
|
||||
(the command-line equivalent of dragging it there). The executable is x86_64;
|
||||
`codesign -dv` reports ad hoc signing. `open "/Applications/Frame Control.app"`
|
||||
returned success and the app started its bundled Python server.
|
||||
|
||||
**No Gatekeeper warning dialog was observed.** `spctl --assess --type execute
|
||||
-vv "/Applications/Frame Control.app"` returned exit 3 and exactly:
|
||||
|
||||
```text
|
||||
/Applications/Frame Control.app: rejected
|
||||
```
|
||||
|
||||
Opening this unquarantined copy needed no override. This does not establish
|
||||
the warning or opening steps for a quarantined browser download. The README
|
||||
keeps Apple's Open Anyway guidance, explicitly unverified here. No quarantine
|
||||
attribute was removed and no global security setting was changed. System
|
||||
Events inspection timed out; an AppleEvent quit request returned “User
|
||||
cancelled” (-128). Native console/menu automation remains unavailable.
|
||||
|
||||
## Official native app results
|
||||
|
||||
After the normal launch, a second run disabled telemetry, used a task-local
|
||||
server data directory and enabled a temporary loopback renderer debug port.
|
||||
The app bundle was not patched. The following checks used its real renderer
|
||||
and `window.frameApp` IPC bridge; they are not physical mouse/menu tests.
|
||||
|
||||
| Check | Result and limit |
|
||||
|---|---|
|
||||
| UI | Apps, Games, Tools, Settings, Privacy & updates, Capture and Decky views rendered with the expected headings. The activity log showed the stale SSH identity warning. [Native Apps screenshot](mac-v0439-native-2026-10-26.png) |
|
||||
| Settings | Initial native preferences were `{}`. `mvSeparate="0"` saved and read back, including after a real app restart. The original preferences file was restored byte-for-byte in `finally` (or removed if originally absent) |
|
||||
| Open at Login | Still opt-in in source; no login item was enabled. Actual menu checkbox and OS login behavior remain unverified because native automation was unavailable |
|
||||
| Logs | `~/Library/Logs/Frame Control/server.log` recorded startup with the installed app's `Contents/Resources/python/bin/python3` and `ui/server.py`. Show Server Log menu interaction remains unverified |
|
||||
| Update check | Native check returned current/latest `0.4.39`, status `none`, error `null`. No update installed |
|
||||
| OBS | Native Start on port 15123 returned `starting`; Stop returned `stopping`, then `idle`. This was offline: no OBS receiver or real headset audio/video |
|
||||
| Decky store | Native Load store returned 116 entries; 40 initial rows rendered. Search found ProtonDB Badges 1.3.4 and its compatibility text. Install remained disabled; no plugin/loader installed |
|
||||
| Framey preview | No standalone local Framey preview control found. No VR runtime result claimed |
|
||||
|
||||
Closing the app through `window.close()` stopped its processes and closed
|
||||
the debug port. **The final shutdown log exposed a bug:** stdin EOF and the
|
||||
app's SIGTERM can overlap, raising `KeyboardInterrupt` while cleanup is
|
||||
installing its ignore handler. Cleanup can then stop before releasing staging
|
||||
or SSH resources. [#284](https://github.com/saphid/frame-control/pull/284)
|
||||
changes SIGTERM to request HTTP shutdown without raising into cleanup.
|
||||
|
||||
The regression test injects SIGTERM at that exact cleanup boundary. On this
|
||||
physical Mac with the official app's bundled Python 3.12, it failed before
|
||||
the fix with exit -2 and the observed traceback. After the fix, all **45
|
||||
server integration tests** passed, including owned staging-file cleanup.
|
||||
This validates source from the fix PR, not a fixed published app.
|
||||
|
||||
## Headset relay and small transfer
|
||||
|
||||
`frame.local` resolved but TCP 22 returned **No route to host**. The native
|
||||
app refused a stale SSH identity; no pin was replaced. The trusted lxso2
|
||||
coordinator relay could reach the Frame. Both shared locks were absent.
|
||||
|
||||
The relay test atomically created `/tmp/frame-test.lock` on the coordinator
|
||||
and the headset, each with this job's owner marker. It then:
|
||||
|
||||
- Read battery **99%, Not charging** before and after; storage total
|
||||
**996,984,774,656 bytes**, free **769,077,035,008 bytes**.
|
||||
- Listed **7** Steam screenshot JPEGs matching
|
||||
`userdata/*/760/remote/*/screenshots/*.jpg`; no image was copied. This narrower
|
||||
layout-specific count differs from the earlier broad JPEG search.
|
||||
- Sent a **40-byte** test payload from this Mac through lxso2, created one
|
||||
reserved file in the Frame user's home using exclusive creation, and read
|
||||
it back. SHA-256 matched
|
||||
`457485db13f80d819d5301d337502c3df050c6b3eb6202be7aac85d2af8e0b92`.
|
||||
- Deleted that exact test file and released only the matching owned locks.
|
||||
A separate SSH read verified the file and both locks were absent.
|
||||
|
||||
This proves a bounded relay transfer, not native Mac app file transfer or
|
||||
direct `frame.local` connectivity. No existing headset file, app, service,
|
||||
power setting or SteamVR state was changed. The headset was not wiped or
|
||||
rebooted. Real OBS and wearer/controller interaction remain unverified.
|
||||
|
||||
The DMG was ejected; the native app, debug port and task browser tab were
|
||||
closed; the desk hold was released. The official app remains installed for
|
||||
reproduction. No social post was made; PR #113 was left alone.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 529 KiB |
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"tag": "v0.4.39",
|
||||
"tagCommit": "fd0b0a946f11d92fd1cc480cd9d1a83be795bf71",
|
||||
"releaseUrl": "https://github.com/saphid/frame-control/releases/tag/v0.4.39",
|
||||
"releaseId": 423799494,
|
||||
"releaseWorkflow": {
|
||||
"runId": 40258561639,
|
||||
"conclusion": "success",
|
||||
"jobsPassed": 6
|
||||
},
|
||||
"publishedLatest": true,
|
||||
"assets": [
|
||||
{
|
||||
"name": "Frame-Control-linux-amd64.deb",
|
||||
"size": 131444552,
|
||||
"digest": "sha256:9ca74551fe47ea244f500e1b4119c02307d185d1acea6287a02a5f6c055c674e"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.AppImage",
|
||||
"size": 156170292,
|
||||
"digest": "sha256:66bd8f644f6a0b06f1e46ff635c5b50e53d3f2a9c6736f12b737ee0ad1667335"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.deb",
|
||||
"size": 118529048,
|
||||
"digest": "sha256:f6b476a2f3163028d0862a13d0c8890760bdc8fe52c1006f106d9e540de32a6a"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-x86_64.AppImage",
|
||||
"size": 164587947,
|
||||
"digest": "sha256:240023c89bea049f0864da75f83ec235c138375c4fd1c4612953a4c5863610ca"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.dmg",
|
||||
"size": 162169626,
|
||||
"digest": "sha256:c0243317555975b1a5588640ec6fe71db5014788858ff00dd45914b77c1fe99a"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.zip",
|
||||
"size": 162054585,
|
||||
"digest": "sha256:08191f80d6c78ac8576e2e2fe7937f5c1689ebed780528e63b6fb41b3c90d823"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.dmg",
|
||||
"size": 169230398,
|
||||
"digest": "sha256:f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.zip",
|
||||
"size": 169141014,
|
||||
"digest": "sha256:bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-Setup-x64.exe",
|
||||
"size": 136500288,
|
||||
"digest": "sha256:44a9f4c072b8c10ec81b060484764af10607763c69d55ecdad6bd0b103b7207e"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-win-x64.zip",
|
||||
"size": 183446427,
|
||||
"digest": "sha256:14f3dae327f054431ecb7ed204144dc529e04a03b35b646925bd9812dc54c504"
|
||||
}
|
||||
],
|
||||
"sha256sumsDigest": "sha256:59cff0ab59e2a681656c0b15b3445ca0baa0546d03196f8c4ae58eae8785db25",
|
||||
"updateJsonDigest": "sha256:afef87fa3869be3acf06dfaa4831a97b29ce9c5d275940a2202bc958c62f4d67",
|
||||
"publisherDryRun": {
|
||||
"passed": true,
|
||||
"installerDigests": 10,
|
||||
"digestAndChecksumChecks": 11,
|
||||
"completeNotesCharacters": 3837
|
||||
},
|
||||
"downloadedAppImage": {
|
||||
"name": "Frame-Control-linux-x86_64.AppImage",
|
||||
"sha256": "240023c89bea049f0864da75f83ec235c138375c4fd1c4612953a4c5863610ca",
|
||||
"isolatedXvfbHttpStatus": 200,
|
||||
"serverStopped": true,
|
||||
"serverHeaderPrefix": "FrameControl/1"
|
||||
},
|
||||
"downloadedIntelMacZip": {
|
||||
"name": "Frame-Control-mac-x64.zip",
|
||||
"sha256": "bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7",
|
||||
"machOCpu": "x86_64",
|
||||
"inspected": [
|
||||
"app executable",
|
||||
"bundled Python 3.12",
|
||||
"Electron helper"
|
||||
],
|
||||
"physicalRuntime": "unverified"
|
||||
},
|
||||
"realFrameSmoke": {
|
||||
"sourceTag": "v0.4.39",
|
||||
"started": "2026-10-26T02:20:45Z",
|
||||
"buildId": "20261022.6153909",
|
||||
"preflight": {
|
||||
"activity": 3,
|
||||
"proximity": [
|
||||
6.3125
|
||||
],
|
||||
"battery": [
|
||||
100
|
||||
]
|
||||
},
|
||||
"passed": 13,
|
||||
"failed": 0,
|
||||
"steps": [
|
||||
{
|
||||
"name": "cleanup: leftovers from earlier runs",
|
||||
"passed": true,
|
||||
"seconds": 8.2
|
||||
},
|
||||
{
|
||||
"name": "devkit service: properties.json",
|
||||
"passed": true,
|
||||
"seconds": 0.0
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"passed": true,
|
||||
"seconds": 0.6
|
||||
},
|
||||
{
|
||||
"name": "arm64: install",
|
||||
"passed": true,
|
||||
"seconds": 8.0
|
||||
},
|
||||
{
|
||||
"name": "arm64: launch",
|
||||
"passed": true,
|
||||
"seconds": 3.5
|
||||
},
|
||||
{
|
||||
"name": "arm64: remove",
|
||||
"passed": true,
|
||||
"seconds": 6.8
|
||||
},
|
||||
{
|
||||
"name": "x86_64: install",
|
||||
"passed": true,
|
||||
"seconds": 7.9
|
||||
},
|
||||
{
|
||||
"name": "x86_64: launch",
|
||||
"passed": true,
|
||||
"seconds": 3.5
|
||||
},
|
||||
{
|
||||
"name": "x86_64: remove",
|
||||
"passed": true,
|
||||
"seconds": 6.6
|
||||
},
|
||||
{
|
||||
"name": "exe: install",
|
||||
"passed": true,
|
||||
"seconds": 8.0
|
||||
},
|
||||
{
|
||||
"name": "exe: launch",
|
||||
"passed": true,
|
||||
"seconds": 3.4
|
||||
},
|
||||
{
|
||||
"name": "exe: remove",
|
||||
"passed": true,
|
||||
"seconds": 6.5
|
||||
},
|
||||
{
|
||||
"name": "cleanup: everything this run installed",
|
||||
"passed": true,
|
||||
"seconds": 8.1
|
||||
}
|
||||
],
|
||||
"ownedLockReleased": true
|
||||
},
|
||||
"postReleasePr282": {
|
||||
"headCommit": "ce6e8f807f44b614e5bbd5c5a5a6a2093236d917",
|
||||
"mergedCommit": "67b9151513bc9fb550f5dfb2d2eec925df390ab3",
|
||||
"exactHeadCiPassed": 11,
|
||||
"unwornVrExercise": {
|
||||
"passed": true,
|
||||
"beforeActivity": 3,
|
||||
"beforeBattery": [
|
||||
99
|
||||
],
|
||||
"afterBattery": [
|
||||
99
|
||||
],
|
||||
"overlayVisible": true,
|
||||
"compositorChanged": true,
|
||||
"stereoCaptureSha256": "c7edc11877aca35dd3aebe480feee93253d04825fe9bd75d02136bb7e3510b97",
|
||||
"mirrorCaptureSha256": "8e5932fb0046b218a1781a6b06cfaa6e3c13dc7874799e287066ed6e43640cd4",
|
||||
"syntheticPointerInputPassed": true,
|
||||
"liveSettingReopened": true,
|
||||
"nativeScrollMoved": false,
|
||||
"powerRecoveryRemoved": true,
|
||||
"ownedLockReleased": true,
|
||||
"cleanupErrors": [],
|
||||
"physicalControllerVerified": false,
|
||||
"wornVisualVerified": false,
|
||||
"officialPluginsInVrVerified": false
|
||||
},
|
||||
"realFrameSmoke": {
|
||||
"passed": 13,
|
||||
"failed": 0,
|
||||
"beforeActivity": 3,
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"buildId": "20261022.6153909",
|
||||
"ownedLockReleased": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# v0.4.39 release gate, 26 October 2026
|
||||
|
||||
The [published release](https://github.com/saphid/frame-control/releases/tag/v0.4.39) is GitHub's latest release. Its tag resolves to main commit `fd0b0a946f11d92fd1cc480cd9d1a83be795bf71`, after PRs #280 and #281 merged. The [tagged workflow](https://github.com/saphid/frame-control/actions/runs/40258561639) passed all six jobs: draft, Apple Silicon Mac, Intel Mac, Windows, Linux and checksums. The release has ten installers, `SHA256SUMS` and `update.json`. [Sanitized numeric results](v0439-release-2026-10-26.json) contain the exact asset sizes and GitHub digests.
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| `scripts/publish-release.sh --dry-run v0.4.39` from the exact tag | Passed: all ten installer digests, regenerated `SHA256SUMS` digest, complete update notes and ten-asset `update.json` |
|
||||
| Downloaded Linux x86_64 AppImage | SHA-256 matched `240023c89bea049f0864da75f83ec235c138375c4fd1c4612953a4c5863610ca`; the tagged isolated Xvfb check received HTTP 200 with a `FrameControl/1` server header prefix and confirmed the server stopped |
|
||||
| Downloaded Intel Mac ZIP | SHA-256 matched `bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7`; ZIP inspection found x86_64 Mach-O app executable, bundled Python 3.12 and Electron helper |
|
||||
| Exact tagged `scripts/frame-smoke.sh` on the real Frame | **13/13** steps passed on SteamOS build `20261022.6153909`; locked preflight found SteamVR activity 3, proximity raw 6.3125 and battery 100%. Reserved test titles were removed and the script released its owner-token lock. |
|
||||
| Published release readback | GitHub reports `draft=false`, `prerelease=false`, latest tag `v0.4.39`; downloaded `update.json` lists ten unique assets, the stable release URL and complete safety notes |
|
||||
|
||||
The first local AppImage launch attempt stopped before app startup because the execution sandbox denied Electron's sandbox host (`Operation not permitted`). Repeating the same tagged check with host permissions passed. The bundled release CI also ran its own AppImage launch check.
|
||||
|
||||
The unworn VR runner's candidate evidence is [documented separately](framey-vr-standby-gate-2026-10-26.md). Its successful stereo and `/dev/video99` panel captures and synthetic OpenVR input are not a worn or physical-controller test. Two other attempts found an intermittently hidden overlay and failed safely. No person wore the headset for this release check. Physical controller input, optical comfort, VR keyboard, native scroll and official-plugin wearer-visible behavior remain unverified.
|
||||
|
||||
The physical Intel MacBook Air ran an earlier candidate, but was unreachable for this exact tagged installer. The architecture and hash checks above do not prove physical Mac runtime. Physical Windows PC and macOS OBS/headset capture also remain unverified. No private room pixels or raw Steam log lines are included here.
|
||||
|
||||
## Post-release Framey library change (#282)
|
||||
|
||||
After v0.4.39 publication, [PR #282](https://github.com/saphid/frame-control/pull/282) merged at `67b9151513bc9fb550f5dfb2d2eec925df390ab3`. All eleven exact-head checks passed on `ce6e8f807f44b614e5bbd5c5a5a6a2093236d917`. This commit is **not** in v0.4.39. The [library-context evidence](framey-library-context-2026-10-26.md) covers its three newly partial official plugins and separate backend limits.
|
||||
|
||||
The exact #282 source ran `scripts/framey-vr-check.py --exercise` under the shared lock from standby (activity 3, battery 99%). The sample panel became visible; stereo compositor pixels changed and `/dev/video99` mirror bytes were captured. Trusted synthetic OpenVR pointer input reached the repository sample's live Decky setting and the saved value survived reopening. Native scroll did not move. The runner removed its recovery timer, restored original power values, reported no cleanup errors and released its owner-token lock. Private images and room pixels remain outside this repository. The source came from an exact commit archive, so the runner's `sourceCommit` field was empty; the archive object ID was verified before extraction.
|
||||
|
||||
Once the Frame returned to standby, the exact #282 source passed `scripts/frame-smoke.sh` **13/13** on the real SteamOS build `20261022.6153909`, with battery 99% at preflight. Test titles were removed and the lock was released. These checks cover the Framey sample and generic Frame smoke, not Steamback, PlayTime or Deck Settings in VR. No wearer or physical controller was involved; those limits remain open.
|
||||
@@ -0,0 +1,9 @@
|
||||
# v0.4.40 candidate: locked real Frame gates, 26 October 2026
|
||||
|
||||
Source commit `9aa26a8322f74144c13528b6fb41edc451ec7eea` contains merged PRs #286–#288 and the candidate 0.4.40 version bump. The Frame ran SteamOS 0.5.6 build `20261022.6153909`. These are source checks before tagged installers exist.
|
||||
|
||||
The unworn `scripts/framey-vr-check.py --exercise` acquired its random-owner `/tmp/frame-test.lock` from lxso2. Its locked read-only preflight found SteamVR activity 3 (standby), proximity 6.25, battery 100%, and original power values `false` and `5`. The repository sample panel became visible, the stereo compositor image changed, and `/dev/video99` supplied a mirror frame. Synthetic OpenVR pointer input produced a trusted click, changed the sample's live Decky setting, and the saved value survived a fresh iframe reopen. The quick action and native slider/text control paths completed; native scrolling did not move. The run exited 0, removed its temporary Decky/panel services and recovery timer, restored and read back the original power values, reported no cleanup errors, found battery 99%, and released its own lock. Private room and UI captures remain outside the repository.
|
||||
|
||||
After the Frame returned to standby, `scripts/frame-smoke.sh` acquired a separate random-owner lock. Its preflight found activity 3, proximity 6.25, and battery 100%. All **13/13** steps passed, including ARM64, x86-64 and Windows-executable install/launch/remove and final cleanup. The command exited 0, which includes successful release of its owned lock. Its private detailed result remains outside the repository.
|
||||
|
||||
These checks exercise Frame Control's repository sample and generic title paths. They do not run the official Deck Shelves backend or prove its panel or Steam Home shelves in VR. The run did not include a wearer, physical controller, VR keyboard, native scrolling, or optical comfort check. A mirror capture and changed compositor pixels do not by themselves prove wearer-visible passthrough for this run. The [compatibility table](../framey-compatibility.md) retains those limits.
|
||||
@@ -0,0 +1,45 @@
|
||||
# v0.4.40 tag and post-tag Frame checks, 26 October 2026
|
||||
|
||||
The v0.4.40 annotated tag dereferences to main merge commit `a4d8073e3199671873d304b80a6acc8cdbd33866` from PR #291. The tag contains the 0.4.40 package and lockfile version. PR #291 passed all reported exact-head checks before merge, including Linux, Windows, macOS, iOS, fake Frame e2e and installer preview builds.
|
||||
|
||||
## Real Frame, tagged source
|
||||
|
||||
From an exact `v0.4.40` checkout, `scripts/frame-smoke.sh` acquired its unique `/tmp/frame-test.lock` owner before any headset read. Locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On SteamOS 0.5.6 build `20261022.6153909`, all **13/13** reserved title steps passed: ARM64, x86_64 and Windows executable install, launch and remove, plus final cleanup. The command exited 0, which includes successful removal of its own lock. Its detailed result remains private under `tests/smoke/results/2026-10-26T035641Z-20261022.6153909.json` in the test checkout.
|
||||
|
||||
## Real Frame, next main source
|
||||
|
||||
After #289 and #290 merged, `scripts/framey-vr-check.py --exercise` ran from main commit `56bde24be1d38ac060e8ff287455182765e48564` under its own unique shared lock. The locked preflight found activity 3, proximity 6.25, battery 99%, dashboard already visible, and original SteamVR power values `false` and `5`.
|
||||
|
||||
The repository sample panel appeared in the stereo compositor; before/after compositor hashes differed and `/dev/video99` returned a mirror frame. Synthetic OpenVR pointer events were trusted by the panel. A real Decky 3.3.0 backend saved the sample setting from off to on; an independent settings-file read returned `{"enabled": true}`, and a fresh iframe reopen showed on. The quick action, slider value 50 and text entry path completed. Native scroll was attempted but its measured offset stayed 0. This exercise did not run an official store plugin.
|
||||
|
||||
The command exited 0. It removed test services and temporary profile/plugin paths, restored and read back the original power values, removed its recovery timer, reported no cleanup errors, found battery still 99%, and released its own lock. Its immediate final SteamVR activity was 0 after the temporary wake; the saved standby power behavior was restored. Private room and UI captures remain outside Git.
|
||||
|
||||
No person wore the headset. A stereo screenshot, changed pixels, trusted synthetic input and mirror frame do **not** prove wearer-visible passthrough, physical controller usability, VR keyboard behavior, native scrolling, optical legibility or comfort. The [worn checklist](../framey-vr-check.md#three-minute-worn-checklist) and hourly check remain necessary. Official TabMaster, Deck Shelves, ProtonDB Badges and other store panels were not exercised in this run.
|
||||
|
||||
## Downloaded tagged installers
|
||||
|
||||
The draft release's GitHub digest for `Frame-Control-linux-x86_64.AppImage` is `sha256:cded3c660e41eb04e835e28057da81345075c4dbe9a009f397aea1da59655b3d`. A fresh download matched it. After setting the download's executable bit, `scripts/check-appimage-launch.py` launched that exact file under isolated Xvfb: its bundled server answered HTTP 200 with `FrameControl/1`, then stopped on shutdown.
|
||||
|
||||
The draft release's GitHub digest for `Frame-Control-mac-arm64.zip` is `sha256:98f6d118d0db0cba10c3e4bfcd618953f10ba1c35246ce26041ecd39a1d3aff0`. Fresh local and physical-Mac copies matched. On a reachable physical arm64 Mac running macOS 26.5.2, a private temporary extraction reported bundle version 0.4.40; `lipo -verify_arch arm64` passed for the app executable, bundled Python, adb and Mac viewer. Its bundled Python imported `http.server`, `ssl` and `sqlite3` and reported OpenSSL 3.5.8. The temporary directory was removed and checked absent.
|
||||
|
||||
That Mac already had a person's Frame Control app running, so no tagged GUI was launched, replaced or quit. The physical Intel Air was unreachable from this worker; the tagged Intel artifact's build check and bundle-architecture CI are separate from a physical Intel runtime test. No macOS Gatekeeper browser-download path was tested.
|
||||
|
||||
## Tagged workflow and checksum gate
|
||||
|
||||
The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40266393941) ran at `a4d8073e3199671873d304b80a6acc8cdbd33866` and passed all six jobs: draft, Apple Silicon Mac, Intel Mac, Windows, Linux and checksums. Its Linux job launched the built AppImage under Xvfb; its Mac jobs checked bundled binary architectures and Python imports; its Windows job installed the Setup executable, compared installed resources and ran the bundled Python PC-library tests.
|
||||
|
||||
The draft has exactly ten installers and `SHA256SUMS`, each with a GitHub SHA-256 digest. Regenerating the checksum file from the ten asset digests produced bytes identical to the downloaded `SHA256SUMS`; its own digest matches `7457da8f292bc1b2b74cb989b4d179858055380721b26e23ccd595994701d1f8`.
|
||||
|
||||
A fresh `Frame-Control-mac-x64.zip` download matched GitHub digest `52f257fc0dfc1b41e89ab0c7a551d667af4cc46f800e4f57e2689d47c856977c`. Independent ZIP inspection found x86_64 Mach-O app, bundled Python 3.12 and Mac viewer executables. This is packaging evidence, not a physical Intel Mac launch.
|
||||
|
||||
## Publication and updater readback
|
||||
|
||||
`scripts/publish-release.sh --dry-run v0.4.40` passed the exact tag, ten installer digests and regenerated `SHA256SUMS` gate. The release script then published [v0.4.40](https://github.com/saphid/frame-control/releases/tag/v0.4.40) at 2026-10-26 04:14:35 UTC. A direct GitHub latest-release read returned `draft=false`, `tag_name=v0.4.40`, ten installers, `SHA256SUMS` and `update.json`. A fresh download of the public `update.json` parsed as version 0.4.40, its stable tag page, ten assets with SHA-256 digests and the unworn-test limits in its notes. This release contains the tagged `a4d8073` commit, not later main changes.
|
||||
|
||||
## Post-tag #292 panel regression
|
||||
|
||||
PR #292 passed all 11 hosted checks on exact head `e0115f356d3b33369a61cfba034b080a7764559f` and merged as `784118b71e3463156574988121f47354d87358c7`. From that exact main commit, `python3 scripts/framey-vr-check.py --exercise --output /tmp/framey-v0440-post292` exited 0 on the real Frame. Its unique `/tmp/frame-test.lock` owner was acquired before any read and released on exit. Preflight: battery 99%, proximity 6.25, SteamVR activity 3 standby, dashboard already visible, saved power `false/5`.
|
||||
|
||||
The temporary repository sample appeared in stereo compositor and mirror captures; compositor frame hashes changed. Trusted synthetic OpenVR pointer input saved a Decky setting to `{"enabled": true}`, independently read from the settings file and retained on a fresh iframe reopen. A quick action, slider value 50 and CDP text entry after OpenVR focus completed. Native scroll remained at offset 0. The test reported `passed=true`, no cleanup errors, removed its recovery timer, restored/read back power `false/5`, battery 99%, and released only its own lock. The private result and room captures remain under `/tmp/framey-v0440-post292/` outside Git.
|
||||
|
||||
This exercises one quick-action activation. It does not prove the new rapid duplicate-tap guard with physical controllers. No person wore the headset; no official Decky plugin panel was tested. The hourly worn-headset schedule remains enabled for wearer-visible passthrough, controller, scrolling, keyboard and comfort checks.
|
||||
@@ -0,0 +1,7 @@
|
||||
# v0.4.41 candidate: real Frame title smoke, 26 October 2026
|
||||
|
||||
Source commit `c87e5c6` bumps the app and lockfile to 0.4.41 on top of main after PRs #289, #290, #292–#295. It includes the exact official ARM64 audio evidence from #293. The release tag and installers do not exist at this candidate stage.
|
||||
|
||||
`scripts/frame-smoke.sh` acquired its unique owner in `/tmp/frame-test.lock` before any headset read. Locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, all **13/13** steps passed: existing reserved-title cleanup, devkit identity/status, install/launch/remove for ARM64, x86_64 and Windows test executables, and final cleanup. The command exited 0, which includes successfully releasing only its own lock. Its detailed result remains private under `tests/smoke/results/2026-10-26T043229Z-20261022.6153909.json` in the candidate checkout.
|
||||
|
||||
No person wore the headset. This checks title management and process cleanup, not wearer-visible rendering, physical controllers, official plugin VR panels, audio output or installed desktop packages. After PR #294, a separate exact-main unworn Framey sample exercise passed live setting persistence, synthetic OpenVR input, stereo compositor change and cleanup; it did not prove rapid physical controller taps or native scroll. The [v0.4.40 publication evidence](v0440-release-2026-10-26.md) records the previous tagged installer gates. New v0.4.41 tagged installers and checksums must be verified before publication.
|
||||
@@ -0,0 +1,23 @@
|
||||
# v0.4.41 tagged release, 26 October 2026
|
||||
|
||||
The annotated `v0.4.41` tag dereferences to main merge commit `b5d50a8d896f790a94baf4e937db914eab475ed9` from PR #297. The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40271174791) passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs. The platform jobs launched the built Linux AppImage, checked the Windows installation and bundled PC Python libraries, and checked Mac bundle architectures. These CI results do not establish a physical Windows or Mac launch.
|
||||
|
||||
## Real Frame, exact tag
|
||||
|
||||
From an exact tag checkout, `scripts/frame-smoke.sh` acquired its unique owner in `/tmp/frame-test.lock` before reading the headset. Preflight found SteamVR activity 3 (standby) and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, all **13/13 reserved title steps passed**; the command exited 0, including test title and process cleanup and release of its owned lock. ARM64 and Proton test executables launched. The x86_64 launch step recorded that optional Steam Linux Runtime 4 was **not installed**, so that executable did not actually launch. The private detailed result is `tests/smoke/results/2026-10-26T045140Z-20261022.6153909.json` in the exact-tag checkout.
|
||||
|
||||
No person wore the headset. This title smoke does not verify active passthrough pixels, wearer-visible panels, physical controller input, official Decky plugin panels, audio output, comfort or VR keyboard use. The separate [unworn Framey sample exercise](framey-scroll-probe-2026-10-26.md) established live sample setting persistence, stereo compositor changes and trusted synthetic OpenVR input; it did not establish a worn result. The hourly worn check remains enabled.
|
||||
|
||||
## Downloaded installer and checksum checks
|
||||
|
||||
The draft held ten installers plus `SHA256SUMS`, each with a GitHub SHA-256 digest. `scripts/publish-release.sh --dry-run v0.4.41` checked the exact tag and all ten installer digests, regenerated `SHA256SUMS` from those digests and matched its own GitHub digest. A fresh downloaded checksum list independently matched `e7555956aeb9ec3a464081aaafe1ed6762f65a17c04edaa6ea45286f10927109`.
|
||||
|
||||
A fresh Linux x86_64 AppImage download matched `5d1f50dc8527faf299f2058c7b5ed19057ec7c81c41449f6ccd44d68e6b4d212`. `scripts/check-appimage-launch.py` launched that exact file under isolated Xvfb: the bundled server answered HTTP 200 with `FrameControl/1`, then stopped. The first local attempt failed at Chromium sandbox startup inside the command sandbox; rerunning the same downloaded file with normal host permissions passed. The release workflow's independent Linux launch also passed.
|
||||
|
||||
Fresh Mac ZIP downloads matched GitHub digests: arm64 `84615ec8a0c62481a9f33bae1815d55ec902db9aa9d4bd767fcf9380c3d44d7a`, x64 `2800b749cda98d15207950eca4a6bd205d35f30551f16efd7720b41347edc7ca`. Both contain bundle version 0.4.41; the app executable Mach-O CPU types are arm64 and x86_64 respectively. The physical Mac was unreachable over SSH, so neither tagged ZIP was launched there. The physical Intel Air was also not tested with this tag.
|
||||
|
||||
## Publication and updater readback
|
||||
|
||||
The final dry run passed with the release notes and hardware limits in the draft. `scripts/publish-release.sh v0.4.41` published [v0.4.41](https://github.com/saphid/frame-control/releases/tag/v0.4.41) at 2026-10-26 05:01:47 UTC. GitHub's latest-release API read back `draft=false`, tag `v0.4.41`, ten installers, `SHA256SUMS` and `update.json`. A fresh public latest-download of `update.json` parsed as version 0.4.41 with the stable release page, ten SHA-256 digests and the unworn-test limits in its notes. The release contains the tagged `b5d50a8` commit; no post-tag changes are implied.
|
||||
|
||||
The exact-version official Decky catalog in this release is **3 works / 48 partial / 54 broken / 11 N/A**. Volume Mixer and Mixer are works only for the tested per-app silent-stream volume scope on real ARM64 Decky; [audio evidence](framey-audio-arm64-2026-10-26.md) describes the independent Pulse readbacks. Volume Boost remains partial. Audible playback, Bluetooth, other mixer controls and official-plugin worn VR remain unverified.
|
||||
@@ -0,0 +1,9 @@
|
||||
# v0.4.42 candidate gate, 26 October 2026
|
||||
|
||||
This branch bumps the app and lockfile to 0.4.42. It includes the [v0.4.41 release evidence](v0441-release-2026-10-26.md), the Framey panel lifecycle change in PR #299, the truthful optional-runtime smoke result in PR #300, the physical Intel Mac acceptance and host-test fixes in PR #303 (`fe1da43171883dc31161ff607c6b5a6ddf75f6ef`), and the receipt-bound API guard in PR #301 (`ad57662d40d3bbf6c850dd80ddc275280d389c86`). All 11 hosted checks passed on PR #301's exact head before its normal merge. The combined release candidate's own headset gates passed; exact-head CI and installer previews remain pending.
|
||||
|
||||
The exact v0.4.42 candidate at `27af46dd20d7af30dde4c3f954d7b57b94843594` ran `scripts/frame-smoke.sh` under its unique shared lock at 05:40 UTC. Locked preflight found standby activity 3, proximity 6.25 and battery 99%. It recorded **12 passed / 1 skipped / 0 failed** on Frame OS 0.5.6 build `20261022.6153909`; ARM64 and Proton test executables launched. The x86_64 launch was skipped because Steam Linux Runtime 4 was not installed. Cleanup passed and the command exited 0. The private detailed result is `tests/smoke/results/2026-10-26T054014Z-20261022.6153909.json` in the candidate checkout. The prior PR #300 result is documented [separately](frame-smoke-runtime-skip-2026-10-26.md).
|
||||
|
||||
The exact candidate then ran `scripts/framey-vr-check.py --exercise` under a fresh owned lock at 05:41 UTC. Preflight again found activity 3 and battery 99%. The real Decky sample setting saved `enabled=true` and survived a fresh iframe; the quick action read the same value. A trusted synthetic OpenVR pointer reached the panel; the sample slider reached 50 and CDP text entry after OpenVR focus reached `framey-ci`. Stereo compositor hashes changed from `38265343e13f5915869c9622ab1ceda7c31ab2e9b050f44564cfb9b6dc9f6e6d` to `64cc4c6f865bb8b2f756632c922d17dee12af40d480171586f2e9a51797b83e6`; the mirror capture hash was `165289dca9ad2bd895eb544f5be5d5fa2db7d2f635a927ac68a0c74267f69ee2`. The exercise exited 0 with `passed=true`, battery 99% afterward, original power values restored (`false` / `5.000000`), recovery timer removed, no cleanup errors and its own lock released. Its private full result is `/tmp/framey-v0442-exercise/result.json` on the test host. Synthetic scroll events left `scrollTop=0` and produced no wheel events. No person wore the Frame; physical controller interaction, official plugin VR panels, optical readability, VR keyboard and comfort remain unverified. The earlier [receipt-guard exercise](framey-receipt-live-2026-10-26.md) is separate evidence.
|
||||
|
||||
The release tag and published installers do not exist at this candidate stage. Publication remains gated on this combined head's exact-head CI, then tagged installer checksums, package checks and public updater readback. The 116 exact-version Decky catalog remains **3 works / 48 partial / 54 broken / 11 N/A**; the two additional works claims are restricted to tested per-app silent-stream volume control. The official v0.4.39 Intel installer ran on a physical MacBook Air, but no tagged v0.4.42 Mac launch or real OBS audio/video check is claimed.
|
||||
@@ -64,9 +64,11 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `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 |
|
||||
| `SteamUIStore` / `DFL.Router.RunningApps`, `MainRunningApp` | Bounded detached Steam CEF reads while the panel is open; refresh about every two seconds; no MobX observation. Failed reads clear stale context. | Locked real Frame empty-list reads; nonempty local Chromium fixtures. Real running-game selection unverified. |
|
||||
| `GameSessions.RegisterForAppLifetimeNotifications` | Actual Steam callbacks, bounded queue and disposable `.unregister()` handle; no events inferred from snapshot changes; lease cleanup after lost panels | Real Frame registration/poll/close; launch/exit, retry, expiry and duplicate tests use explicit fixtures. Real game transitions unverified. |
|
||||
| Other `SteamClient` subscriptions | Explicit inert registration methods return disposable handles; no fabricated power/input/game-action events. Mutating Steam/power methods are unavailable. | Host contract tests; safe fallbacks only |
|
||||
| `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 |
|
||||
| Optional `settings`, `frameyQuickActions` | Separate settings tab when content also exists; settings-only plugins have no empty Panel tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e; real ARM64 Notebook save/reopen across backend restart and Simple Timer settings/recents persistence via local Chromium. No worn-VR claim. |
|
||||
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
|
||||
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
|
||||
|
||||
@@ -4,7 +4,7 @@ Snapshot: 2026-10-25T23:19:48.854504+00:00; 116 current visible official plugins
|
||||
|
||||
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
|
||||
|
||||
**Works: 1 · Partial: 48 · Broken: 56 · N/A: 11.**
|
||||
**Works: 3 · Partial: 49 · Broken: 53 · N/A: 11.**
|
||||
|
||||
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
|
||||
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
|
||||
@@ -27,7 +27,7 @@ Open the browser audit URL in local Chromium, run `runAudit()` and inspect/downl
|
||||
|
||||
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
|
||||
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. These three stay partial: real ARM64 Frame audio dependencies/session mapping remain unverified because the shared headset lock was occupied. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. Other official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, install folders and system information, plus IsThereAnyDeal's country dropdown in local Chromium. The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md).
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. Volume Boost stays partial: default-sink reads matched, but real sink writes were denied. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. Junk-Store loading/error views render, but its real backend cannot execute the extracted startup script, so it stays broken. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. TabMaster/Junk-Store ran in actual isolated amd64 Decky; no ARM64 or worn-VR result is claimed. Beyond the documented checks, official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, install folders and system information, plus IsThereAnyDeal's country dropdown in local Chromium. The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md).
|
||||
|
||||
## Every current plugin
|
||||
|
||||
@@ -40,11 +40,11 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 10: vibrantDeck 2.0.1 | legacy / legacy | N/A | Not supported on Frame: Changes X11 gamescope color properties, not the Frame SteamVR compositor. | static archive, local browser, fake-Frame |
|
||||
| 11: Fantastic 0.5.1 | legacy / legacy | N/A | Not supported on Frame: Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. | static archive, local browser, fake-Frame |
|
||||
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Legacy media controls initialize; player discovery and playback are unverified. | static archive, local browser, fake-Frame |
|
||||
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings render locally with inert system-key subscriptions and empty detached Steam context. Live-game discovery, process pause/resume and suspend integration are unsupported/unverified; no backend actions ran. | static archive, local browser, fake-Frame |
|
||||
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied. | static archive, local browser, fake-Frame |
|
||||
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: Cannot read properties of null (reading 'm_GamepadUIAudioStore'). | static archive, local browser, fake-Frame |
|
||||
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel. | static archive, local browser, fake-Frame |
|
||||
| 18: Bluetooth 3.1.1 | 1 / 2 | partial | Controls render with fixture replies; actual Bluetooth discovery, labels and connections remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 19: Notebook 0.1.5 | legacy / legacy | works | Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Per-game auto-selection and background artwork unavailable; worn-VR input unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 19: Notebook 0.1.5 | legacy / legacy | works | Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Running-game context is now supplied; per-game selection was attempted with a synthetic game and backend calls denied. Real per-game selection, background artwork and worn-VR input remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 21: PowerTools 2.0.3 | legacy / legacy | N/A | Not supported on Frame: Targets AMD Ryzen power controls; Frame uses an ARM64 SoC. | static archive, local browser, fake-Frame |
|
||||
| 22: Memory Deck 0.1.8 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: memory-deck/bin/libscanmem.so.1.0.0. | static archive, local browser, fake-Frame |
|
||||
| 23: Animation Changer 1.3.2 | legacy / legacy | partial | Initializer succeeds; forced backend failure prevents loading animations. Boot-video changes untested. | static archive, local browser, fake-Frame |
|
||||
@@ -55,9 +55,9 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 30: TunnelDeck 1.0.4 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 35: MoonDeck 1.13.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: moondeck/python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so. Frontend initializes; native CPython wheels and executables in the published archive are x86_64. | static archive, local browser, fake-Frame |
|
||||
| 36: SteamGridDB 1.8.1 | 1 / 2 | broken | Frontend audit failed: DFL.createModuleMapping is not a function. | static archive, local browser, fake-Frame |
|
||||
| 37: Discord Status 1.5.0 | 1 / 2 | broken | Detached app overviews are now available, but RunningApps/lifetime observations and Steam artwork helpers still lack a live mapping. Discord activity integration remains unavailable/unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | partial | Local Chromium discovers and changes a private silent stream through the actual Decky 3.3.0 Linux backend, with independent Pulse readback. Frame ARM64 libraries/audio session and Bluetooth playback controls remain unverified. | static archive, local browser, fake-Frame, isolated Linux backend |
|
||||
| 36: SteamGridDB 1.8.1 | 1 / 2 | broken | Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel. | static archive, local browser, fake-Frame |
|
||||
| 37: Discord Status 1.5.0 | 1 / 2 | partial | Disconnected panel renders; running-activity construction accepts a synthetic game in local Chromium. Exact official is_connected returned false in real Decky 3.3.0 ARM64. Empty live running-list reads and lifetime registration/cleanup passed. Real game transitions, Discord shortcut detection/connection/activity, local custom artwork and worn VR remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | works | Per-app stream discovery and slider/keyboard volume changes pass in local Chromium through real Decky 3.3.0 ARM64 on the Frame audio session, with independent Pulse readback of a silent test stream. Bluetooth playback, audible output and worn-VR controls unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||
| 40: Controller Tools 2.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: ControllerTools/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 43: Tailscale Control 0.1.3 | legacy / legacy | partial | Settings render locally after the SuspensefulImage fallback. Tailscale daemon/state, connection and exit-node changes unverified; bundled localhost QR asset remains unavailable. No network settings were changed. | static archive, local browser, fake-Frame |
|
||||
| 44: DeckMTP 1.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: DeckMTP/bin/umtprd. | static archive, local browser, fake-Frame |
|
||||
@@ -68,15 +68,15 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'type'). | static archive, local browser, fake-Frame |
|
||||
| 53: Shotty 0.1.0 | legacy / legacy | broken | Library snapshot fixes the frontend allApps crash. Actual published Decky 3.3.0 amd64 backend fails importing click.get_app_dir (ModuleNotFoundError); the archive does not bundle click. Screenshot aggregation/rescue was not run. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 56: MangoPeel 0.0.7 | 1 / 2 | partial | Initializer succeeds; gamescope/MangoHud actions and backend are unverified. | static archive, local browser, fake-Frame |
|
||||
| 58: Volume Boost 0.0.7 | legacy / legacy | partial | Local Chromium sync/separate sliders, numeric volume entry and cache reopen pass through the actual Decky 3.3.0 backend on a private virtual stereo sink. Frame pactl/default sink, boost above 100% and physical audio remain unverified. | static archive, local browser, fake-Frame, isolated Linux backend |
|
||||
| 58: Volume Boost 0.0.7 | legacy / legacy | partial | Real Decky 3.3.0 ARM64 default-sink reads match independent Frame pactl channel volumes; controls render locally. Sliders/numeric writes passed on an isolated amd64 virtual sink only. Real default-sink writes, boost above 100%, audible output and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||
| 59: Web Browser 1.4.3 | legacy / legacy | broken | Frontend audit failed: deckyFrontendLib.getReactRoot is not a function. | static archive, local browser, fake-Frame |
|
||||
| 60: DeckyFileServer 1.2.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend. | static archive, local browser, fake-Frame |
|
||||
| 61: TabMaster 2.15.1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio'). | static archive, local browser, fake-Frame |
|
||||
| 61: TabMaster 2.15.1 | 1 / 2 | partial | Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified. | static archive, local browser, fake-Frame |
|
||||
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
|
||||
| 63: Free Loader 1.6.0 | 1 / 2 | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
|
||||
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. | static archive, local browser, fake-Frame |
|
||||
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
|
||||
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. ARM64 backend, live MobX/running-game observations and automatic recording remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 67: Battery Tracker 0.2.0 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 68: Wine Cellar 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-wine-cellar/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 70: MicroSDeck 0.11.2 | legacy / 2 | broken | Plugin contains a non-ARM64 Linux binary: MicroSDeck/bin/backend. | static archive, local browser, fake-Frame |
|
||||
@@ -91,7 +91,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 80: MagicBlack 2.0.2 | 1 / 2 | N/A | Not supported on Frame: Targets a global Steam Deck screen-black overlay; the isolated panel cannot black out the Frame compositor. | static archive, local browser, fake-Frame |
|
||||
| 81: IsThereAnyDeal for Deck 1.1.1 | legacy / legacy | partial | Country settings render and select the real Frame Steam country through the read-only bridge. Settings writes, deal lookups and Steam page integration remain unverified; plugin backend calls were denied. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 82: MagicPods 2.0.21 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: MagicPodsDecky/bin/magicpodscore. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 84: Junk-Store 1.1.13 | legacy / legacy | broken | Route registration succeeds; rendering fails on a missing Steam ContainerBackground component. | static archive, local browser, fake-Frame |
|
||||
| 84: Junk-Store 1.1.13 | legacy / legacy | broken | Local CSS lookup fixes the loading view crash. Actual Decky 3.3.0 amd64 startup/static InitActions return a script execution/JSON error; the published scripts are not executable after extraction. Store install/login/game launch and ARM64 remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 85: Decky-Undervolt 1.0.8 | 1 / 2 | N/A | Not supported on Frame: RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. | static archive, local browser, fake-Frame |
|
||||
| 87: ScreenSaver 1.1.2 | legacy / legacy | partial | Initializer succeeds; global screen overlay and idle/suspend integration have no verified VR target. | static archive, local browser, fake-Frame |
|
||||
| 88: steamdeck-input-disabler 1.0.2 | legacy / legacy | N/A | Not supported on Frame: Targets the built-in Steam Deck USB gamepad; Frame has different controllers. | static archive, local browser, fake-Frame |
|
||||
@@ -108,7 +108,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 99: Decky Zerotier 0.3.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 100: Virtual Surround Sound 0.0.5 | 1 / 2 | partial | Initializer succeeds; avatar proxy is unavailable and live audio-routing backend unverified. | static archive, local browser, fake-Frame |
|
||||
| 101: XIVOmega 1.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 102: DeckSP 1.1.3 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio'). | static archive, local browser, fake-Frame |
|
||||
| 102: DeckSP 1.1.3 | 1 / 2 | broken | Optional UI-audio lookup succeeds, but the published frontend raises "Cannot access PluginManager before initialization" when Steam MobX reaction lookup is unavailable. JamesDSP installation/audio routing and ARM64 remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 103: Decky Ludusavi 1.2.2 | 1 / 2 | partial | Initializer succeeds; Flatpak Ludusavi discovery and backup/restore unverified. | static archive, local browser, fake-Frame |
|
||||
| 104: Game Sync 1.0.4 | 1 / 2 | partial | Backend get_config is intentionally rejected by the audit before initialization completes; actual backend compatibility is unknown. | static archive, local browser, fake-Frame |
|
||||
| 105: WiFi Locker 1.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
@@ -140,7 +140,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 132: SuggestMe 1.5.3 | 1 / 2 | partial | Initializer succeeds; recommendation backend and Steam library data unverified. | static archive, local browser, fake-Frame |
|
||||
| 133: Non-Steam Badges 0.1.0 | 1 / 2 | partial | Initializer succeeds; Non-Steam game badges have no supported Steam page injection target. | static archive, local browser, fake-Frame |
|
||||
| 134: Deck Progress Tracker 1.3.6 | 1 / 2 | partial | Initializer succeeds; Steam game/library statistics and page patches unverified. | static archive, local browser, fake-Frame |
|
||||
| 135: Deck Shelves 2.0.0 | 1 / unknown | broken | Frontend audit failed: Failed to resolve module specifier "./chunks/pluginApi-B8sV7970.js". Invalid relative url or base scheme isn't hierarchical.. | static archive, local browser, fake-Frame |
|
||||
| 135: Deck Shelves 2.0.0 | 1 / 1 | partial | The exact chunked frontend now initializes and renders Deck Shelves settings in an opaque local Chromium panel. Backend calls were denied; Home-screen shelf injection has no verified Steam UI target in Framey. Settings writes, real backend behavior and worn VR are unverified. | static archive, local browser, fake-Frame |
|
||||
| 136: Decky-Clipper 1.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: Decky-Clipper/bin/gstreamer-1.0/libgstproxy.so. | static archive, local browser, fake-Frame |
|
||||
| 137: Decky Proton Launch 0.9.0 | 1 / 2 | partial | Initializer succeeds; Proton wrapper installation and launch changes were not run. | static archive, local browser, fake-Frame |
|
||||
| 138: Docky 1.4.13 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
@@ -149,7 +149,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 141: DeckyClocker 1.0.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 142: MuteOnSleep 1.0.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 143: Desktop Apps 0.1.0 | 1 / 2 | partial | Initializer succeeds; Flatpak app discovery and Steam shortcut changes unverified. | static archive, local browser, fake-Frame |
|
||||
| 144: Mixer 0.1.0 | 1 / 2 | partial | Local Chromium numeric stream volume and filter save/reopen pass through the actual Decky 3.3.0 Linux backend; filters survive its restart. Frame pw-dump/wpctl, real audio session and worn VR remain unverified. | static archive, local browser, fake-Frame, isolated Linux backend |
|
||||
| 144: Mixer 0.1.0 | 1 / 2 | works | Per-app discovery and numeric volume changes pass in local Chromium through real Decky 3.3.0 ARM64 on the Frame audio session, independently read back on a silent test stream. Filters persist on amd64; ARM64 filter persistence, Reset, internal audio nodes, audible output and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
|
||||
| 145: VARTA 1.0.21 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
|
||||
## API, UI and backend inventory
|
||||
|
||||
+61
-10
@@ -70,6 +70,15 @@ countdown, completion toast and persisted recents/subtle mode worked. Simple
|
||||
Timer remains partial: its own expired-timer startup raises a missing
|
||||
`timer_task` error, sound/suspend are unsupported, and worn VR was not checked.
|
||||
|
||||
DFL module and class lookup searches a finite registry of Framey's own controls,
|
||||
CSS maps and an explicitly unsupported optional UI-audio player. It follows DFL's
|
||||
module/export filter and mapping contracts; unknown Steam modules return
|
||||
`undefined`, and unknown module mappings are empty. Returned IDs describe local
|
||||
Framey exports, not Steam webpack IDs. Sound playback throws an unsupported
|
||||
error, and Steam audio stores, function-source signatures and global patches are
|
||||
not fabricated. The [module lookup checks](evidence/framey-module-lookup-2026-10-26.md)
|
||||
record the resulting TabMaster documentation support and remaining blockers.
|
||||
|
||||
The host currently supplies sections, rows, buttons, toggles, text fields,
|
||||
sliders, grouped dropdowns,
|
||||
scroll containers, a modal, locale information, local panel routes and toasts,
|
||||
@@ -106,9 +115,9 @@ 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
|
||||
disposable handles without emitting fabricated events. Running-game reads and
|
||||
lifetime notifications use the scoped bridge described below; power, app launch
|
||||
and hardware mutation methods are not supplied. Other live Steam stores 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.
|
||||
@@ -116,13 +125,40 @@ Direct IndexedDB users remain unsupported.
|
||||
`SuspensefulImage` renders a native image. SteamClient `Storage.GetJSON` and
|
||||
`SetObject` use the same bounded per-plugin cache, including panel reopen
|
||||
persistence; they do not read Steam's account/global JSON store. Missing keys
|
||||
reject with `Not found`. Detached `SteamUIStore.RunningApps` is a frozen empty
|
||||
list, and the suspend store has no methods. Optional Steam app/account globals
|
||||
are undefined. These fallbacks let settings render without inventing live
|
||||
reject with `Not found`. The suspend store has no methods. Optional Steam app/account globals
|
||||
are undefined until a successful library read. These fallbacks let settings render without inventing live
|
||||
games or account identity. [The exact-bundle follow-up](evidence/framey-ui-fallbacks-2026-10-26.json)
|
||||
verifies Pause Games and Tailscale Control settings in local Chromium; their
|
||||
primary backend actions remain unverified. PlayTime still requires Steam's
|
||||
real app store and game observers.
|
||||
MobX game observers for automatic tracking.
|
||||
|
||||
Panels referencing running-game APIs now receive a detached, read-only
|
||||
`SteamUIStore.RunningApps` / `MainRunningApp` snapshot, also exposed through
|
||||
`DFL.Router`. The authenticated headset-only `/api/framey/running` endpoint
|
||||
checks installed store ownership; the parent checks the active panel session.
|
||||
Poll and close requests also carry the panel's pinned SHA-256 receipt. A missing
|
||||
or changed installed receipt is rejected before any Steam CEF request; a rejected
|
||||
stale close relies on the existing finite subscription lease for cleanup.
|
||||
Fixed bundled code runs in Steam CEF. It copies at most 64 running app overviews, a main app and
|
||||
public Steam CDN capsule URLs; it never copies local artwork paths, launch
|
||||
commands or Steam objects. The host refreshes about every two seconds without
|
||||
overlapping requests. Failure clears stale context and sets
|
||||
`SteamUIStore.FrameyRunningAvailable` to false.
|
||||
|
||||
`GameSessions.RegisterForAppLifetimeNotifications` relays actual Steam
|
||||
`{unAppID, bRunning}` callbacks through a bounded 64-event queue. Snapshot
|
||||
changes never fabricate launch/exit callbacks. Handles support `.unregister()`;
|
||||
panel close/replacement removes the CEF subscription, with a 15-second lease
|
||||
(checked every five seconds) if cleanup cannot reach Steam. At most four panel
|
||||
sessions are retained. These are open-panel observations, not closed-panel
|
||||
automation or a MobX implementation. Queue overflow can lose old callbacks.
|
||||
`appStore` capsule helpers expose only projected public URLs for running apps;
|
||||
unavailable or local custom artwork returns an empty value.
|
||||
|
||||
[Running-context evidence](evidence/framey-running-context-2026-10-26.md) separates
|
||||
real Frame empty-list/subscription/cleanup reads, the exact Discord Status
|
||||
ARM64 backend connection check, and synthetic nonempty/transition fixtures.
|
||||
Real game transitions, Discord activity and worn-VR operation remain unverified.
|
||||
|
||||
Three parameterless SteamClient reads use Steam's actual CEF context:
|
||||
`User.GetIPCountry`, `InstallFolder.GetInstallFolders` and `System.GetSystemInfo`.
|
||||
@@ -157,7 +193,10 @@ Install, Update, and Remove show the current job stage in the headset panel.
|
||||
The store verifies each downloaded archive and the headset applies it with a
|
||||
rollback if Decky cannot start. The panel remembers the current job ID for this
|
||||
browser session, so **Check result** can resume polling after a navigation or
|
||||
temporary connection error. Refresh the installed list before starting another
|
||||
temporary connection error. After a polling error, Install, Update, and Remove
|
||||
stay disabled while the saved job's result is unknown; use **Check result**
|
||||
before starting another change.
|
||||
Refresh the installed list before starting another
|
||||
operation after an error. The headset panel and desktop app send the hash of
|
||||
the version you saw; if the official store changes that version before the job
|
||||
starts, Frame Control stops before downloading or changing the headset. Refresh
|
||||
@@ -165,6 +204,15 @@ the store and review the new version before retrying. If Frame Control restarts,
|
||||
job result. The panel clears that job ID and asks you to refresh the installed
|
||||
list before making another change.
|
||||
|
||||
An open plugin panel closes before its own Update or Remove starts, so its old
|
||||
frontend cannot call the changing backend. Open panel is unavailable while a
|
||||
store job is pending. A later installed-status refresh also closes an open panel
|
||||
if Decky stopped, its store receipt changed, or the status read failed. Reopen
|
||||
the plugin after a successful refresh to use its current frontend.
|
||||
The headset also includes the panel's store SHA-256 receipt with each backend
|
||||
call, Steam read, and event poll. It rejects a request if the installed receipt
|
||||
changed before the next status refresh; reopen the panel to use the new version.
|
||||
|
||||
## For plugin authors
|
||||
|
||||
Start with the [Framey settings sample](../examples/framey-settings/README.md)
|
||||
@@ -185,10 +233,13 @@ and the fake Frame flow before asking users to install it.
|
||||
|
||||
An API v2 plugin's normal `content` is its default VR panel. Framey also shows
|
||||
an optional `settings` React element under a **Settings** tab and an optional
|
||||
`titleView` above it. For one-tap headset controls, return
|
||||
`titleView` above it. A plugin with only `settings` opens that view directly;
|
||||
Framey shows tabs only when both views exist. For one-tap headset controls, return
|
||||
`frameyQuickActions: [{label: 'Refresh data', onClick: refreshData}]` from the
|
||||
plugin initializer. Framey displays up to eight actions and reports a pending,
|
||||
success, or failure state. These descriptor fields are Framey additions; Decky
|
||||
success, or failure state. It ignores extra taps while an action is pending,
|
||||
and a failed action can be retried. Invalid action descriptors do not count
|
||||
toward the eight-action limit. These descriptor fields are Framey additions; Decky
|
||||
ignores them. Use a plugin backend method through `@decky/api` for work needing
|
||||
persistent state, and keep a quick action safe to repeat.
|
||||
See the [Decky API compatibility table](frame-plugin-author.md#framey-decky-api-compatibility)
|
||||
|
||||
@@ -53,7 +53,12 @@ iframe through its own debugging target, and reads control geometry. OpenVR's
|
||||
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.
|
||||
Settings/Panel tabs and quick action. The scroll diagnostic creates a temporary
|
||||
160-pixel-high region with more than 700 pixels of content, moves the native
|
||||
pointer over it, then attempts OpenVR smooth and discrete scroll events. It
|
||||
reports region geometry, trusted pointer events, wheel events and `scrollTop`.
|
||||
No wheel event or offset change means this synthetic injection path remains
|
||||
unverified; it does not establish whether a physical controller can scroll.
|
||||
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,
|
||||
|
||||
+16
-12
@@ -176,19 +176,21 @@ switch while SSH is down:
|
||||
|
||||
## Headset smoke test
|
||||
|
||||
**Documented shared-device procedure:** before a test installs, launches or
|
||||
stops an application, acquire `ssh frame 'mkdir /tmp/frame-test.lock'`. If it
|
||||
fails, leave that lock alone and continue offline work. Only the thread that
|
||||
acquired it releases it with `ssh frame 'rmdir /tmp/frame-test.lock'`, after
|
||||
cleanup. Keep each device session to a few minutes.
|
||||
**Documented shared-device procedure:** before any headset read or a test that
|
||||
installs, launches or stops an application, atomically create
|
||||
`/tmp/frame-test.lock` on the Frame and write a unique token to its `owner`
|
||||
file. If creation fails, leave that lock alone and continue offline work. Only
|
||||
the owner that verifies its token may remove its `owner` file and lock directory
|
||||
after cleanup. Keep each device session to a few minutes.
|
||||
|
||||
Check battery capacity and charging state under `/sys/class/power_supply`
|
||||
before and after; keep capacity above 20%. Stop only processes started by the
|
||||
test, remove temporary installs and profiles, and restore the prior dashboard
|
||||
state. Leave Steam and SteamVR running. Do not reboot or change global settings.
|
||||
Record the build, actual interaction results, cleanup and any unworn-headset
|
||||
limits alongside screenshots or logs. These are caller responsibilities; the
|
||||
smoke script below does not acquire this shared lock itself.
|
||||
limits alongside screenshots or logs. The smoke script below acquires and
|
||||
releases its own tokenized shared lock; other device tests follow this
|
||||
procedure themselves.
|
||||
|
||||
[Packaged Desktop Capture evidence, 2026-10-24](evidence/desktop-capture-packaged-2026-10-24.md)
|
||||
records v0.4.17 GUI button captures of a real mapped nested desktop on SteamOS
|
||||
@@ -220,17 +222,19 @@ removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||
an x86-64 `.exe` that exits at once). A launch passes only with fresh evidence:
|
||||
the ARM64 program running, the `.exe` started (its process or Steam's log),
|
||||
and the x86-64 program running or Steam logging that its runtime isn't
|
||||
installed, which is what the Frame does today. Steam's log lines about each
|
||||
title are kept.
|
||||
and the x86-64 program running. If Steam logs that the optional x86-64 runtime
|
||||
is not installed, that launch is **skipped**, with the reason and log saved in
|
||||
the result. It does not prove that the x86-64 title ran. Steam's log lines about
|
||||
each title are kept.
|
||||
|
||||
Everything it installs is removed again, also after a failure: the titles and
|
||||
their Steam shortcuts, a paired key, and `~/devkit-utils` if it wasn't there
|
||||
before (if it was, it stays, synced to this checkout as Frame Control always
|
||||
does). A cleanup that fails counts as a failed step. Results go to
|
||||
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
|
||||
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
|
||||
isn't reachable.
|
||||
screen. The summary counts passed, skipped and failed steps separately. It
|
||||
exits 0 when no step failed (including a skipped optional runtime), 1 if a step
|
||||
failed, or 2 if the headset isn't reachable.
|
||||
|
||||
Smoke process cleanup runs before and after title removal and during the
|
||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||
|
||||
@@ -14,6 +14,9 @@ action calls the backend, so it reads the persisted setting rather than a
|
||||
browser cache. A missing, damaged, or old non-boolean value reads as the
|
||||
caller's default;
|
||||
the next save atomically replaces the sample's settings file.
|
||||
The panel also guards its own read and write calls synchronously. If two
|
||||
controller events arrive before React redraws a disabled control, the second
|
||||
event is ignored; after the request settles, a new change can be saved.
|
||||
|
||||
The **About this setting** button opens a local detail page at
|
||||
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
|
||||
|
||||
+11
@@ -30,7 +30,11 @@ function Content() {
|
||||
const [enabled, setEnabled] = SP_REACT.useState(false);
|
||||
const [busy, setBusy] = SP_REACT.useState(true);
|
||||
const [message, setMessage] = SP_REACT.useState('Loading setting…');
|
||||
const pending = SP_REACT.useRef(false);
|
||||
async function refresh() {
|
||||
if (pending.current)
|
||||
return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const value = await getSetting('enabled', false);
|
||||
@@ -41,11 +45,17 @@ function Content() {
|
||||
setMessage(`Could not read setting: ${String(error)}`);
|
||||
}
|
||||
finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
SP_REACT.useEffect(() => { void refresh(); }, []);
|
||||
async function change(value) {
|
||||
// Two controller events can arrive before React redraws the disabled toggle.
|
||||
// Keep writes ordered so a late first reply cannot replace the final choice.
|
||||
if (pending.current)
|
||||
return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSetting('enabled', value);
|
||||
@@ -58,6 +68,7 @@ function Content() {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
}
|
||||
finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
+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, 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',\n body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});\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 // FrLine 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, useRef, 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 const pending = useRef(false);\n\n async function refresh() {\n if (pending.current) return;\n pending.current = true;\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 pending.current = false;\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n // Two controller events can arrive before React redraws the disabled toggle.\n // Keep writes ordered so a late first reply cannot replace the final choice.\n if (pending.current) return;\n pending.current = true;\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',\n body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n pending.current = false;\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 Line truncated
|
||||
@@ -1,6 +1,6 @@
|
||||
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
|
||||
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
|
||||
const setSetting = callable<[key: string, value: boolean], boolean>('set_setting');
|
||||
@@ -9,8 +9,11 @@ function Content() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [message, setMessage] = useState('Loading setting…');
|
||||
const pending = useRef(false);
|
||||
|
||||
async function refresh() {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const value = await getSetting('enabled', false);
|
||||
@@ -19,6 +22,7 @@ function Content() {
|
||||
} catch (error) {
|
||||
setMessage(`Could not read setting: ${String(error)}`);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +30,10 @@ function Content() {
|
||||
useEffect(() => {void refresh();}, []);
|
||||
|
||||
async function change(value: boolean) {
|
||||
// Two controller events can arrive before React redraws the disabled toggle.
|
||||
// Keep writes ordered so a late first reply cannot replace the final choice.
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setSetting('enabled', value);
|
||||
@@ -36,6 +44,7 @@ function Content() {
|
||||
} catch (error) {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ Ready means initializer completion, not useful plugin behavior. No plugin backen
|
||||
<button id=run>Run catalog</button> <button id=download>Download observations</button><p id=progress></p>
|
||||
<iframe id=panel sandbox="allow-scripts" style="width:1100px;height:740px"></iframe>
|
||||
<script>
|
||||
const plugins=CATALOG_DATA, librarySnapshot=LIBRARY_DATA;
|
||||
const plugins=CATALOG_DATA, librarySnapshot=LIBRARY_DATA, runningSnapshot=RUNNING_DATA;
|
||||
window.results=[];let active=null,running=false;
|
||||
addEventListener('message',event=>{
|
||||
if(event.source!==panel.contentWindow||!active)return;
|
||||
@@ -45,6 +45,8 @@ addEventListener('message',event=>{
|
||||
if(data.type==='framey:error')active.error=String(data.message).slice(0,500);
|
||||
if(data.type==='framey:ready')active.ready=true;
|
||||
if(data.type==='framey:events')event.source.postMessage({type:'framey:reply',id:data.id,result:{events:[],cursor:0}},'*');
|
||||
if(data.type==='framey:running')event.source.postMessage({type:'framey:reply',id:data.id,
|
||||
...(runningSnapshot?{result:runningSnapshot}:{error:'Read-only compatibility audit: live running context blocked'})},'*');
|
||||
if(data.type==='framey:steam')event.source.postMessage({type:'framey:reply',id:data.id,
|
||||
...(data.method==='GetLibrarySnapshot'&&librarySnapshot?
|
||||
{result:librarySnapshot}:{error:'Read-only compatibility audit: live Steam API blocked'})},'*');
|
||||
@@ -53,13 +55,14 @@ addEventListener('message',event=>{
|
||||
event.source.postMessage({type:'framey:reply',id:data.id,error:'Read-only compatibility audit: backend method blocked'},'*');}
|
||||
if(data.type==='framey:cache:init')event.source.postMessage({type:'framey:cache:seed',session:data.session,entries:{}},'*');
|
||||
});
|
||||
window.runAudit=async(ids)=>{
|
||||
window.runAudit=async(ids,settleMs=1200)=>{
|
||||
if(!Number.isInteger(settleMs)||settleMs<1200||settleMs>10000)throw Error('Invalid audit settle time');
|
||||
if(running)throw Error('Audit already running');running=true;run.disabled=true;
|
||||
try{for(const plugin of plugins.filter(plugin=>!ids||ids.includes(plugin.id))){
|
||||
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],
|
||||
library:librarySnapshot?'snapshot replay':'unavailable'};
|
||||
library:librarySnapshot?'snapshot replay':'unavailable',running:runningSnapshot?'snapshot replay':'unavailable'};
|
||||
progress.textContent=plugin.id+' '+plugin.name;panel.src='/panel/'+plugin.id;
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));results.push(active);active=null;
|
||||
await new Promise(resolve=>setTimeout(resolve,settleMs));results.push(active);active=null;
|
||||
panel.src='about:blank';await new Promise(resolve=>setTimeout(resolve,30));
|
||||
}}finally{running=false;run.disabled=false;progress.textContent='Done: '+results.length;}
|
||||
return results;
|
||||
@@ -77,6 +80,8 @@ def main():
|
||||
parser.add_argument('--port', type=int, default=18765)
|
||||
parser.add_argument('--library-snapshot', type=Path,
|
||||
help='Optional detached Steam library JSON; replay only, not live Steam/backend evidence')
|
||||
parser.add_argument('--running-snapshot', type=Path,
|
||||
help='Optional detached running-app JSON; replay only, not live Steam/backend evidence')
|
||||
args = parser.parse_args()
|
||||
plugins = json.loads(args.catalog.read_text())['plugins']
|
||||
records = {p['id']: p for p in plugins}
|
||||
@@ -86,6 +91,12 @@ def main():
|
||||
if len(raw) > 2*1024*1024:
|
||||
parser.error('library snapshot exceeds 2 MiB')
|
||||
library = json.loads(raw)
|
||||
running = None
|
||||
if args.running_snapshot:
|
||||
raw = args.running_snapshot.read_bytes()
|
||||
if len(raw) > 256*1024:
|
||||
parser.error('running snapshot exceeds 256 KiB')
|
||||
running = json.loads(raw)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
@@ -94,11 +105,13 @@ def main():
|
||||
def do_GET(self):
|
||||
path = self.path.split('?')[0]
|
||||
content_type = 'text/html; charset=utf-8'
|
||||
module_asset = False
|
||||
if path == '/':
|
||||
# Only names/IDs are needed by the UI; never embed raw executable source.
|
||||
catalog = json.dumps([{'id': p['id'], 'name': p['name']} for p in plugins]).replace('<', '\\u003c')
|
||||
body = PAGE.replace('CATALOG_DATA', catalog).replace(
|
||||
'LIBRARY_DATA', json.dumps(library).replace('<', '\\u003c')).encode()
|
||||
'LIBRARY_DATA', json.dumps(library).replace('<', '\\u003c')).replace(
|
||||
'RUNNING_DATA', json.dumps(running).replace('<', '\\u003c')).encode()
|
||||
elif path.startswith('/panel/'):
|
||||
try:
|
||||
record = records[int(path.split('/')[-1])]
|
||||
@@ -113,6 +126,22 @@ def main():
|
||||
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
|
||||
self.send_error(404, 'Verified frontend archive unavailable')
|
||||
return
|
||||
elif path.startswith('/framey/module/'):
|
||||
try:
|
||||
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
|
||||
if not framey_panel.MODULE_PATH.fullmatch(name):
|
||||
raise ValueError('Invalid module path')
|
||||
record = records[int(plugin_id)]
|
||||
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != record['sha256']:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
body = archive.read(record['folder']+'/dist/'+name)
|
||||
content_type = 'text/javascript; charset=utf-8'
|
||||
module_asset = True
|
||||
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
|
||||
self.send_error(404, 'Verified frontend module unavailable')
|
||||
return
|
||||
else:
|
||||
files = {'/framey-runtime/react.js': 'ui/vendor/react-18.3.1.min.js',
|
||||
'/framey-runtime/react-dom.js': 'ui/vendor/react-dom-18.3.1.min.js',
|
||||
@@ -124,6 +153,8 @@ def main():
|
||||
content_type = 'text/javascript'
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', content_type)
|
||||
if module_asset:
|
||||
self.send_header('Access-Control-Allow-Origin', 'null')
|
||||
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline' blob:; style-src 'unsafe-inline'; img-src data:; frame-src 'self'; connect-src 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
@@ -54,13 +54,17 @@ def render(data):
|
||||
'A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer '
|
||||
'through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, '
|
||||
'independent server readbacks and Mixer filter persistence across a runtime restart passed. '
|
||||
'These three stay partial: real ARM64 Frame audio dependencies/session mapping remain unverified because the shared headset lock was occupied. '
|
||||
'An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. Volume Boost stays partial: default-sink reads matched, but real sink writes were denied. Audible output, Bluetooth playback and worn VR remain unverified. '
|
||||
'A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. '
|
||||
'A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs '
|
||||
'and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot '
|
||||
'to render Deck Settings game selection, Steamback\'s empty save panel and PlayTime statistics/settings. '
|
||||
'Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. '
|
||||
'Shotty\'s frontend now starts, but its published backend fails on missing `click`; it stays broken. '
|
||||
'Other official backends remain unexecuted. '
|
||||
'A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 '
|
||||
'through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. '
|
||||
'Its backend was denied; Steam Home shelves and worn VR remain unverified. '
|
||||
'A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. Junk-Store loading/error views render, but its real backend cannot execute the extracted startup script, so it stays broken. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. TabMaster/Junk-Store ran in actual isolated amd64 Decky; no ARM64 or worn-VR result is claimed. Beyond the documented checks, official backends remain unexecuted. '
|
||||
'The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. '
|
||||
'No worn-VR official-plugin result is claimed. '
|
||||
'A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings '
|
||||
|
||||
@@ -210,7 +210,7 @@ def main(argv=None):
|
||||
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
|
||||
import base64,hashlib,json,pathlib
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky'
|
||||
folder=%r
|
||||
p=base/'plugins'/folder
|
||||
@@ -219,7 +219,10 @@ 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'}))
|
||||
digest=hashlib.sha256()
|
||||
for name in ('plugin.json','main.py','dist/index.js'):
|
||||
digest.update(name.encode()+b'\\0'+(p/name).read_bytes())
|
||||
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check','sha256':digest.hexdigest()}))
|
||||
print(json.dumps({'created':True}))
|
||||
''' % (folder, files)
|
||||
remote.python(source)
|
||||
@@ -260,6 +263,11 @@ print('{}')
|
||||
except Exception as exc:
|
||||
report['hiddenOverlayError'] = str(exc)
|
||||
raise RuntimeError('Frame Control overlay stayed hidden after focus retry; no input sent')
|
||||
if args.exercise:
|
||||
lines = remote(VRCMD + ' --overlays').splitlines()
|
||||
report['testOverlayListing'] = [line.strip() for index, line in enumerate(lines)
|
||||
if line.startswith("'" + KEY + "'") or
|
||||
(index and lines[index - 1].startswith("'" + KEY + "'"))]
|
||||
if args.wake or args.exercise:
|
||||
shot = remote.python((ROOT/'ui/frame_vrshot.py').read_text(), timeout=20)['path']
|
||||
try:
|
||||
|
||||
@@ -93,9 +93,11 @@ class Overlay:
|
||||
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):
|
||||
def post_scroll(self, xdelta, ydelta, kind=309):
|
||||
if kind not in (305, 309):
|
||||
raise ValueError('Only OpenVR discrete and smooth scroll events are allowed')
|
||||
view = self.interface(b'FnTable:IVROverlayView_003')
|
||||
event = Event(309, 0xFFFFFFFF, 0, Data(scroll=Scroll(xdelta, ydelta, 0, 1)))
|
||||
event = Event(kind, 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):
|
||||
|
||||
@@ -46,7 +46,7 @@ def exercise(profile, plugin_id, folder):
|
||||
|
||||
def text(): return panel.eval('document.body.innerText')
|
||||
|
||||
def click(selector):
|
||||
def point(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"}})')
|
||||
@@ -58,6 +58,10 @@ def exercise(profile, plugin_id, folder):
|
||||
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')
|
||||
return x, y
|
||||
|
||||
def click(selector):
|
||||
x, y = point(selector)
|
||||
count = panel.eval('__vrClickCount')
|
||||
vr.post_mouse(300, x, y)
|
||||
vr.post_mouse(301, x, y, 1)
|
||||
@@ -113,12 +117,39 @@ def exercise(profile, plugin_id, folder):
|
||||
# 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')
|
||||
# The sample alone may be shorter than its iframe. Use an owned,
|
||||
# visibly scrollable region so a zero offset is a meaningful result.
|
||||
panel.eval('''(()=>{
|
||||
const box=document.createElement('div');box.id='vr-scroll-probe';
|
||||
box.style.cssText='height:160px;overflow-y:scroll;border:2px solid #72baf0';
|
||||
box.innerHTML='<div style="height:700px;padding:8px">Scroll probe</div>';
|
||||
document.body.append(box);
|
||||
window.__vrWheelEvents=[];
|
||||
document.addEventListener('wheel',e=>__vrWheelEvents.push({trusted:e.isTrusted,
|
||||
deltaY:e.deltaY,target:e.target?.id||e.target?.tagName}),{capture:true,passive:true});
|
||||
return true;
|
||||
})()''')
|
||||
result['scrollGeometry'] = panel.eval('(()=>{let e=document.getElementById("vr-scroll-probe");return {height:e.clientHeight,content:e.scrollHeight}})()')
|
||||
if result['scrollGeometry']['content'] <= result['scrollGeometry']['height']:
|
||||
raise RuntimeError('Scroll diagnostic did not create overflow')
|
||||
x, y = point('#vr-scroll-probe')
|
||||
panel.eval('window.__vrScrollPointer=[];document.addEventListener("pointermove",e=>__vrScrollPointer.push({trusted:e.isTrusted,target:e.target?.id||e.target?.tagName}),{capture:true});true')
|
||||
vr.post_mouse(300, x, y)
|
||||
time.sleep(.2)
|
||||
result['scrollPointer'] = panel.eval('__vrScrollPointer')
|
||||
result['scrollBefore'] = panel.eval('document.getElementById("vr-scroll-probe").scrollTop')
|
||||
result['scrollAttempts'] = []
|
||||
for kind in (309, 305):
|
||||
vr.post_scroll(0,-3,kind)
|
||||
time.sleep(.5)
|
||||
offset = panel.eval('document.getElementById("vr-scroll-probe").scrollTop')
|
||||
result['scrollAttempts'].append({'eventType':kind,'offset':offset})
|
||||
if offset != result['scrollBefore']:
|
||||
break
|
||||
result['scrollAfter'] = result['scrollAttempts'][-1]['offset']
|
||||
result['scrollEvents'] = panel.eval('__vrWheelEvents')
|
||||
result['nativeScrollMoved'] = result['scrollAfter'] != result['scrollBefore']
|
||||
panel.eval('document.getElementById("vr-scroll-probe").remove()')
|
||||
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"})')
|
||||
|
||||
@@ -112,25 +112,28 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
|
||||
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
|
||||
receipt = HASHES[0]
|
||||
def framey_call(body):
|
||||
return ok('POST', '/api/framey/method', dict(body, receipt=receipt))
|
||||
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'get_setting','args':[]},
|
||||
headers={'X-Frame-UI':''})
|
||||
self.assertEqual(status, 403)
|
||||
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'x;alert(1)','args':[]})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
|
||||
self.assertTrue(ok('POST', '/api/framey/method',
|
||||
self.assertTrue(framey_call(
|
||||
{'id':999,'method':'set_setting','args':['theme','light']})['result'])
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}],
|
||||
'legacy':True})['result'], {'success':True,'result':'light'})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}],
|
||||
'legacy':True})['result'], {'success':True,'result':True})
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved')
|
||||
|
||||
StoreFixture.release = 1
|
||||
@@ -138,7 +141,12 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
{'action':'update','id':999,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
status, stale_reply, _ = api('POST', '/api/framey/method',
|
||||
{'id':999,'receipt':receipt,'method':'get_setting','args':['theme','dark']})
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn('changed', str(stale_reply))
|
||||
receipt = HASHES[1]
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':999,'acknowledged':True}))
|
||||
@@ -151,7 +159,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'install','id':999,'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
self.assertEqual(ok('POST', '/api/framey/method',
|
||||
self.assertEqual(framey_call(
|
||||
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
|
||||
stale = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'update','id':999,'sha256':HASHES[0],
|
||||
@@ -177,6 +185,13 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
|
||||
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
|
||||
if plugin['id'] == 135:
|
||||
chunk = 'chunks/pluginApi-B8sV7970.js'
|
||||
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
|
||||
expected_chunk = bundle.read(plugin['folder']+'/dist/'+chunk)
|
||||
self.assertEqual(
|
||||
ssh(f'sha256sum {installed}/dist/{chunk}').split()[0],
|
||||
hashlib.sha256(expected_chunk).hexdigest())
|
||||
job = finished(ok('POST', '/api/decky/plugin',
|
||||
{'action':'remove','id':plugin['id'],'acknowledged':True}))
|
||||
self.assertFalse(job.get('error'), job)
|
||||
|
||||
BIN
Binary file not shown.
+10
-1
@@ -7,20 +7,26 @@ from a local fixture so CI does not depend on the store being online.
|
||||
|
||||
| File | Official plugin | Version | Store SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
|
||||
| `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` |
|
||||
| `36.zip` | SteamGridDB | 1.8.1 | `e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0` |
|
||||
| `135.zip` | Deck Shelves | 2.0.0 | `b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91` |
|
||||
|
||||
The matching source projects are
|
||||
[CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader),
|
||||
[MusicControl](https://github.com/nikurasu/MusicControl),
|
||||
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
|
||||
[Steam Status](https://github.com/bschelst/steamstatus-decky),
|
||||
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner),
|
||||
[Bluetooth](https://github.com/Outpox/Bluetooth), and
|
||||
[HLTB for Deck](https://github.com/morwy/hltb-for-deck).
|
||||
[HLTB for Deck](https://github.com/morwy/hltb-for-deck),
|
||||
[SteamGridDB](https://github.com/SteamGridDB/decky-steamgriddb), and
|
||||
[Deck Shelves](https://github.com/santojon/Deck-Shelves).
|
||||
The test verifies the ZIP digest, archive policy, API v1/v2 manifest, installed
|
||||
frontend bytes, managed store receipt, and removal. Separate local host tests
|
||||
start Bluetooth's frontend and its initial panel without running effects or
|
||||
@@ -29,3 +35,6 @@ 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).
|
||||
Deck Shelves' chunked frontend renders its settings in local Chromium with
|
||||
backend/network calls denied. Its Steam Home shelves cannot be verified in the
|
||||
isolated VR panel; no worn-headset test is claimed.
|
||||
@@ -17,6 +17,8 @@ PLUGINS = (
|
||||
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
|
||||
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
|
||||
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
|
||||
{'id': 135, 'name': 'Deck Shelves', 'version': '2.0.0', 'api_version': 1,
|
||||
'folder': 'Deck-Shelves', 'sha256': 'b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91'},
|
||||
)
|
||||
|
||||
# This archive has a legacy IIFE frontend with no manifest API version.
|
||||
|
||||
@@ -3,11 +3,12 @@ import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI; window.__library=setLibrarySnapshot;\n})();');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI; window.__library=setLibrarySnapshot; window.__running=setRunningSnapshot; window.__refreshRunning=refreshRunning;\n})();');
|
||||
assert.notEqual(testSource,source);
|
||||
const states=[];
|
||||
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
useState:value=>[value,next=>states.push(next)], useEffect(){},isValidElement:value=>!!value?.type};
|
||||
useState:value=>[value,next=>states.push(next)], useRef:value=>({current:value}),
|
||||
useEffect(){},isValidElement:value=>!!value?.type};
|
||||
class Element {constructor(){this.children=[];this.textContent='';this.attributes={};}
|
||||
append(...children){this.children.push(...children);}setAttribute(key,value){this.attributes[key]=value;}
|
||||
remove(){this.removed=true;}}
|
||||
@@ -98,14 +99,33 @@ const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{typ
|
||||
{label:'invalid'},...Array.from({length:9},(_,i)=>({label:`Action ${i}`,onClick:()=>{}}))]};
|
||||
const tree=window.__surface({plugin});
|
||||
const buttons=nodes(tree,'button');
|
||||
assert.equal(buttons.length,9); // Panel, Settings, and seven valid actions inside the first eight entries.
|
||||
assert.equal(buttons.length,10); // Panel, Settings, and eight valid actions after filtering.
|
||||
assert.equal(nodes(tree,'nav')[0].props['aria-label'],'Plugin views');
|
||||
assert.equal(nodes(tree,'section')[0].props['aria-label'],'Plugin quick actions');
|
||||
assert.equal(nodes(tree,'header').length,1);
|
||||
await buttons[2].props.onClick();
|
||||
assert.equal(called,1);
|
||||
assert.deepEqual(states.slice(-3),[0,'Refresh data…','Fresh',null].slice(-3));
|
||||
assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav').length,1);
|
||||
let actionCount=0, settleAction;
|
||||
const guarded=window.__surface({plugin:{content:{type:'content'},frameyQuickActions:[{
|
||||
label:'Save setting',onClick:()=>{actionCount++;return new Promise((resolve,reject)=>{
|
||||
settleAction={resolve,reject};});}}]}});
|
||||
const saveButton=nodes(guarded,'button')[0];
|
||||
const firstSave=saveButton.props.onClick();
|
||||
await saveButton.props.onClick(); // React has not rendered disabled state yet.
|
||||
assert.equal(actionCount,1,'rapid second tap must not repeat a backend mutation');
|
||||
settleAction.reject(Error('offline'));
|
||||
await firstSave;
|
||||
const retrySave=saveButton.props.onClick();
|
||||
assert.equal(actionCount,2,'a failed action must release the synchronous lock');
|
||||
settleAction.resolve('Saved');
|
||||
await retrySave;
|
||||
const settingsOnly=window.__surface({plugin:{settings:{type:'settings'}}});
|
||||
assert.equal(nodes(settingsOnly,'nav').length,0);
|
||||
assert.equal(nodes(settingsOnly,'settings').length,1);
|
||||
const panelOnly=window.__surface({plugin:{content:{type:'content'}}});
|
||||
assert.equal(nodes(panelOnly,'nav').length,0);
|
||||
assert.equal(nodes(panelOnly,'content').length,1);
|
||||
|
||||
// API v2 backend calls remain request-scoped and errors return to the caller.
|
||||
const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test');
|
||||
@@ -165,7 +185,35 @@ 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);
|
||||
// Bounded lookup uses DFL's module/export semantics, with only local exports.
|
||||
const dfl=window.DFL;
|
||||
const spinnerClasses=dfl.findClassModule(module=>!!module.SpinnerLoaderContainer);
|
||||
assert.equal(spinnerClasses.ContainerBackground,'framey-loading');
|
||||
assert.equal(dfl.findClass('framey/spinner','Medium'),'framey-medium');
|
||||
assert.equal(dfl.findClassByName('LoadingStatus'),'framey-status');
|
||||
assert.equal(dfl.unminifyClass('framey-loading'),'ContainerBackground');
|
||||
assert.equal(dfl.findClassModule(module=>module.SteamOnlyClass),undefined);
|
||||
assert.equal(dfl.findClass('steam/unknown','Medium'),undefined);
|
||||
assert.equal(dfl.findClassByName('SteamOnlyClass'),undefined);
|
||||
const details=dfl.findModuleDetailsByExport((value,name)=>name==='Spinner' && typeof value==='function');
|
||||
assert.equal(details[0].Spinner,dfl.Spinner);
|
||||
assert.equal(details[1],dfl.Spinner);
|
||||
assert.equal(details[2],'Spinner');assert.equal(details[3],'framey/components');
|
||||
assert.equal(dfl.findModule(module=>module.Spinner),details[0]);
|
||||
assert.equal(dfl.findModuleByExport((value,name)=>name==='Spinner'),details[0]);
|
||||
assert.equal(dfl.findModuleChild(module=>module.Spinner),dfl.Spinner);
|
||||
assert.equal(dfl.findModuleExport((value,name)=>name==='Spinner',1000),undefined);
|
||||
assert.equal(dfl.findModuleExport(value=>value.prototype.nonexistent),undefined);
|
||||
assert.equal(dfl.findModuleChild(()=>false),undefined);
|
||||
const mapping=dfl.createModuleMapping(module=>!!module.SpinnerLoaderContainer);
|
||||
assert.equal(mapping.size,1);assert.equal(mapping.get('framey/spinner'),spinnerClasses);
|
||||
mapping.clear();assert.equal(dfl.findAllModules(module=>!!module.SpinnerLoaderContainer).length,1);
|
||||
assert.equal(dfl.createModuleMapping(module=>module.SteamOnlyClass).size,0);
|
||||
const audio=dfl.findModuleExport(value=>value.GamepadUIAudio).GamepadUIAudio;
|
||||
assert.equal(audio.available,false);
|
||||
assert.throws(()=>audio.AudioPlaybackManager.PlayAudioURL('file:///sound.wav'),/not supported in Framey/);
|
||||
assert.equal(dfl.findModuleExport(value=>value.m_GamepadUIAudioStore),undefined);
|
||||
assert.equal(dfl.findModuleExport(value=>value.toString?.().includes('().LibraryContextMenu')),undefined);
|
||||
window.DFL.Navigation.CloseSideMenus();
|
||||
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0);
|
||||
undo();assert.equal(window.DFL.Navigation.Navigate('/plugin/details'),false);
|
||||
@@ -206,6 +254,7 @@ assert.equal(window.SteamClient.System.ShutdownPC,undefined);
|
||||
assert.equal(window.DFL.Router.MainRunningApp,undefined);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,0);
|
||||
assert.equal(window.DFL.Router.RunningApps,window.SteamUIStore.RunningApps);
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,false);
|
||||
assert.ok(Object.isFrozen(window.SteamUIStore));
|
||||
assert.ok(Object.isFrozen(window.SuspendResumeStore));
|
||||
assert.equal(window.SuspendResumeStore.InitiateSleep,undefined);
|
||||
@@ -235,6 +284,46 @@ for(const invalid of [null,{apps:[],gameIds:[10]},
|
||||
assert.throws(()=>window.__library(invalid),/Invalid Steam/);
|
||||
assert.equal(window.appStore.allApps[0].display_name,'Plugin-local patch');
|
||||
}
|
||||
const running={artwork:[[10,'https://shared.steamstatic.com/steam/apps/10/library.jpg',[]]],apps:[snapshot.apps[0]],runningIds:[10],mainAppId:10,events:[],cursor:0,lifetimeAvailable:true};
|
||||
const lifetimes=[];
|
||||
const lifetimeHandle=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(event=>{
|
||||
lifetimes.push({...event,running:Array.from(window.DFL.Router.RunningApps,app=>app.appid)});
|
||||
});
|
||||
window.__running(running);await Promise.resolve();
|
||||
assert.equal(lifetimes.length,0); // Initial snapshots are not launch events.
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,true);
|
||||
assert.equal(window.DFL.Router.MainRunningApp.display_name,'Game Ten');
|
||||
assert.equal(window.appStore.GetVerticalCapsuleURLForApp({appid:10}),'https://shared.steamstatic.com/steam/apps/10/library.jpg');
|
||||
assert.equal(window.appStore.GetCustomVerticalCapsuleURLs({appid:10}).length,0);
|
||||
assert.equal(window.DFL.Router.RunningApps,window.SteamUIStore.RunningApps);
|
||||
window.DFL.Router.MainRunningApp.display_name='Detached';
|
||||
assert.equal(running.apps[0][1],'Game Ten');
|
||||
const launch={...running,events:[{sequence:1,unAppID:10,bRunning:true}],cursor:1};
|
||||
window.__running(launch);await Promise.resolve();
|
||||
assert.equal(lifetimes.length,1);assert.deepEqual(lifetimes[0].running,[10]);
|
||||
window.__running(launch);await Promise.resolve();assert.equal(lifetimes.length,1);
|
||||
for(const invalid of [null,{...running,runningIds:[99]}, {...running,mainAppId:99},
|
||||
{...running,runningIds:[10,10]}, {...running,artwork:[[10,'file:///private',[]]]}, {...running,cursor:-1},
|
||||
{...running,events:[{sequence:1,unAppID:10,bRunning:'yes'}],cursor:1}]){
|
||||
assert.throws(()=>window.__running(invalid),/Invalid Steam/);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,1);
|
||||
}
|
||||
const exited={...running,artwork:[],apps:[],runningIds:[],mainAppId:null,
|
||||
events:[{sequence:2,unAppID:10,bRunning:false}],cursor:2};
|
||||
window.__running(exited);await Promise.resolve();
|
||||
assert.deepEqual(lifetimes[1].running,[]);assert.equal(lifetimes[1].bRunning,false);
|
||||
lifetimeHandle.unregister();lifetimeHandle();
|
||||
window.__running({...launch,cursor:3,events:[{sequence:3,unAppID:10,bRunning:true}]});
|
||||
await Promise.resolve();assert.equal(lifetimes.length,2);
|
||||
// A failed live read clears stale running context without inventing an exit.
|
||||
const refresh=window.__refreshRunning();
|
||||
const runningRequest=messages.at(-1);assert.equal(runningRequest.type,'framey:running');
|
||||
listeners.find(l=>l.type==='message').handler({source:parent,data:{type:'framey:reply',id:runningRequest.id,error:'offline'}});
|
||||
await refresh;
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,false);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,0);
|
||||
assert.equal(window.DFL.Router.MainRunningApp,undefined);
|
||||
assert.equal(lifetimes.length,2);
|
||||
for(const register of [window.SteamClient.System.UI.RegisterForSystemKeyEvents,
|
||||
window.SteamClient.Apps.RegisterForGameActionUserRequest]){
|
||||
const dispose=register(()=>eventCount++);assert.equal(dispose.unregister,dispose);dispose();
|
||||
@@ -311,3 +400,13 @@ assert.throws(()=>api.addEventListener('bad event',callback),/Invalid Decky even
|
||||
assert.throws(()=>api.addEventListener('tick',null),/Invalid Decky event/);
|
||||
listeners.find(x=>x.type==='unload').handler();
|
||||
assert.equal(messages.at(-1).type,'framey:events:close');
|
||||
const finalRefresh=window.__refreshRunning();
|
||||
assert.equal(window.__refreshRunning(),finalRefresh,'Running reads must not overlap');
|
||||
const finalRequest=messages.at(-1);
|
||||
listeners.find(x=>x.type==='pagehide').handler();
|
||||
assert.equal(messages.at(-1).type,'framey:running:close');
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:finalRequest.id,result:launch}});
|
||||
await finalRefresh;
|
||||
assert.equal(window.SteamUIStore.FrameyRunningAvailable,false,'Closed panels ignore late snapshots');
|
||||
const messageCount=messages.length;
|
||||
await window.__refreshRunning();assert.equal(messages.length,messageCount);
|
||||
@@ -19,7 +19,7 @@ const parent=vm.createContext({document:{getElementById:el,createElement:()=>({}
|
||||
window:{addEventListener:(type,fn)=>parentListeners.push({type,fn})},
|
||||
setTimeout(){},setInterval(){},fetch(){throw Error('No network expected');},console});
|
||||
vm.runInContext(parentSource,parent);
|
||||
vm.runInContext('deckyState={plugins:[{storeId:14}]};openFrameyPanel(14,"Steam Status")',parent);
|
||||
vm.runInContext('deckyState={running:true,plugins:[{storeId:14,sha256:"a",version:"1"}]};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;
|
||||
|
||||
@@ -15,7 +15,7 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
|
||||
setTimeout(){},setInterval(){},console,
|
||||
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
|
||||
vm.runInContext(source,context);
|
||||
vm.runInContext('deckyState={plugins:[{storeId:96}]};openFrameyPanel(96,"Timer")',context);
|
||||
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};openFrameyPanel(96,"Timer")',context);
|
||||
let session=vm.runInContext('frameySession',context);
|
||||
const onMessage=listeners.find(x=>x.type==='message').fn;
|
||||
const send=(data,origin=frame)=>onMessage({source:origin,data});
|
||||
@@ -28,6 +28,7 @@ const first=send(subscription);
|
||||
assert.equal(requests.length,1);
|
||||
assert.equal(requests[0].path,'/api/framey/events');
|
||||
assert.equal(requests[0].body.id,96);
|
||||
assert.equal(requests[0].body.receipt,'a'.repeat(64));
|
||||
assert.equal(requests[0].body.session,session);
|
||||
// WindowProxy survives navigation: stale same-plugin replies need a session check.
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
@@ -35,6 +36,7 @@ const secondSession=vm.runInContext('frameySession',context);
|
||||
assert.notEqual(secondSession,session);
|
||||
assert.equal(requests[1].body.events.length,0); // Closes only the original subscription.
|
||||
assert.equal(requests[1].body.session,session);
|
||||
assert.equal(requests[1].body.receipt,'a'.repeat(64));
|
||||
requests[0].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[42]}],cursor:1})});
|
||||
await first;assert.equal(replies.length,0);
|
||||
await send({type:'framey:call',id:2,session,method:'cancel_timer',args:[]});
|
||||
@@ -59,6 +61,7 @@ await send(steamRead,{});
|
||||
assert.equal(requests.length,initialCount);
|
||||
const read=send(steamRead);
|
||||
assert.equal(requests.at(-1).path,'/api/framey/steam');assert.equal(requests.at(-1).body.id,96);
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
|
||||
await read;assert.equal(replies.at(-1).result,'AU');
|
||||
const staleRead=send({...steamRead,id:11});
|
||||
@@ -66,3 +69,30 @@ vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
const priorReplies=replies.length;
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
|
||||
await staleRead;assert.equal(replies.length,priorReplies);
|
||||
|
||||
// Running reads are tied to the current iframe and session, with no method path.
|
||||
session=vm.runInContext('frameySession',context);
|
||||
const running={type:'framey:running',id:20,session,args:[0]};
|
||||
const beforeRunning=requests.length;
|
||||
for(const change of [{session:'old'},{args:[-1]},{args:[true]},{args:[0,'extra']}])
|
||||
await send({...running,...change});
|
||||
await send(running,{});assert.equal(requests.length,beforeRunning);
|
||||
const pendingRunning=send(running);
|
||||
assert.equal(requests.at(-1).path,'/api/framey/running');
|
||||
assert.equal(requests.at(-1).body.active,true);
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
const oldRunning=requests.at(-1);
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
assert.equal(requests.at(-1).body.active,false);
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
assert.equal(requests.at(-1).body.session,session);
|
||||
const beforeStaleReply=replies.length;
|
||||
oldRunning.resolve({ok:true,json:async()=>({result:{apps:[],events:[],cursor:0}})});
|
||||
await pendingRunning;assert.equal(replies.length,beforeStaleReply);
|
||||
session=vm.runInContext('frameySession',context);
|
||||
const finalRunning=send({...running,id:21,session});
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:{apps:[],events:[],cursor:0}})});
|
||||
await finalRunning;
|
||||
await send({type:'framey:running:close',session});
|
||||
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
|
||||
assert.equal(requests.at(-1).body.active,false);
|
||||
@@ -49,7 +49,7 @@ 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(window.DFL.findModuleChild(()=>{}),undefined); // 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.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
// Real Frame getter source and unregister-handle shape were probed over SSH.
|
||||
// Nonempty rows and launch/exit notifications here are explicit test fixtures.
|
||||
const source=readFileSync(new URL('../../ui/framey-running.js',import.meta.url),'utf8');
|
||||
const listeners=new Set(),timers=new Map();let now=1000,nextTimer=0;
|
||||
const api={RegisterForAppLifetimeNotifications(listener){
|
||||
listeners.add(listener);return {unregister:()=>listeners.delete(listener)};
|
||||
}};
|
||||
const app={appid:10,display_name:'Test game',sort_as:'test',app_type:1,gameid:'10',
|
||||
minutes_playtime_forever:30,minutes_playtime_last_two_weeks:5,rt_last_time_played:123,
|
||||
private_path:'PRIVATE',owner:'PRIVATE'};
|
||||
const window={SteamClient:{GameSessions:api},SteamUIStore:{RunningApps:[app],MainRunningApp:app}};
|
||||
window.appStore={GetVerticalCapsuleURLForApp:()=> 'https://shared.steamstatic.com/steam/apps/10/library.jpg?t=123',
|
||||
GetCustomVerticalCapsuleURLs:()=>['file:///PRIVATE/path','https://example.com/PRIVATE','https://shared.steamstatic.com/steam/apps/10/custom.jpg']};
|
||||
const context=vm.createContext({window,URL,Date:{now:()=>now},
|
||||
setInterval:fn=>{timers.set(++nextTimer,fn);return nextTimer;},clearInterval:id=>timers.delete(id)});
|
||||
const poll=(session='one',pluginId=13,active=true,cursor=0)=>
|
||||
JSON.parse(JSON.stringify(vm.runInContext(source,context)({session,pluginId,active,cursor})));
|
||||
const emit=event=>{for(const listener of listeners)listener(event);};
|
||||
let data=poll();
|
||||
assert.deepEqual(data.apps,[[10,'Test game','test',1,'10',30,5,123]]);
|
||||
assert.deepEqual(data.artwork,[[10,'https://shared.steamstatic.com/steam/apps/10/library.jpg',['https://shared.steamstatic.com/steam/apps/10/custom.jpg']]]);
|
||||
assert.deepEqual(data.runningIds,[10]);assert.equal(data.mainAppId,10);
|
||||
assert.equal(data.lifetimeAvailable,true);assert.deepEqual(data.events,[]);
|
||||
assert.equal(listeners.size,1);
|
||||
// A nonempty snapshot does not invent a game launch notification.
|
||||
assert.equal(data.cursor,0);assert.deepEqual(poll().events,[]);
|
||||
emit({unAppID:10,bRunning:true,private_token:'PRIVATE'});
|
||||
assert.deepEqual(poll().events,[{sequence:1,unAppID:10,bRunning:true}]);
|
||||
assert.deepEqual(poll('one',13,true,1).events,[]);
|
||||
assert.equal(poll().events.length,1); // Retry a dropped HTTP response.
|
||||
assert.equal(listeners.size,1); // Polls never duplicate registration.
|
||||
poll('two',37);assert.equal(listeners.size,2);
|
||||
assert.throws(()=>poll('one',37),/another plugin/);
|
||||
for(const event of [{unAppID:'10',bRunning:true},{unAppID:10,bRunning:1},
|
||||
{unAppID:NaN,bRunning:true},{unAppID:-1,bRunning:false}])emit(event);
|
||||
assert.equal(poll().cursor,1);
|
||||
for(let n=0;n<100;n++)emit({unAppID:10,bRunning:!!(n%2)});
|
||||
assert.equal(poll().events.length,64);
|
||||
assert.equal(poll().cursor,101);
|
||||
window.SteamUIStore.RunningApps=[];window.SteamUIStore.MainRunningApp=undefined;
|
||||
assert.deepEqual(poll().runningIds,[]);assert.equal(poll().mainAppId,null);
|
||||
assert.equal(poll().cursor,101); // Reading a closed game invents no exit event.
|
||||
assert.equal(poll('one',13,false),null);assert.equal(listeners.size,1);
|
||||
now+=16000;for(const timer of [...timers.values()])timer();
|
||||
assert.equal(listeners.size,0);assert.equal(timers.size,0);
|
||||
poll();assert.equal(listeners.size,1);
|
||||
window.SteamClient.GameSessions={...api};poll();assert.equal(listeners.size,1);
|
||||
poll('one',13,false);assert.equal(listeners.size,0);
|
||||
window.SteamUIStore.RunningApps=Array(65).fill(app);
|
||||
assert.throws(()=>poll(),/Too many/);assert.equal(listeners.size,0);
|
||||
window.SteamUIStore=undefined;
|
||||
assert.throws(()=>poll(),/unavailable/);assert.equal(listeners.size,0);
|
||||
@@ -8,7 +8,8 @@ const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js'
|
||||
assert.match(bundle,/SP_JSX\.jsx/);
|
||||
const React={Fragment:Symbol('fragment'),Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
|
||||
useState:value=>[value,()=>{}],useRef:value=>({current:value}),
|
||||
useEffect(){},isValidElement:value=>!!value?.type};
|
||||
class Element {
|
||||
constructor(){this.children=[];this.textContent='';this.attributes={};}
|
||||
append(...nodes){this.children.push(...nodes);}
|
||||
@@ -51,8 +52,12 @@ assert.equal(calls[0].method,'get_setting');
|
||||
assert.deepEqual(Array.from(calls[0].args),['enabled',false]);
|
||||
// Exercise the built sample's save path through the real Framey toast host.
|
||||
// Its JSX body must reach a React root, rather than stringify to an object.
|
||||
content.children[0][0].children[0].props.onChange(true);
|
||||
const toggle=content.children[0][0].children[0];
|
||||
toggle.props.onChange(true);
|
||||
toggle.props.onChange(false); // Before React redraws disabled, do not race the first write.
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(calls.filter(call=>call.method==='set_setting').length,1);
|
||||
assert.deepEqual(Array.from(calls.find(call=>call.method==='set_setting').args),['enabled',true]);
|
||||
const toast=elements.get('framey-toasts').children[0];
|
||||
assert.equal(toast.attributes.role,'status');
|
||||
assert.equal(toast.children[0].textContent,'Framey sample');
|
||||
@@ -62,6 +67,10 @@ assert.match(JSON.stringify(toastBody),/Setting saved/);
|
||||
assert.match(JSON.stringify(toastBody),/on/);
|
||||
toast.children[2].onclick();
|
||||
assert.equal(roots.get(toast.children[1]).unmounted,true);
|
||||
toggle.props.onChange(false);
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(calls.filter(call=>call.method==='set_setting').length,2,
|
||||
'a completed save must release the sample setting lock');
|
||||
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);
|
||||
|
||||
@@ -68,6 +68,7 @@ ctx.mockApi=async path => path==='/api/decky' ?
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,true);
|
||||
el('deckySearch').value='';el('deckyStoreFilter').value='all';
|
||||
vm.runInContext('showDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
|
||||
@@ -82,6 +83,40 @@ 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);
|
||||
|
||||
// Polling can lose the connection after the server accepted a plugin job.
|
||||
// Keep both store and installed controls disabled until its saved ID resolves.
|
||||
let changes=0;
|
||||
ctx.mockApi=async (path,body) => {
|
||||
if(path==='/api/decky/plugin'){changes++;return {job:'job-pending'};}
|
||||
if(path.startsWith('/api/job'))throw Error('connection lost');
|
||||
if(path==='/api/decky')return {managed:true,running:true,
|
||||
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
|
||||
assert.equal(changes,1);
|
||||
assert.equal(session.has('frameyPluginJob'),true);
|
||||
assert.equal(el('deckyJobCheck').hidden,false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,true);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,true);
|
||||
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
|
||||
await vm.runInContext('deckyChange("remove",{id:2,name:"Plugin 2"})',ctx);
|
||||
assert.equal(changes,1);
|
||||
assert.match(el('deckyJobStatus').textContent,/Check result before starting another/);
|
||||
ctx.mockApi=async path => {
|
||||
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
|
||||
if(path==='/api/decky')return {managed:true,running:true,
|
||||
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('checkDeckyJob()',ctx);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
|
||||
assert.equal(el('deckyStoreList').children[1].children[1].disabled,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');};
|
||||
@@ -117,6 +152,7 @@ await vm.runInContext('checkDeckyJob()',ctx);
|
||||
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
|
||||
|
||||
// Known hardware blockers remain visible, cannot install, and keep their reason in the opened panel.
|
||||
ctx.hardware={id:900,name:'Hardware only',version:'1.0',description:'Fan control',root:false,
|
||||
@@ -131,3 +167,80 @@ assert.equal(el('deckyStoreList').children.length,0);
|
||||
vm.runInContext('openFrameyPanel(900,"Hardware only")',ctx);
|
||||
assert.match(el('frameyCompatibility').textContent,/Deck fan hardware/);
|
||||
assert.match(el('frameyCompatibility').textContent,/local browser/);
|
||||
|
||||
// A failed installed-status read must not leave stale Remove or Install actions.
|
||||
// Older responses must not replace a newer status, even if they arrive last.
|
||||
vm.runInContext('deckyStore=[{id:2,name:"Plugin 2",version:"1.0",description:"A panel",root:false}];' +
|
||||
'deckyState={running:true,plugins:[{storeId:2,name:"Plugin 2",version:"1.0"}]};showDeckyStore()',ctx);
|
||||
let statusCalls=[];
|
||||
ctx.mockApi=path=>{assert.equal(path,'/api/decky');
|
||||
return new Promise((resolve,reject)=>statusCalls.push({resolve,reject}));};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
const staleSuccess=vm.runInContext('refreshDecky()',ctx);
|
||||
const latestFailure=vm.runInContext('refreshDecky()',ctx);
|
||||
statusCalls[1].reject(Error('offline'));
|
||||
await latestFailure;
|
||||
assert.match(el('deckyState').textContent,/Installed plugins unavailable: offline/);
|
||||
assert.equal(el('deckyInstalled').children.length,0);
|
||||
assert.equal(el('deckyInstalled').textContent,'Refresh to check installed plugins.');
|
||||
assert.equal(el('deckyStoreList').children[0].children[1].disabled,true);
|
||||
statusCalls[0].resolve({running:true,plugins:[{storeId:2,name:'Stale',version:'1.0'}]});
|
||||
await staleSuccess;
|
||||
assert.match(el('deckyState').textContent,/offline/);
|
||||
assert.equal(el('deckyInstalled').children.length,0);
|
||||
|
||||
statusCalls=[];
|
||||
const staleFailure=vm.runInContext('refreshDecky()',ctx);
|
||||
const latestSuccess=vm.runInContext('refreshDecky()',ctx);
|
||||
statusCalls[1].resolve({managed:true,running:true,plugins:[{storeId:2,name:'Current',version:'1.0'}]});
|
||||
await latestSuccess;
|
||||
statusCalls[0].reject(Error('old offline error'));
|
||||
await staleFailure;
|
||||
assert.match(el('deckyInstalled').children[0].children[0].textContent,/Current/);
|
||||
assert.doesNotMatch(el('deckyState').textContent,/offline/);
|
||||
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
|
||||
|
||||
// A panel must not keep an old frontend after the installed receipt changes.
|
||||
let installedHash='a'.repeat(64);
|
||||
ctx.mockApi=async path => {
|
||||
assert.equal(path,'/api/decky');
|
||||
return {managed:true,running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0',sha256:installedHash}]};
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,false);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,false,'unchanged receipt keeps the current panel');
|
||||
installedHash='b'.repeat(64);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
assert.equal(el('frameyFrame').src,undefined);
|
||||
assert.match(el('deckyState').textContent,/old plugin panel was closed/);
|
||||
|
||||
// The panel closes before updating its own backend, and stale Open handlers
|
||||
// cannot recreate it while a job is active.
|
||||
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,false);
|
||||
ctx.mockApi=async (path,body) => {
|
||||
if(path==='/api/decky/plugin'){
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
return {job:'job-update'};
|
||||
}
|
||||
if(path.startsWith('/api/job'))return {done:true,message:'Updated Plugin 2'};
|
||||
if(path==='/api/decky')return {managed:true,running:true,
|
||||
plugins:[{storeId:2,name:'Plugin 2',version:'2.0',sha256:'c'.repeat(64)}]};
|
||||
throw Error(`unexpected ${path}`);
|
||||
};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"c".repeat(64)})',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
assert.match(el('deckyJobStatus').textContent,/Old plugin panel closed; reopen it/);
|
||||
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
|
||||
ctx.mockApi=async()=>{throw Error('offline');};
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('frameyPanel').hidden,true);
|
||||
assert.match(el('deckyState').textContent,/panel was closed until status can be refreshed/);
|
||||
+26
-10
@@ -27,7 +27,8 @@ rechecked around deletion; shared Proton services are never stopped by name.
|
||||
|
||||
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
|
||||
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
|
||||
Exit status: 0 all passed, 1 a step failed, 2 the headset isn't reachable.
|
||||
Exit status: 0 no step failed (optional runtime may skip a launch), 1 a step
|
||||
failed, 2 the headset isn't reachable.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@@ -125,21 +126,32 @@ class Smoke:
|
||||
self.installed = set()
|
||||
|
||||
def step(self, name, fn, *args):
|
||||
"""Run one step; record ok/failed, its detail and how long it took."""
|
||||
"""Run one step; record passed/skipped/failed and how long it took."""
|
||||
t0 = time.monotonic()
|
||||
rec = {'step': name}
|
||||
try:
|
||||
detail = fn(*args)
|
||||
rec.update(ok=True, detail=detail)
|
||||
except Skipped as e:
|
||||
rec.update(ok=False, skipped=True, reason=str(e), detail=e.detail)
|
||||
except Exception as e: # a failed step is a result, not the end of the run
|
||||
rec.update(ok=False, error=f'{type(e).__name__}: {e}', trace=traceback.format_exc(limit=3))
|
||||
rec['seconds'] = round(time.monotonic() - t0, 1)
|
||||
self.steps.append(rec)
|
||||
print(f" {'ok ' if rec['ok'] else 'FAIL'} {name} ({rec['seconds']} s)"
|
||||
+ ('' if rec['ok'] else f": {rec['error']}"), flush=True)
|
||||
label = 'ok ' if rec['ok'] else 'SKIP' if rec.get('skipped') else 'FAIL'
|
||||
message = '' if rec['ok'] else f": {rec.get('reason') or rec['error']}"
|
||||
print(f" {label} {name} ({rec['seconds']} s){message}", flush=True)
|
||||
return rec
|
||||
|
||||
|
||||
class Skipped(Exception):
|
||||
"""A prerequisite was unavailable; preserve its evidence without claiming a launch."""
|
||||
|
||||
def __init__(self, reason, detail):
|
||||
super().__init__(reason)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def ssh(cmd, timeout=60):
|
||||
return frame_android.ssh(cmd, timeout=timeout)
|
||||
|
||||
@@ -194,8 +206,8 @@ def steam_log_lines(*patterns):
|
||||
|
||||
# What a launch must show, per kind. The x86-64 runtime isn't installed on the
|
||||
# Frame, so Steam acknowledges that launch and logs "... is not installed"
|
||||
# instead (docs/sideloading.md, 2026-09-26): recorded, not a failure.
|
||||
EXPECTED = {'arm64': ('running',), 'x86_64': ('running', 'runtime missing'), 'exe': ('running', 'started')}
|
||||
# instead (docs/sideloading.md, 2026-09-26): record a skip, not a launch pass.
|
||||
EXPECTED = {'arm64': ('running',), 'x86_64': ('running',), 'exe': ('running', 'started')}
|
||||
|
||||
|
||||
def title_cycle(smoke, kind, folder):
|
||||
@@ -229,10 +241,12 @@ def title_cycle(smoke, kind, folder):
|
||||
outcome, new = 'runtime missing', new + missing
|
||||
elif any('started devkit game' in line or 'chdir' in line for line in new):
|
||||
outcome = 'started'
|
||||
if outcome in EXPECTED[kind]: # else keep looking: the log can come before the process
|
||||
if outcome in EXPECTED[kind] or (kind == 'x86_64' and outcome == 'runtime missing'):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
detail = {'outcome': outcome, 'processes': procs, 'steam_log': new[-12:]}
|
||||
if kind == 'x86_64' and outcome == 'runtime missing':
|
||||
raise Skipped('Steam Linux Runtime 4 is not installed; x86_64 title did not launch', detail)
|
||||
if outcome not in EXPECTED[kind]:
|
||||
raise AssertionError(f"Steam acknowledged the launch, but {outcome} (expected "
|
||||
f"{' or '.join(EXPECTED[kind])}): {json.dumps(detail)[:400]}")
|
||||
@@ -396,14 +410,16 @@ def run_smoke(args, before):
|
||||
'rm -rf ~/devkit-utils ~/.devkit-utils.frame-control && echo removed')
|
||||
subprocess.run(['rm', '-rf', folder])
|
||||
passed = sum(s['ok'] for s in smoke.steps)
|
||||
skipped = sum(s.get('skipped', False) for s in smoke.steps)
|
||||
failed = len(smoke.steps) - passed - skipped
|
||||
RESULTS.mkdir(exist_ok=True)
|
||||
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
|
||||
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'preflight': before,
|
||||
'os_release': release, 'paired': args.pair,
|
||||
'passed': passed, 'failed': len(smoke.steps) - passed,
|
||||
'passed': passed, 'skipped': skipped, 'failed': failed,
|
||||
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
|
||||
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out}')
|
||||
return 0 if passed == len(smoke.steps) else 1
|
||||
print(f'==> {passed} passed, {skipped} skipped, {failed} failed on build {build}; results in {out}')
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -440,6 +440,15 @@ sys.exit(255 if stop else 0)
|
||||
class Recording(unittest.TestCase):
|
||||
"""The Recorder with the real record/finish/cleanup shell, run locally under bash."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# The Frame's Bash 5 returns >128 for a timed-out read, distinct from EOF.
|
||||
# macOS Bash 3.2 returns 1 for both, so it would falsely simulate Stop.
|
||||
result = subprocess.run(["bash", "-c", "(( BASH_VERSINFO[0] >= 4 ))"],
|
||||
capture_output=True, timeout=5)
|
||||
if result.returncode:
|
||||
raise unittest.SkipTest("Recording shell fixtures need Bash 4+ timeout semantics; macOS ships Bash 3.2")
|
||||
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
|
||||
@@ -74,9 +74,14 @@ class OfficialBundles(unittest.TestCase):
|
||||
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
|
||||
with patch.object(framey_panel, 'PLUGINS', plugins):
|
||||
page = framey_panel.panel(plugin['id']).decode()
|
||||
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
|
||||
encoded = re.search(r'<script id="framey-bundle" type="application/json"[^>]*>([^<]+)</script>', page)
|
||||
self.assertIsNotNone(encoded)
|
||||
self.assertEqual(base64.b64decode(encoded.group(1)), (folder / 'dist/index.js').read_bytes())
|
||||
if plugin['id'] == 135:
|
||||
self.assertIn('data-module="/framey/module/135/index.js"', page)
|
||||
with patch.object(framey_panel, 'PLUGINS', plugins):
|
||||
self.assertEqual(framey_panel.local_module(135, 'chunks/pluginApi-B8sV7970.js'),
|
||||
(folder/'dist/chunks/pluginApi-B8sV7970.js').read_bytes())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+110
-21
@@ -18,11 +18,61 @@ import framey_panel
|
||||
import frame_headset
|
||||
import server
|
||||
|
||||
RECEIPT = 'a' * 64
|
||||
|
||||
|
||||
class Panel(unittest.TestCase):
|
||||
def test_running_queue_and_host_contract(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_running.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_running_bridge_is_installed_scoped_bounded_and_headset_only(self):
|
||||
def fake_ssh(command, stdin, **_):
|
||||
self.assertEqual(command, 'python3 -')
|
||||
compile(stdin, '<running read>', 'exec')
|
||||
self.assertIn('RegisterForAppLifetimeNotifications', stdin)
|
||||
self.assertIn('262144', stdin)
|
||||
self.assertNotIn('__FRAMEY_STEAM_', stdin)
|
||||
return '{"result":{"apps":[],"events":[],"cursor":0}}'
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={'plugins':[{'storeId':13,'sha256':'a'*64}]}):
|
||||
self.assertEqual(framey_panel.poll_running(fake_ssh,13,'test-1',True,receipt='a'*64)['result']['cursor'],0)
|
||||
for plugin_id,session,active,cursor in [(True,'test',True,0),(0,'test',True,0),
|
||||
(14,'test',True,0),(13,'bad session',True,0),(13,'test',1,0),
|
||||
(13,'test',True,True),(13,'test',True,-1),(13,'test',True,2**53)]:
|
||||
with self.subTest(args=(plugin_id,session,active,cursor)):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.poll_running(lambda *a,**k:self.fail('Unexpected SSH'),
|
||||
plugin_id,session,active,cursor,receipt='a'*64)
|
||||
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.framey_running_action({'id':13,'session':'test','active':True})
|
||||
with patch.object(server, 'HEADSET_RUNTIME', True), patch.object(framey_panel, 'poll_running',return_value={'result':None}) as poll:
|
||||
self.assertEqual(server.framey_running_action({'id':13,'session':'test','active':False,'receipt':'a'*64}),{'result':None})
|
||||
poll.assert_called_once_with(server.ssh,13,'test',False,0,'a'*64)
|
||||
|
||||
def test_running_rejects_stale_or_missing_receipt_after_plugin_replacement(self):
|
||||
installed = {'storeId':13,'sha256':'a'*64}
|
||||
def safe_ssh(command, stdin, **_):
|
||||
self.assertEqual(command,'python3 -')
|
||||
compile(stdin,'<running read>','exec')
|
||||
return '{"result":null}'
|
||||
with patch.object(framey_panel.frame_decky,'status',side_effect=lambda _: {'plugins':[installed]}):
|
||||
framey_panel.poll_running(safe_ssh,13,'old-panel',True,receipt='a'*64)
|
||||
installed['sha256'] = 'b'*64 # Another manager replaces the installed plugin.
|
||||
for active in (True,False):
|
||||
for receipt in (None,'','bad','a'*64):
|
||||
with self.subTest(active=active,receipt=receipt):
|
||||
with self.assertRaisesRegex(framey_panel.PanelError,'receipt|changed'):
|
||||
framey_panel.poll_running(lambda *a,**k:self.fail('Stale running request reached CEF'),
|
||||
13,'old-panel',active,receipt=receipt)
|
||||
# A reopened panel uses the new receipt for both polling and cleanup.
|
||||
framey_panel.poll_running(safe_ssh,13,'new-panel',True,receipt='b'*64)
|
||||
framey_panel.poll_running(safe_ssh,13,'new-panel',False,receipt='b'*64)
|
||||
|
||||
def test_steam_reads_are_allowlisted_and_installed_only(self):
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck'}]}):
|
||||
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck','sha256':RECEIPT}]}):
|
||||
for method, expression in framey_panel.STEAM_READS.items():
|
||||
def fake_ssh(command, stdin, **_):
|
||||
self.assertEqual(command, 'python3 -')
|
||||
@@ -30,13 +80,16 @@ class Panel(unittest.TestCase):
|
||||
self.assertIn(expression.replace('\n', r'\n'), stdin)
|
||||
self.assertNotIn('__FRAMEY_STEAM_READ__', stdin)
|
||||
return '{"result":"actual read"}'
|
||||
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method)['result'], 'actual read')
|
||||
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method,RECEIPT)['result'], 'actual read')
|
||||
for plugin_id, method in [(True,'GetIPCountry'), (0,'GetIPCountry'),
|
||||
(82,'GetIPCountry'), (81,'ShutdownPC'), (81,'User.GetIPCountry'),
|
||||
(81,[]), (81,'GetIPCountry();alert(1)')]:
|
||||
with self.subTest(plugin_id=plugin_id, method=method):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method)
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method,RECEIPT)
|
||||
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Stale read reached SSH'),
|
||||
81,'GetIPCountry','b'*64)
|
||||
|
||||
def test_steam_read_response_limit_and_socket_cleanup(self):
|
||||
from types import SimpleNamespace
|
||||
@@ -86,19 +139,19 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertEqual(json.loads(ast.literal_eval(assignment.value.args[0]))['args'],args)
|
||||
return '{"result":null}'
|
||||
with patch.object(framey_panel.frame_decky,'status',return_value={
|
||||
'plugins':[{'storeId':53,'name':'Shotty'}]}):
|
||||
'plugins':[{'storeId':53,'name':'Shotty','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,
|
||||
53,'set_id_map_fronend',args,True),{'result':None})
|
||||
53,'set_id_map_fronend',args,True,RECEIPT),{'result':None})
|
||||
with self.assertRaisesRegex(framey_panel.PanelError,'too large'):
|
||||
framey_panel.call_method(lambda *a,**k:self.fail('Unexpected SSH'),
|
||||
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True)
|
||||
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True,RECEIPT)
|
||||
|
||||
def test_steam_reads_have_the_headset_service_boundary(self):
|
||||
with patch.object(server,'HEADSET_RUNTIME',False), patch.dict(server.os.environ,{'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):server.framey_steam_action({'id':81,'method':'GetIPCountry'})
|
||||
with patch.object(server,'HEADSET_RUNTIME',True), patch.object(framey_panel,'read_steam',return_value={'result':'AU'}) as read:
|
||||
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry'}),{'result':'AU'})
|
||||
read.assert_called_once_with(server.ssh,81,'GetIPCountry')
|
||||
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry','receipt':RECEIPT}),{'result':'AU'})
|
||||
read.assert_called_once_with(server.ssh,81,'GetIPCountry',RECEIPT)
|
||||
|
||||
def test_decky_event_queue_scope_bounds_expiry_and_reconnect(self):
|
||||
for name in ('framey_events.mjs', 'framey_event_parent.mjs'):
|
||||
@@ -117,8 +170,11 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertIn('loader/plugin_event', stdin)
|
||||
return json.dumps({'events':[{'event':'timer:tick', 'args':[42]}], 'cursor':6})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':96,'name':'Simple Timer'}]}):
|
||||
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5)['cursor'], 6)
|
||||
'plugins':[{'storeId':96,'name':'Simple Timer','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5, RECEIPT)['cursor'], 6)
|
||||
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
|
||||
framey_panel.poll_events(lambda *a,**k:self.fail('Stale poll reached SSH'),
|
||||
96,'test-123.45',['timer:tick'],5,'b'*64)
|
||||
for plugin_id, session, events, cursor in [
|
||||
(True,'test',['tick'],0), (96,'bad session',['tick'],0),
|
||||
(96,'test',['a']*33,0), (96,'test',['bad event'],0),
|
||||
@@ -129,7 +185,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
with self.subTest(plugin_id=plugin_id, session=session, events=events, cursor=cursor):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.poll_events(lambda *a, **k:self.fail('Unexpected SSH'),
|
||||
plugin_id, session, events, cursor)
|
||||
plugin_id, session, events, cursor, RECEIPT)
|
||||
|
||||
def test_event_action_has_same_headset_boundary_as_methods(self):
|
||||
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
|
||||
@@ -137,8 +193,8 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
server.framey_events_action({'id':96,'session':'test','events':['tick']})
|
||||
with patch.object(server, 'HEADSET_RUNTIME', True), \
|
||||
patch.object(framey_panel, 'poll_events', return_value={'events':[], 'cursor':0}) as poll:
|
||||
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick']})['events'], [])
|
||||
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0)
|
||||
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick'],'receipt':RECEIPT})['events'], [])
|
||||
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0, RECEIPT)
|
||||
|
||||
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
|
||||
@@ -187,6 +243,26 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.panel(14)
|
||||
|
||||
def test_chunked_frontend_serves_only_owned_javascript(self):
|
||||
(self.folder/'dist/index.js').write_text(
|
||||
"import './chunks/view.js'; export default () => ({content: 'OK'});")
|
||||
chunks = self.folder/'dist/chunks'
|
||||
chunks.mkdir()
|
||||
(chunks/'view.js').write_text('export const title = "Settings";')
|
||||
page = framey_panel.panel(14).decode()
|
||||
self.assertIn('data-module="/framey/module/14/index.js"', page)
|
||||
self.assertEqual(framey_panel.local_module(14, 'chunks/view.js'),
|
||||
b'export const title = "Settings";')
|
||||
for name in ('../plugin.json', 'chunks/../../plugin.json',
|
||||
'chunks/view.js.map', 'other.js', 'chunks/%2e%2e/view.js'):
|
||||
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(14, name)
|
||||
(chunks/'linked.js').symlink_to(chunks/'view.js')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(14, 'chunks/linked.js')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
framey_panel.local_module(15, 'chunks/view.js')
|
||||
|
||||
def test_legacy_keyword_rpc_is_scoped_and_validated(self):
|
||||
import shlex
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
@@ -200,12 +276,12 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
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),
|
||||
'plugins':[{'storeId':14,'name':'Legacy','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True, RECEIPT),
|
||||
{'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)
|
||||
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy, RECEIPT)
|
||||
|
||||
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
|
||||
manifest = self.folder/'plugin.json'
|
||||
@@ -235,11 +311,15 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertNotIn('__FRAMEY_METHOD_PAYLOAD__',stdin)
|
||||
return json.dumps({'result':{'theme':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'ProtonDB Badges'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark']),
|
||||
'plugins':[{'storeId':14,'name':'ProtonDB Badges','sha256':RECEIPT}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark'],receipt=RECEIPT),
|
||||
{'result':{'theme':'dark'}})
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[])
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[])
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[],receipt=RECEIPT)
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[],receipt=RECEIPT)
|
||||
for stale in (None, 'b' * 64, 'invalid'):
|
||||
with self.subTest(stale=stale), self.assertRaisesRegex(framey_panel.PanelError, 'receipt|changed'):
|
||||
framey_panel.call_method(lambda *a, **k:self.fail('Stale receipt reached SSH'),
|
||||
14,'get_setting',[],receipt=stale)
|
||||
compile(framey_panel.METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__',repr('{}')), '<remote method>', 'exec')
|
||||
|
||||
def test_headset_bundle_contains_runtime_and_licenses(self):
|
||||
@@ -249,7 +329,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
with tarfile.open(fileobj=io.BytesIO(gzip.decompress(packed))) as archive:
|
||||
names = set(archive.getnames())
|
||||
self.assertTrue({'ui/framey_panel.py','ui/framey-host.js',
|
||||
'ui/framey-events.js',
|
||||
'ui/framey-events.js','ui/framey-running.js',
|
||||
'ui/vendor/react-18.3.1.min.js','ui/vendor/react-dom-18.3.1.min.js',
|
||||
'ui/vendor/REACT-LICENSE.txt','ui/vendor/REACT-DOM-LICENSE.txt'} <= names)
|
||||
|
||||
@@ -273,6 +353,15 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
self.assertIn(b'framey-bundle', body)
|
||||
self.assertEqual(get('/framey/panel/15')[0], 404)
|
||||
|
||||
(self.folder/'dist/chunks').mkdir()
|
||||
(self.folder/'dist/chunks/view.js').write_text('export const value = 1;')
|
||||
status, headers, body = get('/framey/module/14/chunks/view.js')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(headers['Access-Control-Allow-Origin'], 'null')
|
||||
self.assertEqual(body, b'export const value = 1;')
|
||||
self.assertEqual(get('/framey/module/14/chunks/../plugin.json')[0], 404)
|
||||
self.assertEqual(get('/framey/module/15/chunks/view.js')[0], 404)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -105,6 +105,9 @@ class Safety(unittest.TestCase):
|
||||
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)
|
||||
self.assertEqual(ctypes.sizeof(overlay.Scroll), 16)
|
||||
with self.assertRaises(ValueError):
|
||||
object.__new__(overlay.Overlay).post_scroll(0, -3, 308)
|
||||
original = check.frame_headset.bundle()
|
||||
patched = check.debug_bundle(original, '/tmp/owned-profile')
|
||||
import tarfile
|
||||
|
||||
@@ -76,6 +76,8 @@ class NativeContracts(unittest.TestCase):
|
||||
frameworks = str(Path(developer) / "Platforms/MacOSX.platform/Developer/Library/Frameworks")
|
||||
libraries = str(Path(developer) / "Platforms/MacOSX.platform/Developer/usr/lib")
|
||||
shared = str(Path(developer).parent / "SharedFrameworks")
|
||||
if not (Path(frameworks) / "XCTest.framework").is_dir():
|
||||
self.skipTest("Foundation XCTest contracts need full Xcode; Command Line Tools lack XCTest")
|
||||
result = subprocess.run(["swiftc", "-I", libraries, "-L", libraries, "-F", frameworks, "-Xlinker", "-rpath", "-Xlinker", frameworks,
|
||||
"-Xlinker", "-rpath", "-Xlinker", libraries,
|
||||
"-Xlinker", "-rpath", "-Xlinker", shared,
|
||||
|
||||
@@ -131,14 +131,14 @@ class ObsSourceTests(unittest.TestCase):
|
||||
wrapper = ("import importlib.util; "
|
||||
f"s=importlib.util.spec_from_file_location('obs', {str(SCRIPT)!r}); "
|
||||
"o=importlib.util.module_from_spec(s);s.loader.exec_module(o); "
|
||||
"o.STARTUP_TIMEOUT=o.STREAM_STALL=o.CHILD_STOP_TIMEOUT=0.15; "
|
||||
"o.STARTUP_TIMEOUT=5; o.STREAM_STALL=o.CHILD_STOP_TIMEOUT=0.15; "
|
||||
"o.main(['--frame','fake-frame','--control-stdin'])")
|
||||
env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', '')}
|
||||
process = subprocess.Popen([sys.executable, '-c', wrapper], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env)
|
||||
try:
|
||||
# Keep stdin OPEN: real Electron does this until the helper exits.
|
||||
self.assertEqual(process.wait(timeout=4), 1)
|
||||
self.assertEqual(process.wait(timeout=10), 1)
|
||||
error = process.stderr.read().decode(errors='replace')
|
||||
self.assertIn('video stopped', error)
|
||||
self.assertNotIn('Fatal Python error', error)
|
||||
@@ -209,6 +209,7 @@ class ObsSourceTests(unittest.TestCase):
|
||||
for mode in ('startup', 'stream'):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as folder:
|
||||
directory = Path(folder)
|
||||
ready = directory / 'ready'
|
||||
fake = directory / 'ssh'
|
||||
fake.write_text("#!/usr/bin/env python3\n"
|
||||
"import os, signal, sys, time\n"
|
||||
@@ -219,12 +220,29 @@ class ObsSourceTests(unittest.TestCase):
|
||||
"if os.environ['FAKE_SSH_MODE'] == 'stream':\n"
|
||||
" sys.stdout.buffer.write(b'G' * 1316)\n"
|
||||
" sys.stdout.buffer.flush()\n"
|
||||
"open(os.environ['FAKE_SSH_READY'], 'w').close()\n"
|
||||
"while True: time.sleep(1)\n")
|
||||
fake.chmod(0o755)
|
||||
stopped = directory / 'stopped'
|
||||
env = {'PATH': str(directory) + os.pathsep + os.environ.get('PATH', ''),
|
||||
'FAKE_SSH_STOPPED': str(stopped), 'FAKE_SSH_MODE': mode}
|
||||
'FAKE_SSH_STOPPED': str(stopped), 'FAKE_SSH_MODE': mode,
|
||||
'FAKE_SSH_READY': str(ready)}
|
||||
real_popen = subprocess.Popen
|
||||
|
||||
def start_ready(*args, **kwargs):
|
||||
proc = real_popen(*args, **kwargs)
|
||||
# Start the short watchdog clock after the stand-in installed
|
||||
# its handler, independent of native interpreter launch time.
|
||||
end = time.monotonic() + 5
|
||||
while not ready.exists() and proc.poll() is None and time.monotonic() < end:
|
||||
time.sleep(0.01)
|
||||
if not ready.exists():
|
||||
obs.stop_child(proc)
|
||||
self.fail('Stand-in SSH did not finish startup')
|
||||
return proc
|
||||
|
||||
with mock.patch.dict(os.environ, env), \
|
||||
mock.patch.object(obs.subprocess, 'Popen', side_effect=start_ready), \
|
||||
mock.patch.object(obs, 'STARTUP_TIMEOUT', 0.3), \
|
||||
mock.patch.object(obs, 'STREAM_STALL', 0.3):
|
||||
with self.assertRaisesRegex(RuntimeError, 'no OBS video' if mode == 'startup' else 'video stopped'):
|
||||
|
||||
@@ -95,6 +95,60 @@ class RealProcesses(unittest.TestCase):
|
||||
|
||||
|
||||
class Safety(unittest.TestCase):
|
||||
def test_missing_x86_runtime_skips_launch_but_still_removes_title(self):
|
||||
smoke = frame_smoke.Smoke()
|
||||
gid = 'fc_smoke_x86_64'
|
||||
with mock.patch.object(frame_smoke.tiny_programs, 'write', return_value=Path('/tmp/tiny')), \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'install', return_value={
|
||||
'runtime': 'SteamLinuxRuntime_4'}), \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'list_titles', side_effect=[
|
||||
[{'id': gid}], []]), \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'launch') as launch, \
|
||||
mock.patch.object(frame_smoke.frame_titles, 'remove') as remove, \
|
||||
mock.patch.object(frame_smoke, 'steam_log_lines', side_effect=[
|
||||
[], [], [], ['Tool 4183110 Steam Linux Runtime 4.0 is found, but is not installed']]), \
|
||||
mock.patch.object(frame_smoke, 'ssh', return_value=''), \
|
||||
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': []}):
|
||||
frame_smoke.title_cycle(smoke, 'x86_64', '/tmp')
|
||||
self.assertEqual([step['step'] for step in smoke.steps],
|
||||
['x86_64: install', 'x86_64: launch', 'x86_64: remove'])
|
||||
self.assertTrue(smoke.steps[0]['ok'])
|
||||
self.assertFalse(smoke.steps[1]['ok'])
|
||||
self.assertTrue(smoke.steps[1]['skipped'])
|
||||
self.assertEqual(smoke.steps[1]['detail']['outcome'], 'runtime missing')
|
||||
self.assertTrue(smoke.steps[2]['ok'])
|
||||
self.assertNotIn(gid, smoke.installed)
|
||||
launch.assert_called_once_with(gid)
|
||||
remove.assert_called_once_with(gid)
|
||||
|
||||
def test_optional_runtime_skip_is_counted_separately_in_saved_result(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
output = Path(root) / 'results'
|
||||
folder = Path(root) / 'titles'
|
||||
folder.mkdir()
|
||||
def cycle(smoke, kind, _folder):
|
||||
if kind == 'x86_64':
|
||||
def missing():
|
||||
raise frame_smoke.Skipped('optional runtime absent', {'outcome': 'runtime missing'})
|
||||
smoke.step(f'{kind}: launch', missing)
|
||||
else:
|
||||
smoke.step(f'{kind}: launch', lambda: {'outcome': 'running'})
|
||||
with mock.patch.object(frame_smoke, 'RESULTS', output), \
|
||||
mock.patch.object(frame_smoke, 'os_release', return_value={
|
||||
'BUILD_ID': 'fake', 'VERSION_ID': 'fake', 'VARIANT_ID': 'fake'}), \
|
||||
mock.patch.object(frame_smoke, 'ssh', return_value='yes'), \
|
||||
mock.patch.object(frame_smoke, 'cleanup', return_value={}), \
|
||||
mock.patch.object(frame_smoke, 'properties', return_value={}), \
|
||||
mock.patch.object(frame_smoke, 'status', return_value={}), \
|
||||
mock.patch.object(frame_smoke, 'title_cycle', side_effect=cycle), \
|
||||
mock.patch.object(frame_smoke.tempfile, 'mkdtemp', return_value=str(folder)):
|
||||
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
|
||||
{'activity': 3, 'proximity': [], 'battery': [100]}), 0)
|
||||
result = json.loads(next(output.glob('*-fake.json')).read_text())
|
||||
self.assertEqual((result['passed'], result['skipped'], result['failed']), (6, 1, 0))
|
||||
self.assertEqual(next(s for s in result['steps'] if s['step'] == 'x86_64: launch')
|
||||
['detail']['outcome'], 'runtime missing')
|
||||
|
||||
def test_non_test_ids_rejected_before_scanning(self):
|
||||
for ids in ([], ['portal2'], ['fc_smoke_exe/../portal2'], ['fc_smoke_custom']):
|
||||
with self.subTest(ids=ids), self.assertRaises(ValueError):
|
||||
|
||||
+4
-3
@@ -529,9 +529,10 @@ class PanelsAsAFreshProcess(unittest.TestCase):
|
||||
self.assertNotIn("why", out)
|
||||
|
||||
def test_slow_x_tools_stay_inside_the_servers_timeout(self):
|
||||
# Review of #83: X tools answering in 4.6 s each would take well over the server's
|
||||
# 20 s; one deadline for the run (PANELS_DEADLINE, 12 s) returns in time, saying why.
|
||||
out, took = self.run_panels(4.6)
|
||||
# Several 3 s calls exceed the whole-run deadline before a panel is complete.
|
||||
# Keep clear of each tool's 5 s timeout, including process startup on Intel Macs.
|
||||
# One deadline (12 s) must return inside the server's 20 s timeout, saying why.
|
||||
out, took = self.run_panels(3)
|
||||
self.assertLess(took, 15, out)
|
||||
self.assertEqual(out["panels"], [])
|
||||
self.assertIn("took too long", out["why"])
|
||||
|
||||
+139
-37
@@ -580,7 +580,7 @@
|
||||
"https://github.com/wynn1212/SDH-PauseGames.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Settings render locally with inert system-key subscriptions and empty detached Steam context. Live-game discovery, process pause/resume and suspend integration are unsupported/unverified; no backend actions ran.",
|
||||
"reason": "Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -613,6 +613,11 @@
|
||||
"sha256": "4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -793,7 +798,7 @@
|
||||
"https://github.com/SteamDeckHomebrew/decky-loader"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "This plugin needs root and is not supported by the Frame user service. Frontend audit failed: Cannot read properties of null (reading 'm_GamepadUIAudioStore').",
|
||||
"reason": "This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel.",
|
||||
"blockInstall": true,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -804,7 +809,7 @@
|
||||
"id": 16,
|
||||
"name": "Audio Loader",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of null (reading 'm_GamepadUIAudioStore')",
|
||||
"error": "Cannot read properties of undefined (reading 'm_AudioPlaybackManager')",
|
||||
"logs": [
|
||||
{
|
||||
"kind": "warn",
|
||||
@@ -1006,7 +1011,7 @@
|
||||
"https://github.com/popsUlfr/SDH-Notebook.git"
|
||||
],
|
||||
"status": "works",
|
||||
"reason": "Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Per-game auto-selection and background artwork unavailable; worn-VR input unverified.",
|
||||
"reason": "Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Running-game context is now supplied; per-game selection was attempted with a synthetic game and backend calls denied. Real per-game selection, background artwork and worn-VR input remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -1080,6 +1085,11 @@
|
||||
"existingNotesTouched": false
|
||||
},
|
||||
"method": "local Chromium + real Frame SSH; private actual Decky 3.3.0 ARM64 service"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2784,7 +2794,7 @@
|
||||
"https://github.com/SteamGridDB/decky-steamgriddb"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Frontend audit failed: DFL.createModuleMapping is not a function.",
|
||||
"reason": "Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -2795,7 +2805,7 @@
|
||||
"id": 36,
|
||||
"name": "SteamGridDB",
|
||||
"ready": false,
|
||||
"error": "DFL.createModuleMapping is not a function",
|
||||
"error": "Cannot convert undefined or null to object",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
},
|
||||
@@ -2870,8 +2880,8 @@
|
||||
"https://github.com/andrewburgess/steamdeck-discord-status",
|
||||
"https://github.com/andrewburgess/steamdeck-discord-status.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Detached app overviews are now available, but RunningApps/lifetime observations and Steam artwork helpers still lack a live mapping. Discord activity integration remains unavailable/unverified.",
|
||||
"status": "partial",
|
||||
"reason": "Disconnected panel renders; running-activity construction accepts a synthetic game in local Chromium. Exact official is_connected returned false in real Decky 3.3.0 ARM64. Empty live running-list reads and lifetime registration/cleanup passed. Real game transitions, Discord shortcut detection/connection/activity, local custom artwork and worn VR remain unverified/unsupported.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -2917,6 +2927,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2990,14 +3005,15 @@
|
||||
"https://github.com/ldo/dbussy",
|
||||
"https://github.com/mk-fg/python-pulse-control"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Local Chromium discovers and changes a private silent stream through the actual Decky 3.3.0 Linux backend, with independent Pulse readback. Frame ARM64 libraries/audio session and Bluetooth playback controls remain unverified.",
|
||||
"status": "works",
|
||||
"reason": "Per-app stream discovery and slider/keyboard volume changes pass in local Chromium through real Decky 3.3.0 ARM64 on the Frame audio session, with independent Pulse readback of a silent test stream. Bluetooth playback, audible output and worn-VR controls unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame",
|
||||
"isolated Linux backend"
|
||||
"isolated Linux backend",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 38,
|
||||
@@ -3403,6 +3419,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3984,6 +4005,11 @@
|
||||
"sha256": "71dc21ce5f9f4ee0573bd927e9b444d45dc47327888ca78ed063c7bcc57bfac1",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4030,13 +4056,14 @@
|
||||
"https://github.com/saumya-banthia/volume-boost.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Local Chromium sync/separate sliders, numeric volume entry and cache reopen pass through the actual Decky 3.3.0 backend on a private virtual stereo sink. Frame pactl/default sink, boost above 100% and physical audio remain unverified.",
|
||||
"reason": "Real Decky 3.3.0 ARM64 default-sink reads match independent Frame pactl channel volumes; controls render locally. Sliders/numeric writes passed on an isolated amd64 virtual sink only. Real default-sink writes, boost above 100%, audible output and worn VR unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame",
|
||||
"isolated Linux backend"
|
||||
"isolated Linux backend",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 58,
|
||||
@@ -4403,8 +4430,8 @@
|
||||
"https://github.com/Tormak9970/TabMaster",
|
||||
"https://github.com/Tormak9970/TabMaster.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio').",
|
||||
"status": "partial",
|
||||
"reason": "Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -4414,10 +4441,17 @@
|
||||
"browser": {
|
||||
"id": 61,
|
||||
"name": "TabMaster",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of null (reading 'GamepadUIAudio')",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c TabMaster %c ERROR %c background: #ff6d05; color: black; background: #c70808; color: black; background: transparent; Could not find mobx.reaction"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"log_message"
|
||||
]
|
||||
},
|
||||
"archiveValidation": {
|
||||
"id": 61,
|
||||
@@ -4438,6 +4472,11 @@
|
||||
"sha256": "e08333859a32b0c177b8e9a7959c5eeeafe38f946578f7f1d161c721f0e05da5",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4955,7 +4994,7 @@
|
||||
"https://github.com/your-username/PlayTime"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. ARM64 backend, live MobX/running-game observations and automatic recording remain unverified/unsupported.",
|
||||
"reason": "Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -5007,6 +5046,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -6462,7 +6506,7 @@
|
||||
"https://github.com/ebenbruyns/junkstore.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Route registration succeeds; rendering fails on a missing Steam ContainerBackground component.",
|
||||
"reason": "Local CSS lookup fixes the loading view crash. Actual Decky 3.3.0 amd64 startup/static InitActions return a script execution/JSON error; the published scripts are not executable after extraction. Store install/login/game launch and ARM64 remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -6474,14 +6518,10 @@
|
||||
"name": "Junk-Store",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "TypeError: Cannot read properties of null (reading 'ContainerBackground')"
|
||||
}
|
||||
],
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"fetch_rss_feed"
|
||||
"fetch_rss_feed",
|
||||
"execute_action"
|
||||
]
|
||||
},
|
||||
"archiveValidation": {
|
||||
@@ -6915,6 +6955,11 @@
|
||||
"sha256": "2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7284,6 +7329,11 @@
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7714,6 +7764,11 @@
|
||||
"sha256": "49309a02fa028d26c77230cc9f70fec9bcce91d59e887bfe8f9d3e42f8a19139",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7901,6 +7956,11 @@
|
||||
"sha256": "b6c514d147cc46bad5331ca9da36628eb9c330976e13735687499a4a9f17f6d4",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -8473,7 +8533,7 @@
|
||||
"https://github.com/jessebofill/DeckSP.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio').",
|
||||
"reason": "Optional UI-audio lookup succeeds, but the published frontend raises \"Cannot access PluginManager before initialization\" when Steam MobX reaction lookup is unavailable. JamesDSP installation/audio routing and ARM64 remain unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -8484,7 +8544,7 @@
|
||||
"id": 102,
|
||||
"name": "DeckSP",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of null (reading 'GamepadUIAudio')",
|
||||
"error": "Cannot access 'PluginManager' before initialization",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
},
|
||||
@@ -8602,6 +8662,11 @@
|
||||
"sha256": "8a69f45aadce763406b5c27bfef194761006d5e01c5e8841badeb13a27dd0d88",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -8711,6 +8776,11 @@
|
||||
"sha256": "65e021ebb37d2ebfbfbe3dbc8af95aef9556cf484730362c6b26c4ecc3e62ccd",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -9705,6 +9775,11 @@
|
||||
"sha256": "ec59fc8aceb4e1abe2066b3a0707ed6313cabc4421b7c17765397903e2f8fd26",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -10840,6 +10915,11 @@
|
||||
"sha256": "17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -11202,6 +11282,11 @@
|
||||
"sha256": "3def99993c412b669933376b6420e44c68cd1fe9d9baf14b77a1f231242bd7e8",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -11686,6 +11771,11 @@
|
||||
"sha256": "248294b5d6b9d5a5d635ce35f4b5185ce59d4e4fd59353e24df4b29d74c2ade4",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -11886,9 +11976,9 @@
|
||||
"archiveBytes": 862269,
|
||||
"api": 1,
|
||||
"flags": [],
|
||||
"modernBridge": false,
|
||||
"modernBridge": true,
|
||||
"frontend": true,
|
||||
"frontendApi": "unknown",
|
||||
"frontendApi": "1",
|
||||
"pythonFiles": [
|
||||
"Deck-Shelves/main.py"
|
||||
],
|
||||
@@ -11916,8 +12006,8 @@
|
||||
"https://github.com/santojon/Deck-Shelves.git",
|
||||
"https://github.com/sponsors/santojon"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Frontend audit failed: Failed to resolve module specifier \"./chunks/pluginApi-B8sV7970.js\". Invalid relative url or base scheme isn't hierarchical..",
|
||||
"status": "partial",
|
||||
"reason": "The exact chunked frontend now initializes and renders Deck Shelves settings in an opaque local Chromium panel. Backend calls were denied; Home-screen shelf injection has no verified Steam UI target in Framey. Settings writes, real backend behavior and worn VR are unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -11951,6 +12041,12 @@
|
||||
"sha256": "b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"chunkedFollowUp": {
|
||||
"evidence": "docs/evidence/framey-deck-shelves-browser-2026-10-26.md",
|
||||
"mode": "exact archive in opaque local Chromium; backend and network denied",
|
||||
"frontendReady": true,
|
||||
"settingsRendered": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -13217,6 +13313,11 @@
|
||||
"sha256": "33fb674993c9b2bd119f0c6118c465e379f166ec1539c8183fcf7580f1d89577",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"runningFollowUp": {
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -13263,14 +13364,15 @@
|
||||
"https://github.com/allmazz/decky-volume-mixer",
|
||||
"https://github.com/allmazz/decky-volume-mixer.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Local Chromium numeric stream volume and filter save/reopen pass through the actual Decky 3.3.0 Linux backend; filters survive its restart. Frame pw-dump/wpctl, real audio session and worn VR remain unverified.",
|
||||
"status": "works",
|
||||
"reason": "Per-app discovery and numeric volume changes pass in local Chromium through real Decky 3.3.0 ARM64 on the Frame audio session, independently read back on a silent test stream. Filters persist on amd64; ARM64 filter persistence, Reset, internal audio nodes, audible output and worn VR unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame",
|
||||
"isolated Linux backend"
|
||||
"isolated Linux backend",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 144,
|
||||
|
||||
+185
-25
@@ -15,7 +15,14 @@
|
||||
// user identity or suspend state in this isolated document.
|
||||
for (const name of ['App','appStore','appInfoStore','collectionStore']) window[name] = undefined;
|
||||
window.SuspendResumeStore = Object.freeze({});
|
||||
window.SteamUIStore = Object.freeze({RunningApps:Object.freeze([]), MainRunningApp:undefined});
|
||||
let runningApps = Object.freeze([]), mainRunningApp, runningAvailable = false;
|
||||
let runningArtwork = new Map();
|
||||
const runningListeners = new Set();
|
||||
let runningCursor = 0, runningRequest = null, runningTimer = null, runningClosed = false;
|
||||
window.SteamUIStore = Object.freeze({
|
||||
get RunningApps() {return runningApps;}, get MainRunningApp() {return mainRunningApp;},
|
||||
get FrameyRunningAvailable() {return runningAvailable;},
|
||||
});
|
||||
// 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'],
|
||||
@@ -288,6 +295,50 @@
|
||||
R.createElement('button', {type:'button',onClick:closeModal}, 'Close'), element));
|
||||
return {Close:closeModal};
|
||||
};
|
||||
// These are Framey's own styles and components, never Steam's webpack cache.
|
||||
// Keep the registry finite: an unknown Steam implementation stays unavailable.
|
||||
const classModules = new Map(Object.entries({
|
||||
'framey/spinner': {SpinnerLoaderContainer:'framey-spinner', ContainerBackground:'framey-loading',
|
||||
LoadingStatus:'framey-status', Medium:'framey-medium'},
|
||||
'framey/footer': {QuickAccessFooter:'framey-footer', BasicFooter:'framey-footer'},
|
||||
'framey/app-details': {AppDetailSectionList:'framey-sections', AppActionButton:'framey-action'},
|
||||
'framey/play-button': {PlayButtonContainer:'framey-actions', ButtonChild:'framey-button-child',
|
||||
Green:'framey-green', PlayButton:'framey-action'},
|
||||
'framey/library-image': {LongTitles:'framey-long-title', GreyBackground:'framey-muted', Image:'framey-image'},
|
||||
'framey/library': {GamepadLibrary:'framey-library'},
|
||||
'framey/focus': {FocusRingRoot:'framey-focus', FocusRing:'framey-focus'},
|
||||
}).map(([id, classes]) => [id, Object.freeze(classes)]));
|
||||
const localModules = new Map(classModules);
|
||||
const moduleVariants = module => [module?.default, module].filter(value => value != null);
|
||||
function findModule(filter) {
|
||||
for (const module of localModules.values())
|
||||
for (const value of moduleVariants(module)) if (filter(value)) return value;
|
||||
}
|
||||
function findModuleDetailsByExport(filter, minExports) {
|
||||
for (const [id, module] of localModules)
|
||||
for (const value of moduleVariants(module)) {
|
||||
if (typeof value !== 'object' || (minExports && Object.keys(value).length < minExports)) continue;
|
||||
for (const name in value) {
|
||||
if (!value[name]) continue;
|
||||
// DFL tolerates filters that dereference an incompatible export.
|
||||
try { if (filter(value[name], name)) return [value, value[name], name, id]; } catch (_) {}
|
||||
}
|
||||
}
|
||||
return [undefined, undefined, undefined, undefined];
|
||||
}
|
||||
function findModuleChild(filter) {
|
||||
for (const module of localModules.values())
|
||||
for (const value of moduleVariants(module)) {
|
||||
const result = filter(value);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
function createModuleMapping(filter) {
|
||||
const mapping = new Map();
|
||||
for (const [id, module] of localModules)
|
||||
for (const value of moduleVariants(module)) if (filter(value)) mapping.set(id, value);
|
||||
return mapping;
|
||||
}
|
||||
const DFL = window.DFL = {
|
||||
definePlugin: init => init,
|
||||
PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem,
|
||||
@@ -301,6 +352,7 @@
|
||||
DialogHeader:passthrough('h2'), DialogSubHeader:passthrough('h3'), DialogFooter:passthrough('footer'),
|
||||
DialogLabel:passthrough('label'), DialogControlsSection:passthrough('section'),
|
||||
DialogControlsSectionHeader:passthrough('h3'), ModalRoot:passthrough('section'),
|
||||
ModalPosition:passthrough('div'),
|
||||
Menu:passthrough('div'), MenuGroup:passthrough('section'), MenuItem,
|
||||
ProgressBar, ProgressBarWithInfo:ProgressBar,
|
||||
gamepadDialogClasses:{}, quickAccessMenuClasses:{}, gamepadTabbedPageClasses:{},
|
||||
@@ -314,14 +366,32 @@
|
||||
showModal, showContextMenu:showModal,
|
||||
// Steam app, library, and store patches have no target inside this panel.
|
||||
afterPatch:noPatch, beforePatch:noPatch, replacePatch:noPatch, createReactTreePatcher:noPatch,
|
||||
findInReactTree: () => null, findModuleExport: () => null, findModuleChild: () => null,
|
||||
findModule:()=>null, findModuleByExport:()=>null, findClassModule:()=>null,
|
||||
findClass:()=>null, findSP:()=>null, fakeRenderComponent:()=>null,
|
||||
findInReactTree: () => null, findModule, findModuleChild, findModuleDetailsByExport,
|
||||
findModuleByExport:(filter, minExports)=>findModuleDetailsByExport(filter,minExports)[0],
|
||||
findModuleExport:(filter, minExports)=>findModuleDetailsByExport(filter,minExports)[1],
|
||||
createModuleMapping, findAllModules:filter=>[...createModuleMapping(filter).values()],
|
||||
findClassModule:filter=>[...classModules.values()].find(filter),
|
||||
findClass:(id,name)=>classModules.get(id)?.[name],
|
||||
findClassByName:name=>[...classModules.values()].find(classes=>classes[name])?.[name],
|
||||
unminifyClass:token=>{
|
||||
for(const classes of classModules.values())
|
||||
for(const name of Object.keys(classes)) if(classes[name]===token)return name;
|
||||
},
|
||||
findSP:()=>null, fakeRenderComponent:()=>null,
|
||||
appDetailsClasses: {}, appDetailsHeaderClasses: {},
|
||||
useParams: () => currentParams,
|
||||
};
|
||||
// No Steam game is attached to an isolated panel. Subscriptions have a
|
||||
// disposable handle but emit no fabricated game, power, or input events.
|
||||
localModules.set('framey/components', Object.freeze(Object.fromEntries(
|
||||
Object.entries(DFL).filter(([name,value])=>typeof value==='function' && /^[A-Z]/.test(name)))));
|
||||
// Some panels eagerly look up the optional Steam UI sound player. Allow the
|
||||
// lookup, but never claim playback or expose Steam's mutable audio store.
|
||||
localModules.set('framey/ui-audio', Object.freeze({UIAudio:Object.freeze({
|
||||
GamepadUIAudio:Object.freeze({available:false, AudioPlaybackManager:Object.freeze({
|
||||
PlayAudioURL:()=>{throw Error('Steam UI sound playback is not supported in Framey');},
|
||||
})}),
|
||||
})}));
|
||||
// Unsupported subscriptions remain inert. Lifetime callbacks below relay
|
||||
// actual Steam notifications; no power, input or launch events are invented.
|
||||
const subscription = () => {const dispose=()=>{};dispose.unregister=dispose;return dispose;};
|
||||
const register = names => Object.fromEntries(names.map(name=>[name,subscription]));
|
||||
const steamLanguage = () => {
|
||||
@@ -340,7 +410,14 @@
|
||||
...register(['RegisterForSettingsChanges'])},
|
||||
Apps:register(['RegisterForAppDetails','RegisterForGameActionTaskChange',
|
||||
'RegisterForGameActionStart','RegisterForGameActionEnd','RegisterForGameActionUserRequest']),
|
||||
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
|
||||
GameSessions:{...register(['RegisterForScreenshotNotification']),
|
||||
RegisterForAppLifetimeNotifications:listener=>{
|
||||
if (typeof listener !== 'function') throw Error('Invalid Steam lifetime listener');
|
||||
if (runningListeners.size >= 32) throw Error('Too many Steam lifetime listeners');
|
||||
runningListeners.add(listener);
|
||||
const dispose=()=>runningListeners.delete(listener);
|
||||
dispose.unregister=dispose; return dispose;
|
||||
}},
|
||||
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
|
||||
GetSystemInfo:()=>request('GetSystemInfo',[],false,'framey:steam'),
|
||||
UI:register(['RegisterForFocusChangeEvents','RegisterForSystemKeyEvents'])},
|
||||
@@ -365,7 +442,9 @@
|
||||
},
|
||||
},
|
||||
};
|
||||
DFL.Router = {...DFL.Navigation, ...window.SteamUIStore};
|
||||
DFL.Router = {...DFL.Navigation,
|
||||
get RunningApps() {return runningApps;}, get MainRunningApp() {return mainRunningApp;},
|
||||
};
|
||||
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
|
||||
const toaster = {toast: ({title, body, subtext, icon, logo, duration, showToast=true, onClick}={}) => {
|
||||
const data = {title, body, subtext, icon, logo};
|
||||
@@ -419,23 +498,27 @@
|
||||
const [route, setRoute] = R.useState(currentRoute);
|
||||
R.useEffect(() => {routeListeners.add(setRoute); return () => routeListeners.delete(setRoute);}, []);
|
||||
const [running, setRunning] = R.useState(null);
|
||||
const runningRef = R.useRef(false);
|
||||
const [message, setMessage] = R.useState('');
|
||||
const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions.slice(0, 8)
|
||||
const actions = Array.isArray(plugin.frameyQuickActions) ? plugin.frameyQuickActions
|
||||
.filter(action => action && typeof action.label === 'string' && action.label.length <= 80 &&
|
||||
typeof action.onClick === 'function') : [];
|
||||
typeof action.onClick === 'function').slice(0, 8) : [];
|
||||
async function runAction(action, index) {
|
||||
if (running !== null) return;
|
||||
// React state may not render between two controller clicks. Lock before
|
||||
// awaiting a backend action so one tap cannot start a second mutation.
|
||||
if (runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
setRunning(index); setMessage(`${action.label}…`);
|
||||
try {
|
||||
const result = await action.onClick();
|
||||
setMessage(typeof result === 'string' && result.length < 200 ? result : `${action.label} done`);
|
||||
} catch (failure) { setMessage(`${action.label} failed: ${failure.message || failure}`); }
|
||||
finally { setRunning(null); }
|
||||
finally { runningRef.current = false; setRunning(null); }
|
||||
}
|
||||
return R.createElement(R.Fragment, null,
|
||||
plugin.titleView && R.createElement('header', null, plugin.titleView),
|
||||
route && R.createElement('button', {type:'button', onClick:()=>navigate(null)}, 'Back to plugin'),
|
||||
plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'},
|
||||
plugin.content && plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'},
|
||||
R.createElement('button', {type:'button', 'aria-current':view === 'panel' ? 'page' : undefined,
|
||||
onClick:()=>setView('panel')}, 'Panel'),
|
||||
R.createElement('button', {type:'button', 'aria-current':view === 'settings' ? 'page' : undefined,
|
||||
@@ -474,14 +557,9 @@
|
||||
},
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
};
|
||||
function setLibrarySnapshot(snapshot) {
|
||||
// These are detached plugin-local values. Patching an overview or its Map
|
||||
// must never mutate Steam's library. Live MobX observations are unsupported.
|
||||
if (!snapshot || !Array.isArray(snapshot.apps) || snapshot.apps.length > 10000 ||
|
||||
!Array.isArray(snapshot.gameIds) || snapshot.gameIds.length > 10000 ||
|
||||
(snapshot.steamId != null && (typeof snapshot.steamId !== 'string' || !/^\d{17}$/.test(snapshot.steamId))))
|
||||
throw Error('Invalid Steam library snapshot');
|
||||
const apps = snapshot.apps.map(row => {
|
||||
function projectApps(rows, limit) {
|
||||
if (!Array.isArray(rows) || rows.length > limit) throw Error('Invalid Steam app overview');
|
||||
return rows.map(row => {
|
||||
if (!Array.isArray(row) || row.length !== 8 || !Number.isSafeInteger(row[0]) || row[0] <= 0 ||
|
||||
typeof row[1] !== 'string' || row[1].length > 2048 ||
|
||||
(row[2] != null && typeof row[2] !== 'string') || !Number.isSafeInteger(row[3]) ||
|
||||
@@ -493,20 +571,95 @@
|
||||
return {appid,display_name,sort_as,app_type,gameid,
|
||||
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played};
|
||||
});
|
||||
}
|
||||
function setLibrarySnapshot(snapshot) {
|
||||
// These are detached plugin-local values. Patching an overview or its Map
|
||||
// must never mutate Steam's library. Live MobX observations are unsupported.
|
||||
if (!snapshot || !Array.isArray(snapshot.apps) || snapshot.apps.length > 10000 ||
|
||||
!Array.isArray(snapshot.gameIds) || snapshot.gameIds.length > 10000 ||
|
||||
(snapshot.steamId != null && (typeof snapshot.steamId !== 'string' || !/^\d{17}$/.test(snapshot.steamId))))
|
||||
throw Error('Invalid Steam library snapshot');
|
||||
const apps = projectApps(snapshot.apps, 10000);
|
||||
const map = new Map(apps.map(app=>[app.appid,app]));
|
||||
if (map.size !== apps.length || snapshot.gameIds.some(id=>!Number.isSafeInteger(id) || !map.has(id)))
|
||||
throw Error('Invalid Steam game collection');
|
||||
window.appStore = {allApps:apps, m_mapApps:map,
|
||||
GetAppOverviewByAppID:id=>map.get(Number(id)),
|
||||
GetAppOverviewByGameID:id=>apps.find(app=>app.gameid === String(id)),
|
||||
GetVerticalCapsuleURLForApp:app=>runningArtwork.get(Number(app?.appid))?.[0] || '',
|
||||
GetCustomVerticalCapsuleURLs:app=>[...(runningArtwork.get(Number(app?.appid))?.[1] || [])],
|
||||
};
|
||||
window.collectionStore = {allGamesCollection:{allApps:snapshot.gameIds.map(id=>map.get(id))}};
|
||||
window.App = snapshot.steamId ? {m_CurrentUser:{strSteamID:snapshot.steamId}} : undefined;
|
||||
}
|
||||
function setRunningSnapshot(data) {
|
||||
if (!data || !Array.isArray(data.runningIds) || data.runningIds.length > 64 ||
|
||||
!Array.isArray(data.events) || data.events.length > 64 ||
|
||||
!Array.isArray(data.artwork) || data.artwork.length > 65 ||
|
||||
!Number.isSafeInteger(data.cursor) || data.cursor < 0 || typeof data.lifetimeAvailable !== 'boolean')
|
||||
throw Error('Invalid Steam running snapshot');
|
||||
const apps = projectApps(data.apps, 65), map = new Map(apps.map(app=>[app.appid,app]));
|
||||
if (map.size !== apps.length || new Set(data.runningIds).size !== data.runningIds.length ||
|
||||
data.runningIds.some(id=>!Number.isSafeInteger(id) || !map.has(id)) ||
|
||||
(data.mainAppId !== null && (!Number.isSafeInteger(data.mainAppId) || !map.has(data.mainAppId))))
|
||||
throw Error('Invalid Steam running apps');
|
||||
let sequence = 0;
|
||||
const publicArt = value => typeof value === 'string' && value.length <= 2048 &&
|
||||
/^https:\/\/(?:[a-z0-9-]+\.)*steamstatic\.com\/steam\/apps\/[^?#\s\\]+$/.test(value);
|
||||
const artwork = new Map();
|
||||
for (const row of data.artwork) {
|
||||
if (!Array.isArray(row) || row.length !== 3 || !map.has(row[0]) || artwork.has(row[0]) ||
|
||||
(row[1] !== null && !publicArt(row[1])) || !Array.isArray(row[2]) || row[2].length > 8 ||
|
||||
row[2].some(url=>!publicArt(url))) throw Error('Invalid Steam public artwork');
|
||||
artwork.set(row[0],[row[1],[...row[2]]]);
|
||||
}
|
||||
for (const event of data.events) {
|
||||
if (!Number.isSafeInteger(event?.sequence) || event.sequence <= sequence || event.sequence > data.cursor ||
|
||||
!Number.isSafeInteger(event.unAppID) || event.unAppID <= 0 || typeof event.bRunning !== 'boolean')
|
||||
throw Error('Invalid Steam lifetime event');
|
||||
sequence = event.sequence;
|
||||
}
|
||||
// Update detached stores before callbacks read Router.RunningApps.
|
||||
runningApps = Object.freeze(data.runningIds.map(id=>map.get(id)));
|
||||
mainRunningApp = data.mainAppId === null ? undefined : map.get(data.mainAppId);
|
||||
runningAvailable = true;
|
||||
runningArtwork = artwork;
|
||||
if (window.appStore) for (const app of apps) {
|
||||
const existing = window.appStore.m_mapApps.get(app.appid);
|
||||
if (existing) Object.assign(existing,app);
|
||||
else {window.appStore.m_mapApps.set(app.appid,app);window.appStore.allApps.push(app);}
|
||||
}
|
||||
const previous = runningCursor;
|
||||
runningCursor = data.cursor;
|
||||
for (const event of data.events) if (event.sequence > previous)
|
||||
for (const listener of runningListeners)
|
||||
Promise.resolve().then(()=>runningListeners.has(listener) && listener({unAppID:event.unAppID,bRunning:event.bRunning}))
|
||||
.catch(failure=>console.warn('Steam lifetime listener failed',failure.message));
|
||||
}
|
||||
function refreshRunning() {
|
||||
if (runningClosed) return Promise.resolve();
|
||||
if (runningRequest) return runningRequest;
|
||||
runningRequest = request('running',[runningCursor],false,'framey:running')
|
||||
.then(data=>{if (!runningClosed) setRunningSnapshot(data);})
|
||||
.catch(failure=>{
|
||||
runningAvailable = false;
|
||||
runningApps = Object.freeze([]); mainRunningApp = undefined; runningArtwork = new Map();
|
||||
console.warn('Framey Steam running context unavailable',failure.message);
|
||||
}).finally(()=>{
|
||||
runningRequest = null;
|
||||
if (!runningClosed) runningTimer = setTimeout(refreshRunning,2000);
|
||||
});
|
||||
return runningRequest;
|
||||
}
|
||||
addEventListener('pagehide',()=>{
|
||||
runningClosed = true; clearTimeout(runningTimer); runningListeners.clear();
|
||||
parent.postMessage({type:'framey:running:close',session:cacheSession},'*');
|
||||
});
|
||||
async function start() {
|
||||
try {
|
||||
await seeded;
|
||||
const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0));
|
||||
const bundleNode = document.getElementById('framey-bundle');
|
||||
const bytes = Uint8Array.from(atob(bundleNode.textContent), c=>c.charCodeAt(0));
|
||||
const bundleText = new TextDecoder().decode(bytes);
|
||||
// Only panels referencing library globals need the extra read. A failed
|
||||
// read leaves them unavailable rather than fabricating an empty library.
|
||||
@@ -514,13 +667,20 @@
|
||||
try {setLibrarySnapshot(await request('GetLibrarySnapshot',[],false,'framey:steam'));}
|
||||
catch (failure) {console.warn('Framey Steam library unavailable', failure.message);}
|
||||
}
|
||||
if (/\b(?:RunningApps|MainRunningApp|RegisterForAppLifetimeNotifications)\b/.test(bundleText))
|
||||
await refreshRunning();
|
||||
// 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(bundleText);
|
||||
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 (bundleNode.dataset.module && !legacy) {
|
||||
// A hierarchical URL lets official Vite chunks resolve relative imports.
|
||||
module = await import(new URL(bundleNode.dataset.module, location.href).href);
|
||||
} else {
|
||||
const url = URL.createObjectURL(new Blob(legacy ?
|
||||
['const __frameyLegacy =\n', bytes, '\nexport default __frameyLegacy;'] : [bytes], {type:'text/javascript'}));
|
||||
try { module = await import(url); } finally { URL.revokeObjectURL(url); }
|
||||
}
|
||||
if (typeof module.default !== 'function') throw Error('The Decky frontend has no plugin entry point');
|
||||
plugin = await module.default(legacyServerAPI);
|
||||
if (legacy && plugin?.title && !plugin.titleView) plugin.titleView = plugin.title;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/* Fixed, read-only projection executed in Steam CEF. No plugin source runs here. */
|
||||
(function(payload) {
|
||||
const api = window.SteamClient?.GameSessions;
|
||||
let bridge = window.__frameyRunningBridgeV1;
|
||||
if (bridge && bridge.api !== api) {
|
||||
for (const key of [...bridge.sessions.keys()]) bridge.remove(key);
|
||||
bridge = null;
|
||||
}
|
||||
if (!bridge) {
|
||||
const sessions = new Map();
|
||||
const remove = key => {
|
||||
const item = sessions.get(key);
|
||||
item?.handle?.unregister();
|
||||
sessions.delete(key);
|
||||
if (!sessions.size) {clearInterval(bridge.timer); bridge.timer = null;}
|
||||
};
|
||||
bridge = window.__frameyRunningBridgeV1 = {api, sessions, remove, timer:null};
|
||||
}
|
||||
let item = bridge.sessions.get(payload.session);
|
||||
if (item && item.pluginId !== payload.pluginId) throw Error('Framey running session belongs to another plugin');
|
||||
if (!payload.active) {bridge.remove(payload.session); return null;}
|
||||
const store = window.SteamUIStore;
|
||||
if (!Array.isArray(store?.RunningApps)) throw Error('Steam running apps are unavailable');
|
||||
const apps = store.RunningApps;
|
||||
if (apps.length > 64) throw Error('Too many running Steam apps');
|
||||
const main = store.MainRunningApp;
|
||||
const project = app => [app.appid, app.display_name, app.sort_as, app.app_type, String(app.gameid),
|
||||
app.minutes_playtime_forever, app.minutes_playtime_last_two_weeks, app.rt_last_time_played];
|
||||
// Include a selected main app even if Steam's list changes between the reads.
|
||||
const rows = apps.map(project);
|
||||
if (main && !apps.some(app => app.appid === main.appid)) rows.push(project(main));
|
||||
const publicArt = value => {
|
||||
if (typeof value !== 'string' || value.length > 2048) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.port ||
|
||||
!/(^|\.)steamstatic\.com$/.test(url.hostname) || !url.pathname.startsWith('/steam/apps/')) return null;
|
||||
return url.origin + url.pathname; // Never copy local artwork paths or query data.
|
||||
} catch (_) {return null;}
|
||||
};
|
||||
const artwork = [...apps, ...(main && !apps.some(app=>app.appid === main.appid) ? [main] : [])].map(app => {
|
||||
const art = window.appStore;
|
||||
const vertical = publicArt(art?.GetVerticalCapsuleURLForApp?.(app));
|
||||
const custom = art?.GetCustomVerticalCapsuleURLs?.(app);
|
||||
return [app.appid, vertical, Array.isArray(custom) ? custom.slice(0,8).map(publicArt).filter(Boolean) : []];
|
||||
});
|
||||
if (!item) {
|
||||
if (bridge.sessions.size >= 4) throw Error('Too many Framey running sessions');
|
||||
item = {pluginId:payload.pluginId, queue:[], sequence:0, touched:Date.now(), handle:null};
|
||||
if (typeof api?.RegisterForAppLifetimeNotifications === 'function') {
|
||||
item.handle = api.RegisterForAppLifetimeNotifications(event => {
|
||||
// This queue contains actual Steam notifications, never snapshot diffs.
|
||||
if (!Number.isSafeInteger(event?.unAppID) || event.unAppID <= 0 || typeof event.bRunning !== 'boolean') return;
|
||||
item.queue.push({sequence:++item.sequence, unAppID:event.unAppID, bRunning:event.bRunning});
|
||||
if (item.queue.length > 64) item.queue.shift();
|
||||
});
|
||||
}
|
||||
bridge.sessions.set(payload.session, item);
|
||||
if (!bridge.timer) bridge.timer = setInterval(() => {
|
||||
for (const [key, state] of bridge.sessions)
|
||||
if (Date.now() - state.touched > 15000) bridge.remove(key);
|
||||
}, 5000);
|
||||
}
|
||||
item.touched = Date.now();
|
||||
return {apps:rows, artwork, runningIds:apps.map(app => app.appid), mainAppId:main?.appid ?? null,
|
||||
events:item.queue.filter(event => event.sequence > payload.cursor), cursor:item.sequence,
|
||||
lifetimeAvailable:!!item.handle};
|
||||
})
|
||||
+93
-22
@@ -11,6 +11,8 @@ import frame_decky
|
||||
METHOD = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,63}\Z')
|
||||
EVENT = re.compile(r'[A-Za-z0-9_:/.-]{1,128}\Z')
|
||||
SESSION = re.compile(r'[A-Za-z0-9_.-]{1,100}\Z')
|
||||
RECEIPT = re.compile(r'[0-9a-f]{64}\Z')
|
||||
MODULE_PATH = re.compile(r'[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*/[A-Za-z0-9_.-]+\.js\Z|index\.js\Z')
|
||||
PLUGINS = Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
|
||||
|
||||
@@ -18,8 +20,8 @@ class PanelError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def local_bundle(plugin_id):
|
||||
"""Read a modern Decky bundle with a store receipt; reject linked paths."""
|
||||
def _owned_dist(plugin_id):
|
||||
"""Locate a compatible store-owned frontend without following linked paths."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0 or not PLUGINS.is_dir() or PLUGINS.is_symlink():
|
||||
raise FileNotFoundError('Framey plugin unavailable')
|
||||
for folder in PLUGINS.iterdir():
|
||||
@@ -55,24 +57,71 @@ def local_bundle(plugin_id):
|
||||
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
|
||||
if version == 1 and not legacy:
|
||||
marker = b'__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit'
|
||||
supported = marker in bundle
|
||||
if not supported and b'./chunks/' in bundle:
|
||||
chunks = dist/'chunks'
|
||||
if chunks.is_symlink() or not chunks.is_dir():
|
||||
raise FileNotFoundError('Framey plugin chunks unavailable')
|
||||
files = list(chunks.iterdir())
|
||||
if len(files) > 128:
|
||||
raise FileNotFoundError('Framey plugin has too many chunks')
|
||||
for chunk in files:
|
||||
if chunk.suffix != '.js':
|
||||
continue
|
||||
if chunk.is_symlink() or not chunk.is_file() or chunk.stat().st_size > 8*1024*1024:
|
||||
raise FileNotFoundError('Framey plugin chunk unavailable')
|
||||
if marker in chunk.read_bytes():
|
||||
supported = True
|
||||
if not supported:
|
||||
raise FileNotFoundError('This API v1 frontend does not use the supported Decky API bridge')
|
||||
return dist, bundle
|
||||
except (OSError, ValueError):
|
||||
raise FileNotFoundError('Framey plugin files are invalid')
|
||||
raise FileNotFoundError('Plugin is not managed by the Frame Control Decky store')
|
||||
|
||||
|
||||
def local_bundle(plugin_id):
|
||||
"""Read a modern Decky bundle with a store receipt; reject linked paths."""
|
||||
return _owned_dist(plugin_id)[1]
|
||||
|
||||
|
||||
def local_module(plugin_id, name):
|
||||
"""Serve only JavaScript within a store-owned dist directory for module imports."""
|
||||
if not isinstance(name, str) or not MODULE_PATH.fullmatch(name):
|
||||
raise FileNotFoundError('Framey module path unavailable')
|
||||
dist, bundle = _owned_dist(plugin_id)
|
||||
if name == 'index.js':
|
||||
return bundle
|
||||
target = dist
|
||||
for part in name.split('/'):
|
||||
target = target/part
|
||||
if target.is_symlink():
|
||||
raise FileNotFoundError('Framey module path unavailable')
|
||||
if not target.is_file() or target.stat().st_size > 8*1024*1024:
|
||||
raise FileNotFoundError('Framey module unavailable')
|
||||
return target.read_bytes()
|
||||
|
||||
|
||||
def panel(plugin_id):
|
||||
"""The plugin receives no Frame UI key and is sandboxed to an opaque origin."""
|
||||
encoded = base64.b64encode(local_bundle(plugin_id)).decode('ascii')
|
||||
bundle = local_bundle(plugin_id)
|
||||
encoded = base64.b64encode(bundle).decode('ascii')
|
||||
module = f' data-module="/framey/module/{plugin_id}/index.js"' if b'./chunks/' in bundle else ''
|
||||
return ('''<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Framey plugin</title><style>body{font:21px/1.4 system-ui;background:#0d1925;color:#f3f7fa;margin:0;padding:20px}
|
||||
button,select,input{font:inherit;min-height:52px;background:#23618c;color:#fff;border:1px solid #72baf0;border-radius:9px;padding:8px}
|
||||
button:focus-visible,select:focus-visible,input:focus-visible{outline:4px solid #f8d370}button{margin:4px 8px 4px 0}button:disabled{opacity:.55}section{padding:12px;margin:12px 0;background:#1b3042;border-radius:10px}
|
||||
label{display:grid;gap:6px;margin:10px 0}small{color:#b9c9d6}</style>
|
||||
label{display:grid;gap:6px;margin:10px 0}small{color:#b9c9d6}
|
||||
.framey-spinner,.framey-loading{display:grid;place-items:center;min-height:80px}.framey-loading{background:#1b3042}
|
||||
.framey-status{color:#b9c9d6}.framey-medium{font-size:1em}.framey-footer{padding:12px 0}
|
||||
.framey-sections,.framey-library{display:flex;flex-direction:column;gap:12px}.framey-actions{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.framey-action{min-height:52px}.framey-button-child{display:inline-flex;align-items:center;gap:8px}.framey-green{background:#246b42}
|
||||
.framey-long-title{overflow-wrap:anywhere}.framey-muted{background:#1b3042}.framey-image{max-width:100%;object-fit:contain}
|
||||
.framey-focus:focus-visible{outline:4px solid #f8d370}</style>
|
||||
<p id="framey-error" role="status"></p><div id="framey-toasts" aria-live="polite"></div><div id="framey-root"></div><div id="framey-modal"></div>
|
||||
<script id="framey-bundle" type="application/json">''' + encoded + '''</script>
|
||||
<script id="framey-bundle" type="application/json"''' + module + '''>''' + encoded + '''</script>
|
||||
<script src="/framey-runtime/react.js"></script><script src="/framey-runtime/react-dom.js"></script>
|
||||
<script src="/framey-runtime/host.js"></script></html>''').encode()
|
||||
|
||||
@@ -91,7 +140,18 @@ finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
def _current_plugin(ssh, plugin_id, receipt):
|
||||
if not isinstance(receipt, str) or not RECEIPT.fullmatch(receipt):
|
||||
raise PanelError('Reopen the plugin panel to refresh its store receipt')
|
||||
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')
|
||||
if found[0].get('sha256') != receipt:
|
||||
raise PanelError('The plugin changed; close and reopen its panel')
|
||||
return found[0]
|
||||
|
||||
|
||||
def call_method(ssh, plugin_id, method, args, legacy=False, receipt=None):
|
||||
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):
|
||||
@@ -100,10 +160,8 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
raise PanelError('Plugin method arguments are too large')
|
||||
if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))):
|
||||
raise PanelError('Legacy plugin methods need one keyword argument object')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
plugin = _current_plugin(ssh, plugin_id, receipt)
|
||||
payload = {'name':plugin['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
# Library-sized arguments can exceed the OS limit for a single SSH command
|
||||
# argument. Send them as a quoted Python literal on stdin instead.
|
||||
script = METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__', repr(json.dumps(payload)))
|
||||
@@ -144,15 +202,13 @@ finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def read_steam(ssh, plugin_id, method):
|
||||
def read_steam(ssh, plugin_id, method, receipt=None):
|
||||
"""Read one explicitly supported Steam API for an installed store panel."""
|
||||
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 method not in STEAM_READS:
|
||||
raise PanelError('This Steam API is unavailable in Framey')
|
||||
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')
|
||||
_current_plugin(ssh, plugin_id, receipt)
|
||||
expression = '(async()=>await ' + STEAM_READS[method] + ')()'
|
||||
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
|
||||
'__FRAMEY_STEAM_LIMIT__', str(2*1024*1024 if method == 'GetLibrarySnapshot' else 256*1024))
|
||||
@@ -171,7 +227,7 @@ finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def poll_events(ssh, plugin_id, session, events, cursor=0):
|
||||
def poll_events(ssh, plugin_id, session, events, cursor=0, receipt=None):
|
||||
"""Read only this store-owned plugin's subscribed events; never trust its claimed name."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
@@ -182,11 +238,26 @@ def poll_events(ssh, plugin_id, session, events, cursor=0):
|
||||
raise PanelError('Invalid Decky event names')
|
||||
if type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
|
||||
raise PanelError('Invalid Decky event cursor')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'session':session, 'events':events, 'cursor':cursor}
|
||||
plugin = _current_plugin(ssh, plugin_id, receipt)
|
||||
payload = {'name':plugin['name'], 'session':session, 'events':events, 'cursor':cursor}
|
||||
# Embed only our bundled source as a Python string; plugin data stays in JSON.
|
||||
source = Path(__file__).with_name('framey-events.js').read_text(encoding='utf-8')
|
||||
script = EVENT_SCRIPT.replace('__FRAMEY_EVENTS_SOURCE__', repr(source))
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)), stdin=script, timeout=25))
|
||||
|
||||
|
||||
def poll_running(ssh, plugin_id, session, active, cursor=0, receipt=None):
|
||||
"""Project running apps and actual lifetime events for one installed panel."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(session, str) or not SESSION.fullmatch(session):
|
||||
raise PanelError('Invalid Framey running session')
|
||||
if type(active) is not bool or type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
|
||||
raise PanelError('Invalid Framey running request')
|
||||
_current_plugin(ssh, plugin_id, receipt)
|
||||
payload = {'pluginId':plugin_id, 'session':session, 'active':active, 'cursor':cursor}
|
||||
source = Path(__file__).with_name('framey-running.js').read_text(encoding='utf-8')
|
||||
expression = '(' + source + ')(' + json.dumps(payload) + ')'
|
||||
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
|
||||
'__FRAMEY_STEAM_LIMIT__', str(256*1024))
|
||||
return json.loads(ssh('python3 -', stdin=script, timeout=25))
|
||||
+78
-17
@@ -105,22 +105,40 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
|
||||
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
|
||||
}catch(error){list.textContent=error.message;}}
|
||||
function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
|
||||
function deckyJobPending(){return !!sessionStorage.getItem('frameyPluginJob');}
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
|
||||
activeFrameyId=null, frameySession=null, deckyBusy=false;
|
||||
async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
deckyStatusRequest=0,
|
||||
activeFrameyId=null, activeFrameyReceipt=null, frameySession=null, deckyBusy=false;
|
||||
async function refreshDecky(){const request=++deckyStatusRequest;try{const state=await api('/api/decky');
|
||||
if(request!==deckyStatusRequest)return;
|
||||
deckyState=state;
|
||||
const active=(state.plugins||[]).find(p=>p.storeId===activeFrameyId);
|
||||
const panelStale=activeFrameyId!==null&&(!state.running||!active||
|
||||
active.sha256!==activeFrameyReceipt?.sha256||active.version!==activeFrameyReceipt?.version);
|
||||
if(panelStale)closeFrameyPanel();
|
||||
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
|
||||
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
|
||||
'Decky is not running. Install it from Frame Control on your computer.';
|
||||
if(panelStale)byId('deckyState').textContent+=' The old plugin panel was closed; reopen it after refreshing.';
|
||||
const list=byId('deckyInstalled');list.replaceChildren();
|
||||
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
|
||||
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
|
||||
const note=document.createElement('p');note.textContent=compatibilityText(plugin);row.append(note);
|
||||
if(plugin.storeId){const open=document.createElement('button');open.className='secondary';open.textContent='Open panel';
|
||||
open.disabled=deckyBusy||deckyJobPending()||!deckyState.running;
|
||||
open.onclick=()=>openFrameyPanel(plugin.storeId,plugin.name);row.append(open);
|
||||
const button=document.createElement('button');button.className='danger';button.textContent='Remove';
|
||||
button.disabled=deckyBusy;button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
|
||||
button.disabled=deckyBusy||deckyJobPending();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;}}
|
||||
showDeckyStore();}catch(error){if(request!==deckyStatusRequest)return;
|
||||
const panelClosed=activeFrameyId!==null;
|
||||
if(panelClosed)closeFrameyPanel();
|
||||
deckyState=null;
|
||||
byId('deckyState').textContent=`Installed plugins unavailable: ${error.message}`;
|
||||
if(panelClosed)byId('deckyState').textContent+=' The plugin panel was closed until status can be refreshed.';
|
||||
byId('deckyInstalled').replaceChildren();
|
||||
byId('deckyInstalled').textContent='Refresh to check installed plugins.';
|
||||
showDeckyStore();}}
|
||||
async function loadDeckyStore(){const request=++deckyStoreRequest;byId('deckyStorePanel').hidden=false;
|
||||
deckyStore=[];deckyStoreIndex=0;showDeckyStore();
|
||||
byId('deckyStoreSummary').textContent='Loading official Decky store…';
|
||||
@@ -147,7 +165,7 @@ function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren
|
||||
const button=document.createElement('button');button.textContent='Install';
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===plugin.id);
|
||||
if(installed)button.textContent=installed.version===plugin.version?'Reinstall':'Update';
|
||||
button.disabled=deckyBusy||!deckyState?.running||plugin.root||!!plugin.compatibility?.blockInstall;
|
||||
button.disabled=deckyBusy||deckyJobPending()||!deckyState?.running||plugin.root||!!plugin.compatibility?.blockInstall;
|
||||
const note=document.createElement('p');note.textContent=compatibilityText(plugin);label.append(note);
|
||||
if(plugin.root && !plugin.compatibility?.blockInstall)label.append(' · Needs root; not supported on Frame');
|
||||
button.onclick=()=>deckyChange(installed?'update':'install',plugin);row.append(label,button);list.append(row);}}
|
||||
@@ -175,43 +193,64 @@ async function followDeckyJob(job,action,name){
|
||||
}
|
||||
throw Error('Still working. Use Check result to reconnect to this job.');
|
||||
}
|
||||
async function deckyChange(action,plugin){if(deckyBusy||!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
|
||||
async function deckyChange(action,plugin){if(deckyBusy)return;
|
||||
if(deckyJobPending()){
|
||||
deckyJobView('Previous plugin change','Check result before starting another plugin change.',null,true);
|
||||
return;
|
||||
}
|
||||
if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
|
||||
deckyBusy=true;showDeckyStore();
|
||||
deckyJobView(`${action} ${plugin.name}`,'Starting…',0);
|
||||
const panelClosed=(action==='update'||action==='remove')&&activeFrameyId===plugin.id;
|
||||
const panelNote=panelClosed?(action==='remove'?' Plugin panel closed after removal.':
|
||||
' Old plugin panel closed; reopen it after refreshing installed plugins.') : '';
|
||||
if(panelClosed)closeFrameyPanel();
|
||||
deckyJobView(`${action} ${plugin.name}`,
|
||||
panelClosed?'Starting… Old plugin panel closed until the change finishes.':'Starting…',0);
|
||||
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
|
||||
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
|
||||
if(!started.job)throw Error('The server did not return a job ID');
|
||||
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
|
||||
await followDeckyJob(started.job,action,plugin.name);
|
||||
sessionStorage.removeItem('frameyPluginJob');
|
||||
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}`,null,true);}
|
||||
if(panelNote)byId('deckyJobStatus').textContent+=panelNote;
|
||||
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
|
||||
finally{deckyBusy=false;await refreshDecky();}
|
||||
}
|
||||
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
|
||||
if(!saved||typeof saved.job!=='string'){sessionStorage.removeItem('frameyPluginJob');return;}
|
||||
if(deckyBusy)return;
|
||||
if(!saved||typeof saved.job!=='string'){sessionStorage.removeItem('frameyPluginJob');showDeckyStore();return;}
|
||||
deckyBusy=true;byId('deckyJobCheck').hidden=true;
|
||||
try{await followDeckyJob(saved.job,saved.action,saved.name);sessionStorage.removeItem('frameyPluginJob');}
|
||||
catch(error){deckyJobView(`${saved.action} ${saved.name}`,`Error: ${error.message}`,null,true);}
|
||||
finally{deckyBusy=false;await refreshDecky();}}
|
||||
byId('deckyJobCheck').onclick=checkDeckyJob;
|
||||
function openFrameyPanel(id,name){
|
||||
if(deckyBusy||deckyJobPending()||!deckyState?.running)return;
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
|
||||
byId('frameyCompatibility').textContent=compatibilityText(installed);
|
||||
closeFrameyEvents();
|
||||
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
|
||||
activeFrameyId=id;activeFrameyReceipt={sha256:installed.sha256,version:installed.version};
|
||||
frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
|
||||
byId('frameyPanelStatus').textContent='Loading plugin panel…';
|
||||
byId('frameyFrame').src=`/framey/panel/${id}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
|
||||
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
|
||||
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
|
||||
byId('frameyPanelStatus').textContent='No compatible panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
||||
}
|
||||
let frameyEventsActive=false;
|
||||
let frameyEventsActive=false,frameyRunningActive=false;
|
||||
function closeFrameyEvents(){
|
||||
if(frameyRunningActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/running',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,active:false}).catch(()=>{});
|
||||
frameyRunningActive=false;
|
||||
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
api('/api/framey/events',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
frameyEventsActive=false;
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{closeFrameyEvents();activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
function closeFrameyPanel(){closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
|
||||
byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;}
|
||||
byId('frameyClose').onclick=closeFrameyPanel;
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
function frameyCacheRead(id){
|
||||
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
|
||||
@@ -244,6 +283,25 @@ window.addEventListener('message',async event=>{
|
||||
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
|
||||
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
|
||||
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
|
||||
if(req.type==='framey:running:close'){
|
||||
if(req.session===frameySession&&frameyRunningActive){
|
||||
frameyRunningActive=false;
|
||||
api('/api/framey/running',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
|
||||
session:frameySession,active:false}).catch(()=>{});
|
||||
}return;
|
||||
}
|
||||
if(req.type==='framey:running'){
|
||||
if(req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||
|
||||
!Array.isArray(req.args)||req.args.length!==1||!Number.isSafeInteger(req.args[0])||req.args[0]<0)return;
|
||||
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
|
||||
reply={type:'framey:reply',id:req.id};
|
||||
frameyRunningActive=true;
|
||||
try{const data=await api('/api/framey/running',{id:pluginId,receipt,session,active:true,cursor:req.args[0]});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
|
||||
event.source.postMessage(reply,'*');
|
||||
return;
|
||||
}
|
||||
if(req.type==='framey:events:close'){
|
||||
if(req.session===frameySession)closeFrameyEvents();return;
|
||||
}
|
||||
@@ -252,9 +310,11 @@ window.addEventListener('message',async event=>{
|
||||
req.args.length!==2||!Array.isArray(req.args[0])||req.args[0].length>32||
|
||||
req.args[0].some(name=>typeof name!=='string'||!/^[A-Za-z0-9_:/.-]{1,128}$/.test(name))||
|
||||
!Number.isSafeInteger(req.args[1])||req.args[1]<0)return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
|
||||
reply={type:'framey:reply',id:req.id};
|
||||
frameyEventsActive=req.args[0].length>0;
|
||||
try{reply.result=await api('/api/framey/events',{id:pluginId,session,events:req.args[0],cursor:req.args[1]});}
|
||||
try{reply.result=await api('/api/framey/events',{id:pluginId,receipt,session,
|
||||
events:req.args[0],cursor:req.args[1]});}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
|
||||
event.source.postMessage(reply,'*');
|
||||
@@ -263,9 +323,10 @@ window.addEventListener('message',async event=>{
|
||||
if(!['framey:call','framey:steam'].includes(req.type)||req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const steamRead=req.type==='framey:steam';
|
||||
if(steamRead&&(req.args.length!==0||!['GetIPCountry','GetInstallFolders','GetSystemInfo','GetLibrarySnapshot'].includes(req.method)))return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
|
||||
reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api(steamRead?'/api/framey/steam':'/api/framey/method',
|
||||
{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
{id:pluginId,receipt,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
});
|
||||
|
||||
+25
-3
@@ -581,7 +581,8 @@ 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'), body.get('legacy', False))
|
||||
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'),
|
||||
body.get('legacy', False), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
@@ -590,7 +591,7 @@ def framey_steam_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.read_steam(ssh, body.get('id'), body.get('method'))
|
||||
return framey_panel.read_steam(ssh, body.get('id'), body.get('method'), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
@@ -600,7 +601,17 @@ def framey_events_action(body):
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.poll_events(ssh, body.get('id'), body.get('session'),
|
||||
body.get('events'), body.get('cursor', 0))
|
||||
body.get('events'), body.get('cursor', 0), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
def framey_running_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.poll_running(ssh, body.get('id'), body.get('session'),
|
||||
body.get('active'), body.get('cursor', 0), body.get('receipt'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
@@ -3473,6 +3484,7 @@ POST = {
|
||||
"/api/framey/method": framey_method_action,
|
||||
"/api/framey/steam": framey_steam_action,
|
||||
"/api/framey/events": framey_events_action,
|
||||
"/api/framey/running": framey_running_action,
|
||||
"/api/framey/check": framey_check_action,
|
||||
"/api/headset": headset_action,
|
||||
"/api/headset/quick": headset_quick,
|
||||
@@ -3691,6 +3703,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_bytes(framey_panel.panel(plugin_id), 'text/html; charset=utf-8', framey=True)
|
||||
except (ValueError, FileNotFoundError, OSError):
|
||||
self.send_json({'error':'Framey panel unavailable'}, 404)
|
||||
elif path.startswith('/framey/module/'):
|
||||
try:
|
||||
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
|
||||
module = framey_panel.local_module(int(plugin_id), name)
|
||||
# The plugin iframe has an opaque origin; only its module
|
||||
# imports need cross-origin access to these public JS files.
|
||||
self.send_bytes(module, 'text/javascript; charset=utf-8', framey=True,
|
||||
headers=[('Access-Control-Allow-Origin', 'null')])
|
||||
except (ValueError, FileNotFoundError, OSError):
|
||||
self.send_json({'error':'Framey module unavailable'}, 404)
|
||||
elif path.startswith('/framey-runtime/'):
|
||||
files = {'react.js':'vendor/react-18.3.1.min.js',
|
||||
'react-dom.js':'vendor/react-dom-18.3.1.min.js',
|
||||
|
||||
Reference in new issue
Block a user