Compare commits

..
120 Commits
Author SHA1 Message Date
SAPHID 5da87c8dc0 Record exact v0.4.45 candidate Frame smoke 2026-10-26 10:35:54 +00:00
SAPHID 59ea09325c Prepare Frame Control v0.4.45 candidate 2026-10-26 10:33:21 +00:00
Alex Southwell f88e2ed209 Merge pull request #317 from saphid/feat/framey-next-backend-compat
Fix Decky activation and confirmation controls in Framey
2026-10-26 10:25:37 +00:00
Alex Southwell 43a6b5e008 Merge pull request #316 from saphid/feat/framey-receipt-modules
Bind Framey plugin modules to installed receipts
2026-10-26 10:13:21 +00:00
Alex Southwell 221d2b26ab Merge pull request #318 from saphid/docs/v0444-release-evidence
Record v0.4.44 release verification
2026-10-26 10:10:11 +00:00
SAPHID 087bb1ef9f Keep updated Framey confirmation dialogs dismissible 2026-10-26 10:10:05 +00:00
SAPHID 70b4bb1368 Merge remote-tracking branch 'origin/main' into docs/v0444-release-evidence 2026-10-26 10:01:30 +00:00
Alex Southwell b961fc4cef Merge pull request #319 from saphid/fix/fakeframe-pacman-retry
Retry truncated fake Frame package downloads
2026-10-26 10:00:55 +00:00
SAPHID 9af06c7845 Retry transient fake Frame package downloads 2026-10-26 09:52:47 +00:00
SAPHID 9359b4ffbd Record v0.4.44 release verification 2026-10-26 09:40:30 +00:00
SAPHID 0051ac4570 Fix Decky activation and confirmation controls in Framey 2026-10-26 09:32:34 +00:00
Alex Southwell 37e25e62ac Merge pull request #315 from saphid/release/v0.4.44
Prepare Frame Control v0.4.44
2026-10-26 09:17:25 +00:00
SAPHID dddfc6bea8 Bind Framey plugin modules to installed receipts 2026-10-26 09:16:07 +00:00
SAPHID d1e526cb4b Record exact v0.4.44 candidate Frame smoke 2026-10-26 09:01:33 +00:00
SAPHID 8230fdb19d Merge remote-tracking branch 'origin/main' into release/v0.4.44 2026-10-26 08:59:06 +00:00
Alex Southwell 2937a12864 Merge pull request #314 from saphid/docs/v0443-release-evidence
Document v0.4.43 tagged release verification
2026-10-26 08:58:23 +00:00
SAPHID 71fe4a3ebb Prepare Frame Control v0.4.44 candidate 2026-10-26 08:50:12 +00:00
SAPHID 42f3b2e1e7 Record post-tag Framey scroll button exercise 2026-10-26 08:47:27 +00:00
SAPHID ca4c370b33 Document v0.4.43 tagged release verification 2026-10-26 08:41:10 +00:00
Alex Southwell c50f96510f Merge pull request #313 from saphid/feat/framey-installed-app-context
fix(framey): restore Junk-Store About with installed metadata
2026-10-26 08:31:28 +00:00
Alex Southwell 43e801091a Merge pull request #312 from saphid/feat/framey-scroll-buttons
Add visible scroll controls to Framey plugin panels
2026-10-26 08:30:07 +00:00
Alex Southwell b4e632b218 Merge pull request #311 from saphid/release/v0.4.43
Prepare Frame Control v0.4.43
2026-10-26 08:25:09 +00:00
SAPHID d84491c0ec fix(framey): project installed metadata for Junk-Store About 2026-10-26 08:23:50 +00:00
SAPHID 83b8d13846 Add visible scroll controls to Framey plugin panels 2026-10-26 08:20:11 +00:00
SAPHID 340ca88359 Record test-only main merge in v0.4.43 candidate 2026-10-26 08:10:20 +00:00
SAPHID e62589417b Merge remote-tracking branch 'origin/main' into release/v0.4.43 2026-10-26 08:09:54 +00:00
Alex Southwell 03b3796d8d Merge pull request #310 from saphid/fix/windows-wireless-live-retry
Retry transient Windows wireless inventory smoke timeout
2026-10-26 08:09:31 +00:00
SAPHID 93da4e466f Prepare Frame Control v0.4.43 candidate 2026-10-26 08:05:12 +00:00
Alex Southwell 05093ea4d4 Merge pull request #309 from saphid/feat/framey-script-entrypoints
fix(framey): restore executable scripts for Junk-Store
2026-10-26 08:03:32 +00:00
SAPHID 232b253e7a Merge Web Browser compatibility into Junk-Store fix 2026-10-26 07:53:39 +00:00
Alex Southwell de8abe32c6 Merge pull request #308 from saphid/feat/framey-web-browser
Framey: handle missing Steam React root in legacy panels
2026-10-26 07:51:52 +00:00
SAPHID 42492e7879 Retry transient Windows wireless smoke timeout once 2026-10-26 07:45:31 +00:00
SAPHID f50139fe6a Merge main into Framey Web Browser compatibility 2026-10-26 07:34:55 +00:00
SAPHID 698a643eb7 Merge remote-tracking branch 'origin/main' into feat/framey-script-entrypoints
# Conflicts:
#	docs/framey-compatibility.md
2026-10-26 07:34:03 +00:00
SAPHID d0cef31656 fix(framey): restore executable plugin script entrypoints 2026-10-26 07:33:14 +00:00
Alex Southwell 4ac59f1a44 Merge pull request #307 from saphid/feat/framey-volume-boost-arm64
docs: verify Volume Boost sink controls on ARM64 Frame
2026-10-26 07:32:50 +00:00
SAPHID c91cfff797 docs: record exact Framey unworn regression gate 2026-10-26 07:28:07 +00:00
SAPHID d5e62f643d Framey: tolerate missing Steam React root in legacy panels 2026-10-26 07:26:21 +00:00
Alex Southwell f3f574666f Merge pull request #306 from saphid/docs/v0442-release-evidence
Document v0.4.42 tagged release verification
2026-10-26 07:13:01 +00:00
SAPHID 40ece4fef0 docs: verify Volume Boost sink controls on ARM64 Frame 2026-10-26 06:19:34 +00:00
Alex Southwell f52c020310 Merge pull request #305 from saphid/docs/framey-running-postmerge-vr
Document post-merge Framey running panel check
2026-10-26 06:04:36 +00:00
SAPHID efe0c508f4 docs: record v0.4.42 tagged release verification 2026-10-26 06:03:42 +00:00
SAPHID 1af2bb45d9 docs: record post-merge Framey running panel exercise 2026-10-26 05:56:16 +00:00
Alex Southwell daee952873 Merge pull request #304 from saphid/feat/framey-running-context
Framey: bridge running-game context and Steam lifetime notifications
2026-10-26 05:53:58 +00:00
Alex Southwell 98909cb543 Merge pull request #302 from saphid/release/v0.4.42
Prepare Frame Control v0.4.42
2026-10-26 05:52:18 +00:00
SAPHID 1903153d7b docs: record exact v0.4.42 Frame candidate gates 2026-10-26 05:43:12 +00:00
SAPHID 7172be17fc Document running receipt guard and its verification boundary 2026-10-26 05:39:17 +00:00
SAPHID 27af46dd20 Record receipt guard in v0.4.42 candidate 2026-10-26 05:38:34 +00:00
SAPHID 761e952d8f Merge remote-tracking branch 'origin/main' into feat/framey-running-context 2026-10-26 05:38:07 +00:00
SAPHID 0237e342d2 Bind Framey running polls and cleanup to the installed plugin receipt 2026-10-26 05:38:07 +00:00
SAPHID 33d85dff1c Merge remote-tracking branch 'origin/main' into release/v0.4.42 2026-10-26 05:37:36 +00:00
Alex Southwell ad57662d40 Merge pull request #301 from saphid/fix/framey-receipt-bound-api
Reject stale Framey panel requests after plugin changes
2026-10-26 05:37:04 +00:00
SAPHID 5381667947 Update v0.4.42 candidate with Intel Mac evidence 2026-10-26 05:33:31 +00:00
SAPHID 7cdff4285a Merge remote-tracking branch 'origin/main' into release/v0.4.42 2026-10-26 05:32:40 +00:00
Alex Southwell fe1da43171 Merge pull request #303 from saphid/fleet/1vbt29ptkw
Record official Intel Mac v0.4.39 acceptance and fix host tests
2026-10-26 05:31:37 +00:00
SAPHID 1c1d941a1c Document locked unworn Framey receipt exercise 2026-10-26 05:27:46 +00:00
SAPHID c2f94c242d Give unworn Framey sample a content receipt 2026-10-26 05:25:48 +00:00
SAPHID e6e42a93f0 Fix stale receipt e2e response assertion 2026-10-26 05:23:47 +00:00
SAPHID 10b196beaa Merge remote-tracking branch 'origin/main' into feat/framey-running-context 2026-10-26 05:23:14 +00:00
SAPHID 3f74ff4bb0 Add read-only running-game context and Steam lifetime relay for Framey 2026-10-26 05:23:13 +00:00
Alex Southwell f2f7a3f4c1 Merge current main and retain Intel acceptance evidence 2026-10-26 16:21:49 +11:00
Alex Southwell 5c4c447d2d Test official Intel Mac v0.4.39 and fix host validation fixtures 2026-10-26 16:21:03 +11:00
SAPHID 63f1f7fb39 Prepare Frame Control v0.4.42 candidate 2026-10-26 05:20:11 +00:00
Alex Southwell c88736c6ce Merge pull request #300 from saphid/fix/smoke-runtime-skip
Report unavailable x86 runtime as skipped in Frame smoke
2026-10-26 05:16:15 +00:00
SAPHID fa354ce365 Bind Framey panel requests to installed plugin receipt 2026-10-26 05:16:02 +00:00
Alex Southwell fcdd1f22f5 Merge pull request #299 from saphid/fix/framey-close-stale-panel
Close stale Framey panels during plugin changes
2026-10-26 05:15:21 +00:00
Alex Southwell d43e760aef Merge pull request #298 from saphid/docs/v0441-release-evidence
Document v0.4.41 tagged release verification
2026-10-26 05:12:14 +00:00
SAPHID a3e0ec01d4 Report unavailable x86 runtime as skipped in Frame smoke 2026-10-26 05:09:01 +00:00
SAPHID 240288df65 Close stale Framey panels during plugin changes 2026-10-26 05:06:43 +00:00
SAPHID a68c5584a9 Document v0.4.41 tagged release verification 2026-10-26 05:03:11 +00:00
Alex Southwell b5d50a8d89 Merge pull request #297 from saphid/release/v0.4.41
Prepare Frame Control v0.4.41
2026-10-26 04:50:27 +00:00
SAPHID 0c9bed1254 Merge remote-tracking branch 'origin/main' into release/v0.4.41 2026-10-26 04:40:51 +00:00
Alex Southwell 741735aa98 Merge pull request #296 from saphid/test/framey-native-scroll
Measure native Framey scroll in an overflowed test region
2026-10-26 04:40:21 +00:00
SAPHID 4133ea1c7d Update support matrix for merged ARM64 audio evidence 2026-10-26 04:39:11 +00:00
SAPHID 3aa510a0e0 Merge remote-tracking branch 'origin/test/framey-native-scroll' into release/v0.4.41 2026-10-26 04:36:17 +00:00
SAPHID 637813024f Record v0.4.41 candidate Frame smoke 2026-10-26 04:34:32 +00:00
SAPHID c87e5c6e8a Prepare Frame Control v0.4.41 2026-10-26 04:32:18 +00:00
Alex Southwell c9777851c9 Merge pull request #293 from saphid/feat/framey-audio-arm64
Verify official audio mixers on the ARM64 Frame
2026-10-26 04:30:38 +00:00
SAPHID af03081f27 Record exact-main Framey sample check after input guard 2026-10-26 04:27:58 +00:00
SAPHID ef99d4953b Merge remote-tracking branch 'origin/main' into test/framey-native-scroll 2026-10-26 04:27:37 +00:00
Alex Southwell 17a7b0b80e Merge pull request #295 from saphid/docs/v0440-release-evidence
Document v0.4.40 release and post-tag Frame checks
2026-10-26 04:26:56 +00:00
Alex Southwell c38e7e8da4 Merge pull request #294 from saphid/fix/framey-sample-setting-guard
Guard Framey sample setting writes against rapid input
2026-10-26 04:25:34 +00:00
SAPHID fc00878a23 Measure Framey native scroll on an overflowed test region 2026-10-26 04:24:43 +00:00
SAPHID 657b4449f7 Document v0.4.40 release and post-tag Frame verification 2026-10-26 04:16:43 +00:00
SAPHID 30a65facde docs(framey): verify official per-app mixers on ARM64 Frame 2026-10-26 04:14:17 +00:00
SAPHID 9135f355f4 Guard sample plugin setting writes against rapid input 2026-10-26 04:14:10 +00:00
Alex Southwell 784118b71e Merge pull request #292 from saphid/fix/framey-quick-action-guard
Guard Framey quick actions against repeated taps
2026-10-26 04:13:32 +00:00
SAPHID e0115f356d Guard Framey quick actions against repeated taps 2026-10-26 04:04:53 +00:00
Alex Southwell 56bde24be1 Merge pull request #290 from saphid/fix/framey-pending-job
Hold Framey plugin changes until saved job resolves
2026-10-26 03:56:02 +00:00
Alex Southwell 59c270a7ad Merge pull request #289 from saphid/feat/framey-module-exports
Enable local Decky module lookups and TabMaster docs
2026-10-26 03:55:43 +00:00
Alex Southwell a4d8073e31 Merge pull request #291 from saphid/release/v0.4.40
Prepare Frame Control v0.4.40
2026-10-26 03:54:16 +00:00
SAPHID c0275b483a Record combined main regression checks for Framey module lookup 2026-10-26 03:39:24 +00:00
SAPHID ff49b44350 Record v0.4.40 candidate real Frame gates 2026-10-26 03:39:09 +00:00
SAPHID 7e6fb7e342 Keep Framey store locked while job result is unknown 2026-10-26 03:38:51 +00:00
SAPHID 57c1c4564b Merge remote-tracking branch 'origin/main' into feat/framey-module-exports
# Conflicts:
#	docs/framey-compatibility.md
#	scripts/framey-compatibility-doc.py
2026-10-26 03:35:18 +00:00
SAPHID 9aa26a8322 Merge remote-tracking branch 'origin/main' into release/v0.4.40 2026-10-26 03:34:08 +00:00
Alex Southwell 38c388f3f3 Merge pull request #288 from saphid/fix/framey-stale-status
Clear stale Framey store status after refresh failures
2026-10-26 03:33:52 +00:00
Alex Southwell 27e3c42ed0 Merge pull request #286 from saphid/feat/framey-chunked-frontend
Load chunked Decky frontends in Framey
2026-10-26 03:33:21 +00:00
SAPHID c2be12fa75 Merge remote-tracking branch 'origin/main' into feat/framey-module-exports 2026-10-26 03:32:53 +00:00
SAPHID c164606fe3 Support bounded Framey-local Decky module and CSS lookup 2026-10-26 03:32:53 +00:00
SAPHID 8d9d4807c3 Prepare Frame Control v0.4.40 version 2026-10-26 03:26:00 +00:00
SAPHID 7f8a743cb5 Clarify real Frame gate scope in Deck Shelves evidence 2026-10-26 03:21:40 +00:00
SAPHID 40df801cdb Clear stale Framey store state after status failure 2026-10-26 03:21:16 +00:00
SAPHID 35d53e6010 Merge remote-tracking branch 'origin/main' into feat/framey-chunked-frontend 2026-10-26 03:21:10 +00:00
SAPHID 1b02d7f558 Record exact Frame gates and verify installed chunk on fake Frame 2026-10-26 03:21:09 +00:00
Alex Southwell edfa48d1f5 Merge pull request #287 from saphid/fix/framey-settings-only-panel
Framey: hide empty Panel tab for settings-only plugins
2026-10-26 03:20:12 +00:00
Alex Southwell c89ddffcbd Merge pull request #285 from saphid/docs/mac-v0439-runtime
Document official Intel Mac runtime test and x64 download
2026-10-26 03:12:42 +00:00
Alex Southwell 5183f30068 Merge pull request #284 from saphid/fix/mac-server-shutdown
Fix server cleanup interrupted by late SIGTERM on Mac quit
2026-10-26 03:12:06 +00:00
SAPHID 37e2faacc7 Hide empty Panel tab for settings-only Framey plugins 2026-10-26 03:10:40 +00:00
SAPHID b0c4647b85 Support chunked official Decky frontends in Framey 2026-10-26 03:09:57 +00:00
Alex Southwell ff3b0dd6f9 Record official Intel Mac runtime checks and remaining opening limits 2026-10-26 14:02:08 +11:00
Alex Southwell 2b6b05a175 Let SIGTERM request server shutdown without interrupting cleanup 2026-10-26 13:59:17 +11:00
Alex Southwell 5c9ca110ed Merge pull request #283 from saphid/docs/v0439-release-evidence
Document v0.4.39 tagged release evidence
2026-10-26 02:57:01 +00:00
SAPHID e3540cab5e Separate released and post-release Framey coverage 2026-10-26 02:41:33 +00:00
SAPHID 0eaa9f2088 Record post-release Framey hardware checks 2026-10-26 02:39:39 +00:00
SAPHID 59f7348aa5 Merge remote-tracking branch 'origin/main' into docs/v0439-release-evidence 2026-10-26 02:39:07 +00:00
Alex Southwell 67b9151513 Merge pull request #282 from saphid/feat/framey-library-context
Framey: add read-only Steam library context for plugin panels
2026-10-26 02:37:07 +00:00
SAPHID 67a95d4f4c Document v0.4.39 tagged release verification 2026-10-26 02:31:10 +00:00
SAPHID ce6e8f807f Merge remote-tracking branch 'origin/main' into feat/framey-library-context 2026-10-26 02:26:52 +00:00
SAPHID e7fe34d865 Add detached Steam library context for Framey panels 2026-10-26 02:26:52 +00:00
93 changed files with 8947 additions and 300 deletions

No files matched your search

+11 -5
View File
@@ -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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.39",
"version": "0.4.45",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.39",
"version": "0.4.45",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.39",
"version": "0.4.45",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+8 -8
View File
@@ -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,22 @@
# Fake Frame package download retry, 26 October 2026
The ARM64 fake Frame container sometimes fails before project tests start.
Valve's `holo-packages.steamos.cloud` returned package responses with **164
bytes missing** during the `pacman -Syu` layer in
`tests/fakeframe/Containerfile`:
| Run | Package and outcome |
| --- | --- |
| [v0.4.44 candidate checks](https://github.com/saphid/frame-control/actions/runs/40296431563) | `iptables` download truncated; a failed-only rerun passed on the unchanged PR head. |
| [PR #316 checks](https://github.com/saphid/frame-control/actions/runs/40298316496) | `c-ares` download truncated before e2e tests. |
| [PR #318 checks](https://github.com/saphid/frame-control/actions/runs/40301478955) | First attempt truncated `iptables`; failed-only rerun on the unchanged documentation head truncated `rsync`. The six other jobs passed. |
The fake Frame image now retries that package transaction at most five times,
with a two-second delay after each failed attempt except the last. A fifth
failure still fails the build. The end-to-end tests and their assertions are
unchanged. This retry affects only the fake Frame test image; it does not
change a real headset or the released application.
Exact-head hosted CI must pass before this change is merged. A successful
container build would show that at least one package attempt completed; it
would not establish that Valve's package service is permanently healthy.
@@ -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,311 @@
{
"checkedAtUtc": "2026-10-26T09:28:48.295443+00:00",
"plugin": {
"id": 124,
"name": "AlarMe",
"version": "1.6.3",
"sha256": "17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe"
},
"status": "partial",
"sharedControls": {
"ConfirmModalOfficialImporters": 43,
"FocusableOfficialImporters": 65,
"claim": "Import counts show shared surface, not successful behavior in every plugin. Only AlarMe received a real-backend follow-up."
},
"realFrame": {
"os": "Frame OS 0.5.6",
"arch": "aarch64",
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"transport": "trusted SSH through coordinator host; receipt-bound real CEF Decky RPC/events",
"isolation": "Exact official archive installed by production APPLY into a random private path. Removable unprivileged user service, not enabled, RuntimeMaxSec=900 and TimeoutStopSec=5.",
"settings": {
"timer_subtle_mode": true,
"timer_volume": 0,
"timer_auto_suspend": false,
"timer_prevent_sleep": false,
"pomodoro_subtle_mode": true,
"pomodoro_volume": 0,
"pomodoro_prevent_sleep": false,
"overlay_enabled": false
},
"safeRpcResults": [
{
"method": "create_timer",
"args": [
300,
"Framey silent private timer",
true,
false,
false
],
"result": "91c1f87d"
},
{
"method": "pause_timer",
"args": [
"91c1f87d"
],
"result": true
},
{
"method": "save_preset_from_timer",
"args": [
"91c1f87d"
],
"result": {
"id": "47c046cd",
"seconds": 300,
"label": "Framey silent private timer",
"subtle_mode": true,
"auto_suspend": false,
"prevent_sleep": false
}
},
{
"method": "remove_preset",
"args": [
"47c046cd"
],
"result": true
},
{
"method": "cancel_timer",
"args": [
"91c1f87d"
],
"result": true
},
{
"method": "stopwatch_start",
"args": [],
"result": {
"status": "running",
"start_time": 1793006404.791917,
"elapsed_ms": 0,
"laps": [],
"prevent_sleep": false,
"current_elapsed_ms": 1.0344982147216797
}
},
{
"method": "stopwatch_lap",
"args": [],
"result": {
"status": "running",
"start_time": 1793006404.791917,
"elapsed_ms": 0,
"laps": [
{
"label": "Lap 1",
"absolute_ms": 31747.3783493042,
"split_ms": 31747.3783493042
}
],
"prevent_sleep": false,
"current_elapsed_ms": 31750.77533721924,
"total_lap_count": 1,
"lap_limit_reached": false
}
},
{
"method": "stopwatch_pause",
"args": [],
"result": {
"status": "paused",
"start_time": null,
"elapsed_ms": 34339.426040649414,
"laps": [
{
"label": "Lap 1",
"absolute_ms": 31747.3783493042,
"split_ms": 31747.3783493042
}
],
"prevent_sleep": false,
"current_elapsed_ms": 34339.426040649414,
"total_lap_count": 1,
"lap_limit_reached": false
}
},
{
"method": "stopwatch_reset",
"args": [],
"result": {
"status": "idle",
"start_time": null,
"elapsed_ms": 0,
"laps": [],
"current_elapsed_ms": 0
}
}
],
"eventCounts": {
"alarme_timers_updated": 3,
"alarme_sleep_inhibitor_updated": 6,
"alarme_timer_tick": 124,
"alarme_presets_updated": 2,
"alarme_stopwatch_updated": 3
},
"finalReadback": {
"timers": [],
"stopwatch": {
"status": "idle",
"start_time": null,
"elapsed_ms": 0,
"laps": [],
"current_elapsed_ms": 0
},
"sleep": {
"active": false,
"items": []
},
"presets": [
{
"id": "preset-5",
"seconds": 300,
"label": "5 minutes",
"subtle_mode": false,
"auto_suspend": false
},
{
"id": "preset-10",
"seconds": 600,
"label": "10 minutes",
"subtle_mode": false,
"auto_suspend": false
},
{
"id": "preset-15",
"seconds": 900,
"label": "15 minutes",
"subtle_mode": false,
"auto_suspend": false
},
{
"id": "preset-30",
"seconds": 1800,
"label": "30 minutes",
"subtle_mode": false,
"auto_suspend": false
}
]
},
"before": {
"decky": "failed",
"panel": "inactive",
"battery": [
97
]
},
"after": {
"decky": "failed",
"panel": "inactive",
"battery": [
96
]
},
"cleanup": {
"baseAbsent": true,
"unitAbsent": true
},
"lockReleased": true
},
"browser": {
"host": "Repository headset.html and opaque allow-scripts iframe with bundled React 18.3.1; loopback RPC/event relay to the real ARM64 backend",
"verified": [
"labeled silent timer creation and live tick display",
"timer pause through onActivate and backend readback",
"save paused timer as preset through onActivate",
"delete confirmation shows preset name and description",
"Cancel closes dialog without dispatching remove_preset; backend readback retains preset",
"Delete invokes remove_preset once and readback removes the private preset",
"timer cancellation",
"Watch tab activation; stopwatch start/lap/pause",
"fresh panel reopen retains paused 34.339-second stopwatch and one lap",
"stopwatch reset; no active timers or sleep inhibitors in final backend readback"
],
"domFixture": {
"type": "fixture:result",
"checks": [
"description visible",
"underlying panel inert",
"dialog fixed",
"Tab wraps in top dialog",
"Cancel callback once",
"parent draft preserved",
"disabled actions",
"old Close retains child",
"programmatic Close skips callback",
"OK does not close newly opened dialog",
"Update changes body",
"Escape cancels top dialog",
"panel reactivated",
"pointer Enter Space activate once each"
]
},
"limits": "Local Chromium with real backend, not the deployed headset HTTP host or worn VR. Synthetic DOM checks use programmatic DOM events, not physical controllers."
},
"fixtureCorrection": {
"issue": "Initial seed used settings/<display-name>; actual Decky uses settings/<folder>. Safety assertion failed on readback before any timer action.",
"resolution": "First private service/data removed and lock released. Correct folder seed read back before second run exposed actions."
},
"cleanupAudit": {
"servicesAudited": 2,
"allBaseUnitArchiveAbsent": true,
"allLoadStateNotFound": true,
"allActiveStateInactive": true,
"allMainPidZero": true,
"sharedLock": "Fresh atomic lock acquired and released for cleanup audit"
},
"unverified": [
"audio playback and sound imports",
"alarms and reminders",
"auto-suspend and /dev/uinput sleep inhibition",
"in-game/global Steam overlay: no injection target in isolated panel",
"background notifications after panel closes",
"worn VR and physical controller/keyboard input"
],
"validation": {
"fullPython": {
"ran": 1628,
"skipped": 16,
"result": "pass"
},
"nodeContracts": [
"tests/page/framey_actions.mjs",
"tests/page/framey_running.mjs"
],
"siteTestFiles": 2,
"currentStoreRefresh": {
"plugins": 116,
"exactIdsVersionsAndDigestsUnchanged": true
},
"fakeFrame": {
"blocked": 56,
"installed-removed": 60
},
"noContextBrowser": {
"plugins": 116,
"ready": 101,
"failed": 15,
"noRegressionsAgainstCatalogBaseline": true,
"scope": "Initialization only, all backend/network denied; library-dependent plugins lack Steam context"
},
"contextReplayBrowser": {
"plugins": 116,
"ready": 104,
"failed": 12,
"initializationRegressionsAgainstPriorContextReplay": [],
"scope": "Synthetic library/running replay; backend and network denied, initialization only"
}
},
"reviewFollowUp": {
"checkedAtUtc": "2026-10-26T10:10:05.650676+00:00",
"change": "Update reinjects the scoped close callback into replacement React dialogs and uses the replacement close callback.",
"verification": "Node regression: updated ConfirmModal OK and Cancel, with and without custom close callbacks; ClosedPromise resolves; original callback not retained; repeated Close/Update inert. Regression reproduced before the fix.",
"limits": "No additional local Chromium or real Frame check for this small follow-up; prior runtime evidence applies to initial dialog and workflow paths."
}
}
@@ -0,0 +1,107 @@
# Framey activation and dialog follow-up — 2026-10-26
AlarMe 1.6.3 now has usable timer, preset and stopwatch controls in the local
Framey host with its official backend running on the ARM64 Frame. It remains
**partial**. This check does not establish audio, alarm/reminder, sleep-control,
background-notification or worn-VR compatibility.
The exact archive is store ID 124, SHA-256
`17d4166a9e98979df5fdb07c7eae42317acd54a453d3b82757508f971193c0fe`.
See the [structured results](framey-dialog-controls-2026-10-26.json).
## Reproduced blockers and repairs
AlarMe uses DFL `Focusable.onActivate` for timer pause, save-as-preset, cancel,
preset deletion, tabs and stopwatch controls. Framey's previous `div`
passthrough ignored that callback: clicks on the save/pause controls did not
dispatch backend calls. Focusable now maps pointer activation and Enter/Space
to plugin callbacks, exposes a keyboard focus target and respects disabled
controls. A click does not invoke both `onClick` and `onActivate` when a plugin
supplies both; nested activation stops bubbling to the parent action.
The previous ConfirmModal omitted `strDescription`, Cancel, middle actions and
disabled flags. showModal did not inject a `closeModal` callback into custom
dialog components, and every Close handle cleared the same root. A confirmation
opened by another dialog could therefore disappear when the earlier action
closed. Dialogs also appeared below the panel's long page content.
Dialogs now occupy a scrollable overlay inside the opaque iframe. They preserve
the underlying dialog's mounted state, receive scoped close callbacks, support
Close/ClosedPromise/Update, show confirmation descriptions and cancel/middle
actions, and respect disabled flags. The underlying plugin surface becomes
inert while a dialog is open; Tab stays in the top dialog, Escape cancels it and
closing restores focus. Steam popouts and controller navigation are not emulated.
43 official archives import ConfirmModal and 65 import Focusable in this
snapshot. These are shared API surfaces, **not** verification or status
promotions for every importing plugin.
## Real backend and local browser verification
Held the normal atomic shared headset lock. Installed the exact official archive
through the production archive validation/extraction path into a random private
directory. Used published Decky 3.3.0 ARM64, an unprivileged user service that was
never enabled, with a 900-second runtime bound and five-second stop timeout.
RPCs and events used Framey's production receipt-bound bridge through real Steam
CEF. The Chromium host served repository headset.html, React and framey-host.js;
its status endpoints were fixture-shaped, while AlarMe RPCs/events were live.
The initial private settings seed used the plugin display name instead of the
archive folder. A safety assertion failed on readback of the defaults before any
timer action. That service/data was removed and the lock released. The corrected
run read back subtle mode, zero volume, no auto-suspend, no sleep inhibition and
no overlay before exposing actions. Its relay allowed only the documented safe
methods; sound, suspend, sleep-inhibitor and arbitrary plugin actions were denied.
Verified with actual browser clicks and real backend readback:
1. Add Label opened a custom timer dialog. Created a silent five-minute timer
named `Framey silent private timer`; live backend ticks changed its display.
2. Pause and save-as-preset activated after the Focusable repair. Backend
readback reported a paused timer and its saved preset.
3. Delete Preset showed the preset's name in its confirmation description.
Cancel closed the dialog without dispatching `remove_preset`; readback kept
the preset. Reopening and choosing Delete dispatched it once and removed it.
4. Cancel removed the private timer. Watch opened the stopwatch; Start, Lap and
Pause produced real backend state and events.
5. Closing and reopening the panel retained the paused stopwatch at 34.339
seconds and one lap. Reset cleared it. Final readback showed no active timers,
no laps and no active sleep inhibitor.
A separate local Chromium DOM fixture passed 14 checks for nested-dialog state,
scoped handles, descriptions, disabled buttons, Cancel, Escape, Tab wrapping,
Close/ClosedPromise/Update and pointer/Enter/Space activation. These programmatic
DOM checks do not verify physical input devices.
Review follow-up: Update also injects the scoped close callback into replacement
dialogs. Node regression contracts update to ConfirmModal and exercise both OK
and Cancel, with and without a plugin close callback, then await ClosedPromise.
They verify that the replacement's callback runs once and the original callback
is not retained. This extends the local host's Update behavior so replacements
remain dismissible; this follow-up was not rerun in Chromium or on the headset.
## Cleanup and remaining limits
Removed both private service units, bases and uploaded archives; reloaded the
user service manager and released the matching shared lock. A fresh locked audit
confirmed both units were not found, inactive and PID zero, with all test paths
absent. Managed Decky remained failed and the headset panel inactive, matching
the initial state. Battery was 97% before and 96% after. No power settings changed.
AlarMe stays partial. Audio, sound imports, alarms/reminders, auto-suspend and
`/dev/uinput` sleep inhibition were not exercised. Steam's global/in-game overlay
has no injection target inside Framey's isolated panel. Notifications require
the plugin panel to remain open. The deployed headset HTTP host, worn VR and
physical controller/keyboard input were not tested in this pass.
The store and documentation use the same exact-digest catalog reason. Aggregate
counts remain **4 works / 49 partial / 52 broken / 11 N/A**.
Validation: all 116 official IDs/versions/digests unchanged on store refresh;
fake-Frame 60 installed/removed and 56 blocked; full Python suite 1,628 tests,
16 skipped; Framey action/running Node contracts and both site test files pass.
The no-Steam-context Chromium pass attempted all 116 frontends (101 initialized,
15 failed); backend and network access were denied throughout that pass.
The repeat with synthetic library/running context initialized 104 of 116, with no
initialization regressions against the prior context-replay pass. Neither
initialization pass is a useful-functionality check.
@@ -0,0 +1,140 @@
{
"checkedAtUtc": "2026-10-26T08:14:37.777131+00:00",
"plugin": {
"id": 84,
"name": "Junk-Store",
"version": "1.1.13",
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1"
},
"projection": {
"field": "local_per_client_data.installed",
"wire": "optional ninth scalar: boolean or null",
"unknown": "null creates an empty detached metadata object; installed stays undefined",
"privateFieldsCopied": false,
"oldEightScalarRows": "accepted; running updates preserve existing library metadata",
"liveObservation": false
},
"realFrame": {
"os": "Frame OS 0.5.6",
"arch": "aarch64",
"uid": 1000,
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"transport": "trusted SSH through lxso2",
"sharedLock": true,
"runtime": "private bounded removable user service; not enabled",
"library": {
"apps": 1106,
"games": 895,
"installedTrue": 41,
"installedFalse": 0,
"installedUnknown": 1065
},
"protonRuntimeInstalledFields": "undefined for both EasyAntiCheat and BattlEye; no false value invented",
"legendaryInstalled": false,
"allowedMethods": [
"execute_action",
"get_websocket_port"
],
"staticActions": [
"MainMenu/GetContent",
"init/InitActions"
],
"noMutationActions": true
},
"localBrowser": {
"host": "actual headset.html/framey-host.js, vendored React, opaque iframe",
"backend": "exact official ARM64 plugin through production receipt-bound bridge",
"library": "production receipt-bound snapshot from real Frame CEF",
"outerStatusInstallCheck": "fixture responses",
"passed": [
"main menu",
"About content and tab list",
"Dependencies tab controls render; no buttons invoked",
"Back to main menu",
"close/reopen and About again"
],
"limits": [
"optional localhost websocket blocked by test relay CSP",
"RSS calls denied",
"running-context relay returns fixture/unavailable data; no lifetime claims",
"outer extension/status fixture is incomplete; unrelated outer status/extension errors excluded from plugin claim",
"deployed headset HTTP endpoint unverified",
"worn VR unverified"
]
},
"cleanup": {
"initialSshStop": "timed out while test unit deactivating",
"recovery": "SIGKILL to exact owned unit only after matching lock owner; private files removed",
"result": {
"baseAbsent": true,
"unitAbsent": true,
"archiveAbsent": true,
"state": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
"normal": {
"frame-control-decky.service": "inactive",
"frame-control-headset.service": "inactive"
},
"battery": [
99
]
},
"freshLockedAudit": {
"services": 1,
"allArtifactsAbsent": true,
"allUnloadedInactivePidZero": true
},
"lockReleased": true,
"batteryBeforeAfter": [
[
99
],
[
99
]
],
"powerSettingChanged": false,
"localRelayAndBrowserClosed": true
},
"verification": {
"python": {
"run": 1628,
"skipped": 16,
"failures": 0
},
"panelFocused": {
"run": 22,
"failures": 0
},
"nodeContracts": [
"framey_actions.mjs",
"framey_running.mjs"
],
"siteTestFiles": {
"passed": 2,
"failures": 0
},
"fakeFrame": {
"blocked": 56,
"installed-removed": 60
},
"officialStore": {
"plugins": 116,
"errors": 0,
"identitySetUnchanged": true,
"checkedAtUtc": "2026-10-26T08:21:01.653768+00:00"
}
},
"status": "partial",
"unverified": [
"dependency/runtime installation",
"login",
"game listing/download/launch",
"RSS",
"optional websocket operations",
"Steam shortcut mutation",
"worn VR"
]
}
@@ -0,0 +1,62 @@
# Junk-Store About and read-only installed metadata
Junk-Store 1.1.13 (official ID 84) now renders its About and Dependencies tabs
without the `local_per_client_data.installed` crash recorded in the
[script-entrypoint audit](framey-script-entrypoints-2026-10-26.md). It remains
**partial**. The catalog and both store views use the same updated reason.
The library snapshot omitted the entire `local_per_client_data` object. The
official frontend dereferences it to display its dependency controls. Under the
shared headset lock, a read-only probe confirmed that the real Frame exposes
this object for the two Proton runtimes, but neither has an `installed` boolean.
Across 1,106 app overviews, 41 had `installed: true`, none had `false`, and 1,065
had no installed boolean. Those absent values must not be relabelled as false.
The snapshot adds one bounded scalar per app: a Steam-supplied boolean or null.
Framey's detached overview creates a minimal metadata object, copying `installed`
only for a boolean. Null leaves that property undefined. No client IDs, paths,
other metadata, live Steam objects or methods cross the iframe boundary. Eight
scalar rows remain accepted, and running-app updates preserve library metadata.
The snapshot still refreshes at panel open; it is not a live installation feed.
## Actual ARM64 backend and local Chromium
The unmodified, hash-verified official archive ran in published Decky 3.3.0 ARM64
on Frame OS 0.5.6 using a private, bounded, removable user service. Legendary was
absent; no dependency installer, runtime install, login, game operation or custom
backend download was invoked. The exact plugin and loader digests are in the
[redacted results](framey-installed-context-2026-10-26.json).
Local Chromium ran the actual Framey host and official frontend in an opaque
iframe. The production receipt-bound bridges read the real Steam library and
called the official ARM64 backend's static menu actions. About content and its
tab list, Dependencies controls, Back, close/reopen, and About again passed.
No dependency buttons were pressed. The real library counts matched the probe.
The optional localhost websocket was blocked by the test relay CSP; RSS was
deliberately denied. Outer status/install/check replies were fixtures; incomplete
outer extension/status and running-context replies are excluded from the plugin
claim. The screenshot still used the catalog reason from before this follow-up.
Dependency installation, login, game operations, RSS, websocket operations,
shortcut changes, deployed headset HTTP and worn VR remain unverified.
## Cleanup and regression checks
The initial cleanup SSH timed out while the private Decky unit was deactivating.
Recovery checked the matching lock owner, killed only that exact test unit, and
removed its private base, unit file and archive. A fresh locked audit independently
confirmed unloaded/inactive/PID 0 and no artifacts, then released its lock. Normal
managed Decky/panel services stayed inactive; battery was 99 → 99. No power-setting
action ran. Local relay and owned browser tab are closed.
- Full Python suite: 1,628 tests, 16 skipped, no failures; focused panel: 22 passed.
- Framey action/running Node contracts passed, including true/false/unknown,
malformed metadata, detached values, old rows and running-update preservation.
- Both site test files passed. Generated docs and diff checks passed.
- All 116 exact official archives passed the fake-Frame audit: 60 installed/removed,
56 expected blocks; disposable project removed. Backend execution is stubbed in
that harness. The fresh official store has 116 entries, no audit errors, and an
unchanged ID/version/digest set.
No new works claim is made. Catalog totals remain **4 works / 49 partial / 52 broken
/ 11 N/A**; only Junk-Store's verified scope and remaining limits change.
@@ -0,0 +1,326 @@
{
"timeUtc": "2026-10-26T02:23:08.939647+00:00",
"theme": "Detached read-only Steam library and current-user context",
"baselineMain": "3921e59",
"hostFileSha256": "3f70e9769c6c95d39cfa329ccf3f822df85d411e9295bdf888508b4738679e9d",
"realFrame": {
"platform": "Frame OS 0.5.6 ARM64",
"path": "SSH via lxso2; Steam SharedJSContext CDP",
"lock": "/tmp/frame-test.lock atomic mkdir; matching owner released in finally after every probe",
"apps": 1106,
"games": 895,
"compactBytesWithoutUser": 88451,
"invalidRows": 0,
"missingGameOverviews": 0,
"currentUser": "17-digit string verified; value withheld",
"mutations": "none; no wake, service, setting, screenshot or library changes; no device artifacts created",
"limits": "read-only CEF projection, not an official backend or worn-VR test"
},
"browser": {
"mode": "opaque iframe Chromium; detached real Frame snapshot replay; all backend/network actions denied",
"attempted": 116,
"initializerReady": 101,
"failed": 15,
"initializerGains": [
46,
53,
66
],
"regressions": [],
"focused": [
{
"id": 37,
"name": "Discord Status",
"ready": true,
"error": null,
"logs": [],
"methods": [],
"library": "snapshot replay"
},
{
"id": 46,
"name": "Steamback",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "TypeError: saveInfos.map is not a function"
}
],
"methods": [
"set_account_id",
"get_saveinfos"
],
"library": "snapshot replay"
},
{
"id": 53,
"name": "Shotty",
"ready": true,
"error": null,
"logs": [],
"methods": [
"set_id_map_fronend"
],
"library": "snapshot replay"
},
{
"id": 66,
"name": "PlayTime",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Failed to set current user: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Error: Read-only compatibility audit: backend method blocked"
}
],
"methods": [
"set_current_user",
"fetch_playtime_information",
"statistics_for_last_two_weeks",
"daily_statistics_for_period"
],
"library": "snapshot replay"
},
{
"id": 93,
"name": "Deck Settings",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
},
{
"kind": "error",
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: Error: Read-only compatibility audit: live Steam API blocked"
}
],
"methods": [],
"library": "snapshot replay"
},
{
"id": 132,
"name": "SuggestMe",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "[SuggestMe] Failed to load config: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load library status: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load play next list: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load excluded games: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load filter presets: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load history: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load non-steam info: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load collections: Error: Read-only compatibility audit: backend method blocked"
}
],
"methods": [
"log_message",
"get_config",
"get_library_status",
"get_play_next_list",
"get_excluded_games",
"get_filter_presets",
"get_suggestion_history",
"get_non_steam_games",
"get_collections",
"get_available_genres",
"get_available_tags",
"get_available_community_tags"
],
"library": "snapshot replay"
},
{
"id": 134,
"name": "Deck Progress Tracker",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_settings",
"get_tag_statistics"
],
"library": "snapshot replay"
},
{
"id": 135,
"name": "Deck Shelves",
"ready": false,
"error": "Failed to resolve module specifier \"./chunks/pluginApi-B8sV7970.js\". Invalid relative url or base scheme isn't hierarchical.",
"logs": [],
"methods": [],
"library": "snapshot replay"
}
]
},
"fakeFrame": {
"attempted": 116,
"installedRemoved": 60,
"expectedBlockers": 56,
"backendExecution": "stubbed",
"cleanup": "private Compose containers/network/volumes removed"
},
"localBackends": {
"timeUtc": "2026-10-26T02:18:51.848256+00:00",
"verification": "local Chromium + actual Decky 3.3.0 amd64, detached real Frame SSH snapshot; no worn VR",
"calls": [
{
"id": 46,
"method": "set_account_id",
"resultType": "dict",
"resultCount": 2
},
{
"id": 46,
"method": "get_saveinfos",
"resultType": "dict",
"resultCount": 2
},
{
"id": 66,
"method": "set_current_user",
"resultType": "NoneType"
},
{
"id": 66,
"method": "statistics_for_last_two_weeks",
"resultType": "list",
"resultCount": 0
},
{
"id": 66,
"method": "fetch_playtime_information",
"resultType": "list",
"resultCount": 0
},
{
"id": 66,
"method": "daily_statistics_for_period",
"resultType": "dict",
"resultCount": 3
},
{
"id": 66,
"method": "fetch_playtime_information",
"resultType": "list",
"resultCount": 0
},
{
"id": 66,
"method": "statistics_for_last_two_weeks",
"resultType": "list",
"resultCount": 0
}
],
"runtime": {
"tag": "v3.3.0",
"name": "PluginLoader",
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
},
"legacyReadback": {
"plugin": 46,
"method": "get_saveinfos",
"replyType": 1,
"success": true,
"saveInfoCount": 0
}
},
"uiChecks": {
"Steamback": "Supported games panel, actual private backend set_account_id and get_saveinfos returned successfully; empty isolated Steam save data",
"PlayTime": "Statistics panel (empty database) and routed General settings/tabs rendered; actual private backend selected user and returned statistics",
"Deck Settings": "Installed game picker rendered real Frame install-folder/name snapshot; search modal rendered",
"Shotty": "Initializer repaired; actual Decky 3.3.0 backend fails importing unused click.get_app_dir: ModuleNotFoundError; no screenshot methods invoked"
},
"notVerified": [
"Real ARM64 official backends in this theme",
"Worn VR/controllers/keyboard",
"Live MobX or running-game observations",
"Steamback backups/restores and automatic notifications",
"PlayTime automatic recording, existing databases or account switches while panel remains open",
"Deck Settings remote reports/proxy fetch and settings changes",
"Shotty screenshot aggregation/rescue"
],
"cleanup": {
"localContainer": "stopped/removed; docker ps -a matching name empty",
"headsetLockReleased": true,
"httpHelpers": "three task-local HTTP servers stopped"
},
"plugins": [
{
"id": 37,
"name": "Discord Status",
"version": "1.5.0",
"sha256": "00a45dd919abb6e050685e1e408ce8023b9c4a09d6e91ba528d50aed2a63bf45"
},
{
"id": 46,
"name": "Steamback",
"version": "1.0.0-1",
"sha256": "ed5f2fbb47cdfc3aa9a4349a139ab20c29976117a3be0de7e8a588bb265ee7f3"
},
{
"id": 53,
"name": "Shotty",
"version": "0.1.0",
"sha256": "ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe"
},
{
"id": 66,
"name": "PlayTime",
"version": "3.2.5",
"sha256": "10fc3beea48991c77cb02867e919b618d4cd6d5e9c6a6d0ccd5266c13214039f"
},
{
"id": 93,
"name": "Deck Settings",
"version": "1.2.6",
"sha256": "23c2a933a6883e2622f0184c33a76ce4a4e88c0a7dcd730f9bab71b8c3702132"
}
],
"validation": {
"serverTests": 1617,
"serverSkips": 16,
"siteTests": 18,
"fakeFramePanel": "2 targeted panel/store flows passed; private Compose resources removed",
"focusedFrameyTests": 40
}
}
@@ -0,0 +1,72 @@
# Framey detached Steam library context — 2026-10-26
Framey now reads a bounded library snapshot before importing official frontends
that reference `appStore`, `collectionStore` or `m_CurrentUser`. It supplies app
IDs/names/types/game IDs, sorting/playtime scalars, the actual all-games
collection and the current public Steam ID. It does not copy credentials,
balances, account names, filesystem paths or executable Steam objects.
The projection is a fixed, parameterless `GetLibrarySnapshot` read through the
existing installed-store-plugin boundary. The parent checks the iframe source,
session, method and empty argument list. The host validates rows before exposing
detached Maps and lookup methods. Plugin patches remain local to that iframe.
Missing or invalid data leaves the globals unavailable and logs a clear warning;
there is no invented account or empty-library fallback. Reopening the panel
refreshes the snapshot. Live MobX observations, account-switch notifications and
running-game events are outside this change.
Library-sized backend arguments now have a 256 KiB UTF-8 JSON budget. The old
8 KiB ceiling rejected normal app-name maps. Payloads travel as quoted literals
on SSH stdin, avoiding operating-system limits on a single command argument.
Store receipt ownership, method validation and legacy keyword envelopes still
apply. The library read has a separate 2 MiB / 10,000-app limit; other Steam
reads retain their 256 KiB response limit.
## Observations
| Plugin | Verified behavior | Remaining limit |
| --- | --- | --- |
| Steamback 1.0.0-1 | Panel opens; account setup and empty save-list reads succeed through actual Decky 3.3.0 amd64 | Private container had no Steam saves. ARM64 backend, backup/restore and automatic hooks unverified. Partial. |
| PlayTime 3.2.5 | Actual backend selects the user, returns empty statistics; statistics panel and routed settings/tabs render | No sessions were created. ARM64 backend, existing databases, live observations and automatic recording unverified. Partial. |
| Deck Settings 1.2.6 | Installed game picker renders the real Frame folder/name snapshot; search modal opens | Remote reports/proxy fetch, settings changes and ARM64 backend unverified. Partial. |
| Shotty 0.1.0 | Library-based initializer now completes | Exact backend fails importing `click.get_app_dir`: `ModuleNotFoundError: No module named 'click'`. Archive does not bundle it. Broken. |
| Discord Status 1.5.0 | Detached overview lookups available | Live running-app observations and artwork helpers remain absent; Discord activity unverified. Broken. |
Exact plugin versions/digests, runtime digest, scoped calls and counts are in the
[redacted observations](framey-library-context-2026-10-26.json). The canonical
table/store labels use the same digest-scoped JSON. None of these plugins is
promoted to **works** by this pass.
## Verification boundaries
- **Real Frame SSH:** atomic `/tmp/frame-test.lock` acquisition for every probe,
matching-owner release in `finally`. Frame OS 0.5.6 ARM64 Steam CEF returned
1,106 apps and 895 game IDs; no missing collection members or invalid rows.
The compact library-only response was 88,451 bytes. Current-user field shape
was separately inspected. Values remain private; only aggregate counts are
published. No wake, power/service changes or device artifacts were needed.
- **Local Chromium:** production `headset.html`, current host, opaque iframe and
unmodified digest-checked official frontends. Library/user and install-folder
data were detached snapshots read from the Frame, replayed locally. This is
not a deployed headset HTTP or worn-VR test.
- **Actual local backends:** published Decky 3.3.0 amd64 executable, private
unprivileged container, network disabled, capabilities dropped, no devices,
host home, saves or Steam files mounted. Only account setup within private
plugin data and read methods were allowed. Backup, restore, aggregation,
screenshot rescue and game actions were denied. Shotty's import failure was
observed from the runtime log before invoking screenshot methods.
- **Catalog regression:** 116 exact frontends attempted, 101 initializer-ready
and 15 failed, compared with 98 ready before this theme. Steamback, Shotty and
PlayTime gained initializer readiness; no previous ready state regressed.
Backend/network calls stayed denied in this sweep. Readiness alone does not
establish useful operation.
- **Fake Frame:** 116 archive/transport cases; 60 install/remove cycles and 56
expected blockers. Official backend execution was stubbed. Its private
Compose containers, network and volumes were removed.
The browser audit accepts `--library-snapshot /private/library.json` to reproduce
the detached replay. This flag does not convert the audit into live Steam or
backend evidence. Never commit personal library snapshots or account values.
No worn-VR, physical controller, keyboard, automatic recording or backup claim
is made. Further device backend checks still require the shared hardware lock.
@@ -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,101 @@
# 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.
## Post-merge unworn panel exercise
On merged main `daee952873255b86018cce1d2a4d3bd070ec4341`,
`scripts/framey-vr-check.py --exercise --output /tmp/framey-post304-exercise`
acquired its own `/tmp/frame-test.lock` before any headset read. At 05:54 UTC,
preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%.
The temporary sample panel passed the real Decky setting save and fresh-iframe
reopen, a trusted synthetic OpenVR pointer, quick action, slider value 50 and
CDP text entry after OpenVR focus. Stereo compositor SHA-256 changed from
`50fcab1ee25e6d9398106cc0c24ec6616e07bcf4573b52be1774c715b756060b`
to `98241806fb1f775e7f4c5ef0b0564bfd3c552ee896ff225b4d0cb5b7b53f70d1`;
the `/dev/video99` mirror capture hash was
`c284c05f86b60362168c1d1f58fe775974b70ac54bbfd57834802b892eb4509e`.
The command exited 0 with `passed=true`. The original power values (`false` and
`5.000000`) were read back, battery remained 99%, the recovery timer was removed,
cleanup errors were empty and the run released only its own lock. The private
full report is `/tmp/framey-post304-exercise/result.json` on the test host.
This exercise covered the sample panel path after the running-context merge. It
did not observe a nonempty running-game list, a game lifetime callback, Discord
activity, a worn display or physical controller input. Synthetic native scroll
again did not move (`scrollTop=0`, no wheel event).
@@ -0,0 +1,184 @@
{
"checkedAtUtc": "2026-10-26T07:22:08.822239+00:00",
"plugin": {
"id": 84,
"name": "Junk-Store",
"version": "1.1.13",
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
"license": "BSD-3-Clause",
"sourceModified": false
},
"installer": {
"beforeScriptMode": "0644",
"afterScriptMode": "0744",
"policy": "Add owner execute for a shebang or ZIP-declared executable; do not restore archive privilege/write bits. Existing installs require update or reinstall."
},
"isolatedBackend": {
"runtime": "published Decky 3.3.0 amd64",
"loaderSha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"isolation": [
"unprivileged",
"no network",
"no devices",
"no capabilities",
"read-only root",
"private tmpfs home and tmp"
],
"beforeInit": {
"result": {
"Type": "Error",
"Content": {
"Message": "Error parsing json result Expecting value: line 1 column 1 (char 0)",
"Data": "",
"ActionName": "init",
"ActionSet": "init"
}
},
"success": true
},
"afterInit": {
"result": {
"Type": "Success",
"Content": {
"Message": "Initialized"
}
},
"success": true
},
"beforeInitActions": {
"result": {
"Type": "Error",
"Content": {
"Message": "Error parsing json result Expecting value: line 1 column 1 (char 0)",
"Data": "",
"ActionName": "InitActions",
"ActionSet": "init"
}
},
"success": true
},
"afterInitActions": {
"outerSuccess": true,
"innerType": "ActionSet",
"setName": "MainMenu",
"actionCount": 4
}
},
"realFrame": {
"os": "Frame OS 0.5.6",
"arch": "aarch64",
"uid": 1000,
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"transport": "existing trusted SSH via lxso2",
"sharedLock": true,
"service": "private removable user service; not enabled; RuntimeMaxSec=600",
"installedWithProductionApply": true,
"scriptMode": "0o744",
"legendaryInstalled": false,
"backendMethods": [
"execute_action",
"get_websocket_port"
],
"staticActions": [
"JunkStoreActions/GetContent",
"JunkStoreActions/GetEpicActions",
"MainMenu/GetContent",
"MainMenu/JunkStoreInit",
"init/InitActions"
],
"websocketPort": 8765,
"websocketCommandsSent": false,
"librarySnapshotCounts": {
"apps": 1106,
"games": 895
},
"explicitInitActionInvoked": false,
"normalDeckyBeforeAfter": [
"inactive",
"inactive"
],
"normalPanelBeforeAfter": [
"inactive",
"inactive"
],
"batteryBeforeAfter": [
[
99
],
[
100
]
],
"powerSettingChanged": false,
"cleanup": {
"baseAbsent": true,
"unitAbsent": true
},
"lockReleased": true
},
"localBrowser": {
"runtime": "local Chromium, real headset.html/framey-host.js, vendored React, official frontend in opaque iframe",
"backend": "production receipt-bound bridge to actual ARM64 backend",
"steamLibrary": "production receipt-bound GetLibrarySnapshot; only counts retained",
"outerStatusInstallResponses": "fixtures",
"passed": [
"Custom Stores main menu and Games/News/About navigation items render",
"Games route opens static Epic tab",
"Back returns to main menu",
"close/reopen returns to main menu"
],
"remainingFailure": "About: Cannot read properties of undefined (reading 'installed'); detached app overview lacks local_per_client_data.installed",
"deniedActions": [
"fetch_rss_feed",
"EpicActions/GetContent"
],
"denialIsNotBackendFailure": true
},
"cleanupAudit": {
"attempts": 3,
"allPrivateBasesAbsent": true,
"allPrivateUnitsAbsent": true,
"allArchivesAbsent": true,
"allServicesInactive": true,
"freshAuditLockReleased": true
},
"verification": {
"python": {
"run": 1628,
"skipped": 16,
"failures": 0
},
"site": {
"passed": 18
},
"focusedStore": {
"passed": 19
},
"fakeFrame": {
"blocked": 56,
"installed-removed": 60
},
"officialStore": {
"plugins": 116,
"errors": 0,
"identitySetUnchanged": true,
"checkedAtUtc": "2026-10-26T07:22:17.371046+00:00"
},
"staticEntrypointArchives": 28
},
"unverified": [
"Legendary dependency installation",
"Epic login",
"game listing",
"game downloads or launch",
"RSS feed",
"Steam shortcut mutation",
"optional websocket operations",
"deployed headset HTTP endpoint",
"worn VR"
],
"status": "partial"
}
@@ -0,0 +1,76 @@
# Executable script entrypoints: Junk-Store
Junk-Store 1.1.13 (official ID 84) moves **broken → partial**. The exact unmodified
BSD-3-Clause archive is pinned in the [redacted results](framey-script-entrypoints-2026-10-26.json).
No ARM64 rebuild or upstream plugin source patch was needed.
Python ZIP extraction drops execute permissions. This archive also publishes its
shebang scripts as 0644, relying on upstream Decky's recursive permission setup.
Framey's user service disables that setup. Direct script calls therefore fail.
The installer now adds **owner execute only** to shebang scripts or ZIP-declared
executables. It does not restore archive privilege/write bits, grant group/other
execute, or change unmarked data permissions. Archive validation and installation
blocks remain in place. Existing installations need update/reinstall for repair.
The upstream comparison is Decky 3.3.0's
[permission setup](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/browser.py)
and [Linux implementation](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/localplatform/localplatformlinux.py).
Static inspection found 28 accepted official archives with shebang or declared
executable members. This is shared installer coverage, not 28 functional claims.
## Before and after in an isolated real backend
Both runs used the exact official plugin and published Decky 3.3.0 amd64, with no
network/devices/capabilities, a read-only root and private temporary home/tmp.
The only difference was the production installer before/after this change.
| Check | Before | After |
| --- | --- | --- |
| `scripts/get-json.py` | 0644 | 0744 |
| `execute_action(init, init)` | inner `Type:Error`, empty output / JSON parse error | `Type:Success`, `Initialized` |
| `execute_action(init, InitActions)` | inner `Type:Error` | `Type:ActionSet`, `MainMenu`, four actions |
The legacy wrapper reports `success:true` even for the failing inner error.
Startup log text alone is also insufficient: the baseline emits initialized/started
messages despite those errors. Both private containers were removed.
## Real ARM64 Frame and local Chromium
Under the shared headset lock, the patched production installer installed the
official archive into a private, bounded, removable Decky 3.3.0 ARM64 user service
on Frame OS 0.5.6. The service was not enabled. Legendary was absent before and
after; no dependency installer, login, download, launch or websocket command ran.
The production receipt-bound bridge called the actual backend through live Steam
CEF. Static action sets, main-menu content and the Epic action set returned real
results. `get_websocket_port` returned 8765. Explicit `init/init` success was tested
in the isolated amd64 backend; it was not explicitly invoked in the final Frame run.
Local Chromium ran the actual Framey host and official frontend in an opaque
iframe, relaying only allowed static calls to that ARM64 backend. Main menu,
Games → static Epic tab, Back and fresh close/reopen passed. Steam library reads
used the production receipt-bound bridge; only counts were retained. Outer
status/install responses were fixtures, and the deployed headset HTTP endpoint
was not tested.
The About route still crashes on missing `local_per_client_data.installed` in
the detached app overview. Epic game-list and RSS calls were deliberately denied;
their loading/error views do not demonstrate backend failure. Dependency
installation, login, game operations, RSS, Steam shortcut mutation and worn VR
remain unverified. This supports **partial**, not works.
Three temporary attempts were cleaned, including an initial Steam reinjection
readiness failure and a test-relay omission of the library endpoint. A fresh
shared-lock audit found all three services inactive/unloaded (PID 0), with their
private bases, unit files and archives absent. Normal managed Decky/panel services
stayed inactive; final battery was 99 → 100 while charging. No power-setting action
ran. The audit lock was released, and local relays/browser tabs were closed.
## Verification
- Python suite: 1,628 tests, 16 skipped, no failures; store suite: 19 passed.
- Site suite: 18 passed. Two new regression tests failed before the fix.
- All 116 official plugins: fake-Frame 60 installed/removed, 56 expected blocks;
no actual plugin backends run by this harness. Disposable project cleaned.
- Fresh official-store audit: 116 plugins, zero errors; ID/version/digest set
matches the catalog. Other plugin statuses are 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,405 @@
{
"baseCommit": "daee952873255b86018cce1d2a4d3bd070ec4341",
"plugin": {
"id": 58,
"name": "Volume Boost",
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc"
},
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6",
"os": "Frame OS 0.5.6",
"arch": "aarch64"
},
"verificationLimits": {
"fakeFrame": "Prior exact archive HTTP/SSH install/receipt/removal; plugin execution stubbed, not repeated",
"localBrowser": "Actual headset.html, React and Framey host, exact unmodified official frontend in opaque iframe; direct RPC relay to actual ARM64 backend",
"realFrameSSH": "Production receipt-bound framey_panel.call_method over SSH and live Steam CEF DeckyBackend.call into actual Decky 3.3.0 ARM64 plugin; default speaker sink remained muted throughout",
"wornVR": "Not tested",
"audibleOutput": "Not tested; gain writes and readbacks only",
"deployedHTTP": "Not tested; outer status/install/check responses were fixtures, methods used production call_method with exact current receipt"
},
"catalogRefresh": {
"checkedAtUtc": "2026-10-26T06:10:58.537029+00:00",
"plugins": 116,
"changedIdsVersionsDigests": 0
},
"runs": [
{
"timeUtc": "2026-10-26T06:07:19.370988+00:00",
"locale": "LC_ALL=C for the private Decky service",
"originalSink": {
"mute": true,
"volumes": {
"front-left": 0,
"front-right": 0
}
},
"writes": [
{
"request": {
"value": 125,
"channel": "both"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 81920,
"front-right": 81920
}
},
"backendReadback": {
"left": {
"result": 125,
"success": true
},
"right": {
"result": 125,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 75,
"channel": "both"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 49152,
"front-right": 49152
}
},
"backendReadback": {
"left": {
"result": 75,
"success": true
},
"right": {
"result": 75,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 74,
"channel": "both"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 48496,
"front-right": 48496
}
},
"backendReadback": {
"left": {
"result": 74,
"success": true
},
"right": {
"result": 74,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 33,
"channel": "left"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 21626,
"front-right": 0
}
},
"backendReadback": {
"left": {
"result": 33,
"success": true
},
"right": {
"result": 0,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 67,
"channel": "right"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 0,
"front-right": 43909
}
},
"backendReadback": {
"left": {
"result": 0,
"success": true
},
"right": {
"result": 67,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 33,
"channel": "right"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 0,
"front-right": 21626
}
},
"backendReadback": {
"left": {
"result": 0,
"success": true
},
"right": {
"result": 33,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
}
],
"reads": [
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
},
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
}
],
"reopen": "Fresh query/session, ready; UI returned to actual restored zero; no write on mount",
"before": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "inactive",
"panel": "inactive"
},
"after": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "inactive",
"panel": "inactive"
},
"cleanup": {
"baseAbsent": true,
"unitAbsent": true
},
"exactSinkRestored": true,
"lockReleased": true
},
{
"timeUtc": "2026-10-26T06:12:04.889613+00:00",
"locale": "Inherited user-service environment, matching production unit; no LANG or LC_ALL override",
"originalSink": {
"mute": true,
"volumes": {
"front-left": 0,
"front-right": 0
}
},
"writes": [
{
"request": {
"value": 150,
"channel": "both"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 98304,
"front-right": 98304
}
},
"backendReadback": {
"left": {
"result": 150,
"success": true
},
"right": {
"result": 150,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
}
],
"reads": [
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
},
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
}
],
"reopen": "Fresh query/session, ready; UI returned to actual restored zero; no write on mount",
"before": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "inactive",
"panel": "inactive"
},
"after": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "inactive",
"panel": "inactive"
},
"cleanup": {
"baseAbsent": true,
"unitAbsent": true
},
"exactSinkRestored": true,
"lockReleased": true
}
],
"guard": {
"scope": "Private test helper only; not a shipped feature or plugin patch",
"timeoutSeconds": 60,
"runtimeMaxSec": 120,
"sequence": [
"Capture exact default sink identity, native per-channel volume integers and original mute",
"Persist recovery state; start bounded device-side systemd watchdog before mute/write",
"Require stereo front-left/front-right and mute readback before plugin call",
"Only official get_volume_state/set_volume_state; value 0..150, known channels",
"Read native Pulse volume and mute independently; read both channels through actual plugin",
"Restore exact native volume while muted, verify, then restore original mute and verify exact snapshot",
"Remove recovery state, stop only own watchdog; cleanup own service/base and release matching lock"
],
"automaticTimeoutExercised": false,
"explicitRestoreExercised": true
},
"freshCleanupAudit": {
"exactSinkRestored": true,
"baseAbsent": true,
"unitAbsent": true,
"services": {
"framey-boost-kp0z857q58-b4126054.service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
"framey-boost-kp0z857q58-b4126054-guard.service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n"
},
"battery": [
100
]
},
"remaining": [
"Audible sound/quality, perceived loudness and physical speaker gain are unverified",
"Worn VR, headset controller input and Bluetooth sinks are unverified",
"No default route change, playback or power-setting action was invoked",
"Decky startup reinjects its frontend and can restart Steam CEF; normal managed services stayed inactive"
],
"repositoryChecks": {
"python": "1,626 tests, 16 skips, no failures",
"site": "18 passed",
"catalogDocCheck": "passed",
"diffCheck": "passed"
}
}
@@ -0,0 +1,96 @@
# Volume Boost sink controls on the ARM64 Frame
Volume Boost 0.0.7 (official store ID 58) now qualifies as **works for sink
controls**. The exact official, unmodified Python backend ran in Decky 3.3.0
ARM64 on Frame OS 0.5.6. Local Chromium rendered its official frontend through
Framey's existing legacy bridge. Actual control changes reached the Frame's
default speaker sink, including above-100% gain, with independent Pulse
readbacks. The sink stayed muted; no audible-output or worn-VR claim is made.
[Digest-pinned observations and cleanup](framey-volume-boost-arm64-2026-10-26.json)
record both runs. This supersedes the Volume Boost read-only limitation in the
[previous ARM64 audio check](framey-audio-arm64-2026-10-26.md). No plugin patch,
native rebuild or new compatibility shim was necessary. Volume Boost's backend
uses Python and the Frame's existing ARM64 `pactl`; its hard-coded UID 1000
matches the actual Frame user.
| Local Chromium action | Actual ARM64 result, independently read back |
| --- | --- |
| Linked numeric input 125% | Native Pulse channel values 81920 / 81920; plugin reads 125 / 125 |
| Linked slider click, then ArrowDown | 75% then 74%; native values 49152 / 49152 then 48496 / 48496 |
| Unlink, set left to 33% | Native values 21626 / 0; plugin reads 33 / 0 |
| Set right to 67% | Native values 0 / 43909; plugin reads 0 / 67 |
| Re-link | Official frontend issues a right-only 33% write; native values 0 / 21626 |
| Close and reopen | New document/session, ready; backend reads and displayed linked value return to the restored 0%; no mount write |
| Repeat linked numeric 150% with inherited service locale | Native values 98304 / 98304; plugin reads 150 / 150; reopen again reads restored zero |
The test restores the original sink state after **each** write. Therefore the
separate-channel checks intentionally begin from 0% / 0%, rather than from the
previous UI value. A right-only re-link request is the published plugin's
behavior. These checks verify channel selection and preservation of the other
channel; they do not claim a continuous unmuted listening session. Pulse's
integer conversion truncates fractional native values; the evidence retains
those integers rather than replacing them with rounded percentages.
## Runtime and transport
Each run separately acquired the atomic `/tmp/frame-test.lock`; neither stole
an existing lock. A unique private Decky user service used its own plugin,
settings and runtime directories, was never enabled, and had an eight-minute
runtime limit. The upstream ARM64 loader matched GitHub's published SHA-256
and ELF machine 183. The plugin ZIP matched the official digest.
The local relay served actual `ui/headset.html`, vendored React and
`framey-host.js`, with exact official frontend bytes in an opaque
`allow-scripts` iframe. Outer status/install/check responses were fixtures.
Plugin calls used production `framey_panel.call_method`, including the current
store receipt, SSH through lxso2, live Steam CEF and `DeckyBackend.call` into the
actual plugin subprocess. Only the temporary install paths were redirected.
This does not verify the deployed headset HTTP endpoint or a VR compositor.
The first private service explicitly used `LC_ALL=C`. The second inherited the
user-service locale without a `LANG` or `LC_ALL` override, matching the
production service configuration, and repeated actual reads, a 150% write and
reopen. Zero alone was not accepted as success: upstream returns zero on
command failure. Every write returned true, both backend channel reads agreed,
and independent native Pulse readbacks confirmed nonzero changes.
## Bounded muted writes and restoration
The default sink was already muted at native channel values 0 / 0. Before each
plugin write, a private device-side helper captured the exact sink identity,
channel values and mute state; persisted recovery state; and started a bounded
60-second systemd watchdog. It then required a successful mute readback before
allowing the plugin call. The relay allowed only `get_volume_state` and
`set_volume_state`, known channels and values from 0 through 150.
After the call, independent `pactl --format=json list sinks` and both plugin
reads checked the result. In `finally`, the helper restored exact native
channel values while muted, checked them, restored the original mute state,
and verified the entire snapshot before removing recovery state and stopping
its watchdog. No default route, playback, existing stream or power setting was
changed. Automatic watchdog expiry was armed but not deliberately exercised;
explicit restoration passed after all seven writes.
Both private Decky services/bases were removed and their locks released.
Fresh-lock cleanup audits found their Decky and guard units unloaded/inactive
with PID 0, paths absent, and the exact original sink/mute restored. Battery
stayed 100 → 100; normal managed Decky and headset-panel services stayed
inactive. Decky startup reinjects its frontend and can restart Steam CEF.
## Catalog and verification limits
The canonical store catalog and generated table report **4 works / 48 partial /
53 broken / 11 N/A**. A fresh full official-store fetch at 06:10:58 UTC found
all 116 IDs, versions and digests unchanged. Historical fake-Frame checks cover
this exact archive's install/receipt/removal with backend execution stubbed;
that test was not repeated or relabelled as real-backend evidence.
Audible gain, sound quality, physical speaker output, Bluetooth sinks,
headset-controller input and worn VR remain unverified. The store row includes
these limits. This change records new real-backend evidence and updates its
compatibility classification; it changes no shipped audio implementation.
The full server suite completed 1,626 tests with no failures (16 skips); all
18 site tests passed. Generated-document consistency and `git diff --check`
also passed. No implementation change followed those tests.
@@ -0,0 +1,48 @@
# Official Web Browser 1.4.3 frontend follow-up
The exact official Decky store archive for ID 59 is 91,859 bytes and matched
SHA-256 `1192864fddc5d72550346b8dff02c21d71f0c40a06ac41c4e67bb6220d267f9d`.
Its legacy bundle calls `deckyFrontendLib.getReactRoot` when attempting to patch
Steam's main menu and search bar. The prior full-catalog audit recorded
`getReactRoot is not a function` and initializer failure.
Framey's isolated iframe has no Steam React root. The host now exposes
`getReactRoot()` with a **null** result, consistent with its existing inert
`findInReactTree()` lookup. It never returns a fabricated Steam tree or permits
patching the user's Steam UI. The focused Node host test checks this contract.
A focused local Chromium run used the verified archive through
`scripts/framey-audit-browser.py`, with every backend method, fetch and
WebSocket denied. The exact frontend reported `ready=true` with no initializer
error after 2.5 seconds, and again during a longer ten-second observation.
It attempted `set_main_tab` and `load`; the audit denied both. Console output
reported that no Steam window or main-menu root was available. The panel did
not show a usable browser view in the audited state.
Static inspection of this archive's published Python backend shows
`set_main_tab()` requesting a Steam GamepadUI CDP tab, then returning a key and
port for its own WebSocket server. That Steam tab and browser injection path
are outside Framey's isolated panel. No real plugin backend was executed, no
browser tab or navigation was verified, and no real Frame or worn-VR check was
performed for this change. The catalog retains **broken** for this digest; its
reason and focused observation now distinguish successful initialization from
the unresolved core browser dependency. The full 116-plugin browser baseline
still refers to its earlier host commit; this is a one-plugin follow-up.
## Unworn Framey regression gate
The exact code commit `d5e62f643d462b7070f27c2c47b11b4aa9982094` ran
`scripts/framey-vr-check.py --exercise` under its own shared-lock owner at
07:26 UTC. Preflight found SteamVR activity 3 (standby), proximity 6.25 and
battery 100%. The real Decky sample setting saved `enabled=true`, survived a
fresh iframe, and the quick action read the saved value. A trusted synthetic
OpenVR pointer, slider value 50, CDP text entry after focus and changed stereo
compositor hashes passed. The mirror capture SHA-256 was
`4f0d977fab7c144a3490b12ebefddacf3126db585057eb022006ffdf844a7498`.
The command exited 0 with `passed=true`; original power settings (`false` and
`5.000000`) were read back, battery ended at 99%, the recovery timer was
removed, cleanup errors were empty and only its own lock was released. The
private full report is `/tmp/framey-web-browser-vr-d5e62f6/result.json` on the
test host. This verifies the sample-panel regression path, not the official
Web Browser plugin or a worn headset. Synthetic native scroll still did not
move.
@@ -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

+4
View File
@@ -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
+102
View File
@@ -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

+212
View File
@@ -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
}
}
}
+25
View File
@@ -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.
+45
View File
@@ -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.
+23
View File
@@ -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.
+62
View File
@@ -0,0 +1,62 @@
# v0.4.42 tagged release, 26 October 2026
The annotated `v0.4.42` tag dereferences to main merge commit
`98909cb54374a7bd7ec1f665f50eda20fa196f32` from PR #302. The app and
lockfile both report 0.4.42. The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40276685171)
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs.
The Linux job launched its built AppImage under isolated Xvfb. CI does not prove
a physical Windows or Mac launch.
## Real Frame, exact tag
From an exact tag checkout, `scripts/frame-smoke.sh` acquired its own unique
owner in `/tmp/frame-test.lock` before reading the headset. Preflight found
SteamVR activity 3 (standby), proximity 6.25 and battery 100%. On Frame OS 0.5.6
build `20261022.6153909`, it recorded **12 passed / 1 skipped / 0 failed** and
exited 0. ARM64 and Proton test executables launched. The x86_64 title was
installed and removed, but its launch was skipped because optional Steam Linux
Runtime 4 was absent. Test title/process cleanup passed and only the run's lock
was released. The private result is
`tests/smoke/results/2026-10-26T055940Z-20261022.6153909.json` in the exact tag
checkout.
The separate [unworn Framey candidate exercise](v0442-candidate-2026-10-26.md)
passed real sample setting save/reopen, synthetic OpenVR pointer delivery and
stereo compositor change, with power restored and cleanup complete. It was run
on the release branch's code before the evidence-only final commit. No person
wore the Frame. Neither run verifies official plugin VR panels, physical
controller usability, native scrolling, VR keyboard or optical comfort. The
hourly worn check remains enabled.
## Installer, checksum and updater checks
The draft held ten installers plus `SHA256SUMS`, each with a GitHub SHA-256
digest. `scripts/publish-release.sh --dry-run v0.4.42` checked the exact remote
tag and every installer digest, regenerated the checksum list from those
metadata digests, and matched the uploaded `SHA256SUMS` digest. A fresh
checksum-list download independently matched
`e5c85f5a46d789dccafae5259bd00b9b048dd10ca4f2a6cdc52d1f432a6db989`.
A fresh Linux x86_64 AppImage download matched
`16fc9fbe448bccbe59aa4206bc171c6bfc741973d2b4da0a79652a5c11d837b3`.
`scripts/check-appimage-launch.py` launched those exact bytes under isolated
Xvfb: the bundled server answered HTTP 200 with `FrameControl/1` and then
stopped. Fresh Mac ZIP downloads matched GitHub digests: arm64
`394d6088e732a19289b7b92366dd247f14f7293a345d57ed5c4e9c83a50abad3`
and x64 `66d4606e38210d02af59dc87e4d1216282ad9e968056d7096f247fcc51e72395`.
Both bundles report version 0.4.42; their main Mach-O CPU types are arm64 and
x86_64 respectively. Neither tagged Mac ZIP was launched on a physical Mac.
`scripts/publish-release.sh v0.4.42` published
[v0.4.42](https://github.com/saphid/frame-control/releases/tag/v0.4.42) at
2026-10-26 06:02:20 UTC. GitHub's latest-release API read back `draft=false`,
`prerelease=false`, the expected tag, ten installers, `SHA256SUMS` and
`update.json`. A fresh public latest-download of `update.json` parsed as
version 0.4.42 with the stable release page and ten SHA-256 installer digests.
The tag contains #299, #300, #301 and #303 through #302's release merge.
#304 merged after the tag and is not in this release.
The exact-version official Decky table for v0.4.42 is **3 works / 48 partial /
54 broken / 11 N/A**. The two additional works labels are restricted to tested
per-app silent-stream volume control. Audible playback and official-plugin
worn VR remain unverified.
@@ -0,0 +1,7 @@
# v0.4.43 candidate Frame gate, 26 October 2026
The combined code head `232b253e7a9400aa9829f9fea6a5d2011dd2a8f3` (PR #309 after merging main with PR #308) ran `scripts/frame-smoke.sh` under its own `/tmp/frame-test.lock` owner at 07:57 UTC. Its locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, it recorded **12 passed / 1 skipped / 0 failed**. The ARM64 and Proton executables launched; x86_64 launch was skipped because Steam Linux Runtime 4 is absent. It removed its test titles, exited 0 and released its lock. The private detailed result is `/tmp/frame-smoke-pr309/2026-10-26T075752Z-20261022.6153909.json` on the test host. A fresh, separately owned read-only lock then found activity 3 and battery 99%, and was released. PR #310 subsequently changed only the Windows wireless CI smoke test; the release branch merged that test change and bumped the app version, with no further headset runtime code change after this Frame smoke.
PR #308's panel code commit `d5e62f643d462b7070f27c2c47b11b4aa9982094` previously passed the temporary Framey sample `scripts/framey-vr-check.py --exercise` machine check, with cleanup and power restoration; see [Web Browser follow-up](framey-web-browser-2026-10-26.md). That test used synthetic OpenVR input and a repository sample, not the official Web Browser plugin or a person wearing the headset. No wake or VR input exercise ran on this combined code head because wearer confirmation was absent. The current smoke made no worn visual, physical-controller, audible-output or official-plugin VR claim.
This is candidate evidence, not a published-release result. Publication still needs all exact-head PR and release checks, ten tagged installers with matching `SHA256SUMS`, package validation, the publisher dry run, and public updater readback. The exact-version official Decky catalog in this candidate is **4 works / 49 partial / 52 broken / 11 N/A**. Volume Boost's works claim covers tested muted sink controls; Junk-Store remains partial, and Web Browser remains broken.
+79
View File
@@ -0,0 +1,79 @@
# v0.4.43 tagged release, 26 October 2026
The annotated `v0.4.43` tag dereferences to main merge commit
`b4e632b218eed0761db488061e3fe6c503b06a0a` from PR #311. Both the app
and lockfile report 0.4.43. The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40292211650)
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs.
The [tagged main checks](https://github.com/saphid/frame-control/actions/runs/40292128084)
passed all seven jobs, including iOS and fake-Frame end-to-end tests. The Linux
builder launched its AppImage under isolated Xvfb; that does not prove a physical
display, FUSE mount or headset connection.
## Frame and package gates
The [combined-head candidate](v0443-candidate-2026-10-26.md) passed the real
Frame smoke before tagging: 12 passed, one optional x86 launch skipped, zero
failed. The only later changes before the tag were the Windows wireless CI
test and version metadata. Its locked preflight found SteamVR activity 3
(standby), proximity 6.25 and battery 99%.
The draft had ten installers plus `SHA256SUMS`. Each installer had a GitHub
SHA-256 digest. A fresh downloaded checksum list contained exactly those ten
names and digests; its own downloaded bytes matched GitHub's digest
`57f211d8bbf76db8aa96cc4a5ae31410ed7e3075ce2e8c0c17394feb9426d01c`.
`scripts/publish-release.sh --dry-run v0.4.43` passed the exact remote tag,
installer and checksum gates after the notes were attached.
Freshly downloaded packages passed these independent checks:
| Package | Result |
| --- | --- |
| Linux x86_64 AppImage | SHA-256 `d5e6c2272a14a2aa77aa932db43f3ce887e09eb78c87bf2de410832b5df4827d`; launched under isolated Xvfb, bundled server returned HTTP 200 with `FrameControl/1`, and stopped on exit. |
| Mac arm64 ZIP | SHA-256 `c6c4a9c74644c3479d1af112ec60d4c2770201fc366f21bca23a4e2fcc86194a`; ZIP CRC passed, bundle version 0.4.43, main Mach-O CPU arm64. |
| Mac x64 ZIP | SHA-256 `58dfc88bb71f1733add7477f9cd888417e4827e608fe910017837ba466a72a40`; ZIP CRC passed, bundle version 0.4.43, main Mach-O CPU x86_64. |
| Windows x64 Setup | SHA-256 `8c17522b0e96d54f53949f4df56a3e1ec1fe2da8df595482a358a3d014b6f1a9`; PE/Nullsoft installer format. The tagged Windows CI also installed and checked its packaged files and Python libraries. |
| Linux arm64 DEB | SHA-256 `c00f141b6b63344c629bbd659a366a74705c52061d83572ae235a75ac6618df0`; package version 0.4.43 and architecture arm64. |
Tagged Mac and Windows packages were not launched on physical machines in this
release gate. Earlier Intel Mac acceptance covers an older version, not these
tagged bytes.
## Publication and later main
`scripts/publish-release.sh v0.4.43` published
[v0.4.43](https://github.com/saphid/frame-control/releases/tag/v0.4.43) at
2026-10-26 08:39:09 UTC. GitHub's latest-release API returned `draft=false`,
`prerelease=false`, the expected tag, ten installers, `SHA256SUMS` and
`update.json`. A fresh public latest-download of `update.json` parsed as
version 0.4.43 with the stable release page, ten hashed assets and the worn-VR
limits in its notes. The release catalog is **4 works / 49 partial / 52 broken /
11 N/A**. Volume Boost's works label covers tested muted sink controls only.
PRs #312 and #313 merged **after** the tag and are absent from v0.4.43. On their
combined main commit `c50f96510fdb42a2aee227436a04c0cfe8817919`, a second
locked `scripts/frame-smoke.sh` run at 08:37 UTC found activity 3, proximity 6.25
and battery 99%, then passed 12 checks with the same optional x86 launch skip
and zero failures. Test title and process cleanup passed; the command exited 0
and released only its owned lock. The private result is
`tests/smoke/results/2026-10-26T083719Z-20261022.6153909.json` in the test
checkout. This post-tag smoke does not test the new Framey scroll buttons or
Junk-Store's deployed headset panel.
The guarded `scripts/framey-vr-check.py --exercise` then ran on the same code
commit, using the existing direct `frame` SSH alias and its own shared-lock owner.
It began at 08:45 UTC in SteamVR standby (activity 3, proximity 6.3125,
battery 99%). The temporary sample panel passed setting save/reopen, quick
action, slider and text checks. Synthetic native OpenVR clicks on the visible
**Scroll plugin down/up** buttons moved the 160-pixel test region 0 → 120 → 0;
the compositor screenshot hash changed. Native synthetic wheel attempts still
produced no wheel event or movement. The private report is
`/tmp/framey-scroll-postmerge-direct-20261026/result.json` on the test host.
The command exited 0, removed its test services and power watchdog, read back
the original `false` / `5` power settings, found battery 99%, and released its
owned lock with no cleanup errors. It did not exercise an official plugin or a
physical controller.
No wearer confirmation was available, so no worn check, wake or input exercise
ran. Physical controller use, optical readability, official-plugin VR panels,
audible output and VR keyboard remain unverified. The hourly worn-check schedule
stays enabled.
@@ -0,0 +1,56 @@
# v0.4.44 candidate gate, 26 October 2026
This candidate starts from main commit
`c50f96510fdb42a2aee227436a04c0cfe8817919`, which contains PR #312's
Framey scroll buttons and PR #313's read-only installed-app metadata projection.
It also carries the documentation-only v0.4.43 release evidence from PR #314.
The app and lockfile are bumped to 0.4.44. No compatibility label changes:
the exact-version official Decky catalog remains **4 works / 49 partial /
52 broken / 11 N/A**. Junk-Store's About and Dependencies pages now render in
the local Chromium test with its actual ARM64 backend; the plugin remains partial.
## Real Frame checks on the combined code
At 08:37 UTC, `scripts/frame-smoke.sh` ran on that main commit under its own
`/tmp/frame-test.lock` owner. Locked preflight found SteamVR activity 3
(standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build
`20261022.6153909`, ARM64 and Proton executables launched; the optional x86_64
launch was skipped because Steam Linux Runtime 4 is absent. The result was
**12 passed / 1 skipped / 0 failed**. It removed its test titles, exited 0 and
released its owned lock. The private result is
`tests/smoke/results/2026-10-26T083719Z-20261022.6153909.json` on the test
host.
At 08:45 UTC, `scripts/framey-vr-check.py --exercise` ran the same code under
a separate unique shared-lock owner, beginning in standby (activity 3,
proximity 6.3125, battery 99%). Synthetic native OpenVR clicks on the new
**Scroll plugin down/up** buttons moved a temporary sample region 0 → 120 → 0.
The sample setting save/reopen, quick action, slider, text and compositor-change
checks passed. Native synthetic wheel attempts still produced no event or
movement. Cleanup removed test services and its power watchdog, restored the
original `false` / `5` power values, read battery 99%, and released its lock
with no errors. The private report is
`/tmp/framey-scroll-postmerge-direct-20261026/result.json` on the test host.
This is an unworn sample panel check. It does not prove physical controller
scrolling, optical readability or an official-plugin panel in VR.
## Publication boundary
The version-bumped release branch also ran `scripts/frame-smoke.sh` after its
normal merge of PR #314's documentation. At 08:59 UTC, its locked preflight
found activity 3, proximity 6.3125 and battery 97%. The result was again
**12 passed / 1 skipped / 0 failed** on the same Frame OS build; ARM64 and
Proton launches passed, while the optional x86 launch was skipped for the
missing Steam Linux Runtime 4. Owned test title/process cleanup passed, the
command exited 0, and its lock was released. The private result is
`tests/smoke/results/2026-10-26T085951Z-20261022.6153909.json` on the test
host. This evidence-only paragraph is the sole change after that smoke.
PR #313's own actual ARM64 backend/library test is documented in
[installed-context evidence](framey-installed-context-2026-10-26.md). Its
dependency installation, login, game operations, RSS and optional websocket
remain unverified. The candidate still needs exact-head CI, tagged builds,
ten installer digests with `SHA256SUMS`, downloaded package checks, publisher
dry run and public updater readback before publication. A worn check requires
activity, proximity and reliable wearer confirmation; none was available, so
the hourly schedule stays enabled.
+58
View File
@@ -0,0 +1,58 @@
# v0.4.44 tagged release, 26 October 2026
The annotated `v0.4.44` tag dereferences to main merge commit
`37e25e62ace388ce032f47d08398c26ea8540fdb` from PR #315. The app and
lockfile both report 0.4.44. [Post-merge main checks](https://github.com/saphid/frame-control/actions/runs/40298400256)
passed all seven jobs, including iOS and fake Frame end-to-end tests. The
[tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40299460805)
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum
jobs. The tagged Linux build ran the AppImage under isolated Xvfb; this does
not verify a physical display or headset.
## Frame and package gates
The [candidate evidence](v0444-candidate-2026-10-26.md) records a shared-lock
real Frame smoke on the version-bumped candidate: **12 passed / 1 optional x86
launch skipped / 0 failed**, with owned cleanup and lock release. The x86
launch was skipped because Steam Linux Runtime 4 is absent. The candidate
also records an unworn sample panel exercise: synthetic OpenVR clicks moved a
temporary scroll region down and back, while a synthetic native wheel did
not scroll. No physical controller or wearer-visible result follows from it.
The final candidate edit was evidence only, and the merge commit added no
runtime changes.
The draft contained ten installers and `SHA256SUMS`. Every installer had a
GitHub SHA-256 digest. A freshly downloaded `SHA256SUMS` listed exactly those
ten names and matching digests; its downloaded bytes matched GitHub's digest
`072f8084d6f9f145f4a58457d10f54390b50d11288bece42f6d801a356a69b1c`.
After the release notes were attached, `scripts/publish-release.sh --dry-run
v0.4.44` passed the remote tag, ten installer and checksum gates.
Freshly downloaded packages passed these independent checks:
| Package | Result |
| --- | --- |
| Linux x86_64 AppImage | SHA-256 `d1cb5c3b590a44a9c61cf1d38d546cf89a835c73bece839c639f0d2be70ff139`; launched under isolated Xvfb, bundled server returned HTTP 200 with `FrameControl/1`, and stopped on exit. The first local attempt was blocked by the sandbox; the same downloaded bytes passed outside it. |
| Mac arm64 ZIP | SHA-256 `5a25dfea9b3c645dae7acaf660b7127e9a46a03173a69d02ef4c64b0b39a5934`; ZIP CRC valid, bundle version 0.4.44, main Mach-O CPU arm64. |
| Mac x64 ZIP | SHA-256 `fde1c48eef4e3b4be12a862d32c1154afd1a93fe1f27a32f0799365205655021`; ZIP CRC valid, bundle version 0.4.44, main Mach-O CPU x86_64. |
| Windows x64 Setup | SHA-256 `e8cae0a8d0f8b76a41ca109f5ec4bf9947ca31531a8b93ebb8e326e453602a16`; PE/Nullsoft installer format. The tagged Windows CI also checked its packaged files and Python libraries. |
| Linux arm64 DEB | SHA-256 `40f1704af89b5b824bc28e93328c516306aaefb2e0f441c1866197fc953eb21f`; package version 0.4.44 and architecture arm64. |
The tagged Mac and Windows packages were not launched on physical machines
in this release gate. Earlier Intel Mac acceptance covers older bytes.
## Publication and limits
`scripts/publish-release.sh v0.4.44` published
[v0.4.44](https://github.com/saphid/frame-control/releases/tag/v0.4.44) at
2026-10-26 09:38:50 UTC. GitHub's latest-release API returned `draft=false`,
`prerelease=false`, the expected tag, ten installers, `SHA256SUMS` and
`update.json`. A fresh public latest-download of `update.json` reported
version 0.4.44, the stable release page and ten hashed assets. Its notes
retain the worn-VR limits. The exact-version official Decky catalog remains
**4 works / 49 partial / 52 broken / 11 N/A** out of 116; Junk-Store remains
partial.
No reliable wearer confirmation was available. Official-plugin VR rendering,
physical controller use, optical readability, audible output and VR keyboard
remain unverified. The hourly shared-lock worn-check schedule stays enabled.
@@ -0,0 +1,84 @@
# v0.4.45 candidate gate, 26 October 2026
The candidate starts from main commit
`f88e2ed209167a836282a969a71df4c897e658a8`, after PR #316 bound
Framey chunk URLs to installed store receipts and PR #317 fixed shared Decky
activation and confirmation controls. PR #319 also added a bounded retry to
the fake Frame test image after Valve's ARM64 package server repeatedly
truncated downloads. The app and lockfile are bumped to 0.4.45. The official
116-plugin catalog remains **4 works / 49 partial / 52 broken / 11 N/A**.
AlarMe remains partial: its silent timer, preset and stopwatch paths were
exercised with the official ARM64 backend, while sound, reminders, sleep
controls, closed-panel notifications and worn VR were not verified.
## Locked real Frame smoke
At 10:26 UTC, `scripts/frame-smoke.sh` ran on that combined main commit after
atomically acquiring its own `/tmp/frame-test.lock`. Read-only preflight found
SteamVR activity 3 (standby), proximity 6.3125 and battery 95%. On Frame OS
0.5.6 build `20261022.6153909`, ARM64 and Proton test titles installed,
launched and were removed. The optional x86_64 title installed and was
removed, but its launch was skipped because Steam Linux Runtime 4 is absent.
The result was **12 passed / 1 skipped / 0 failed**. Owned process and title
cleanup passed, the command exited 0 and released its lock. The private
detailed report is
`tests/smoke/results/2026-10-26T102653Z-20261022.6153909.json` in the
test checkout.
## Unworn Framey panel exercise
At 10:29 UTC, `scripts/framey-vr-check.py --exercise` ran the same combined
main commit under a separate random shared-lock owner. It began in standby
with proximity 6.3125 and battery 95%. The temporary managed panel, Decky
service and repository sample passed the shipped host's setting save and
fresh-iframe reopen, quick action, slider and text checks. A trusted
synthetic OpenVR pointer reached the panel. The scroll buttons moved a
temporary test region **0 → 120 → 0**; synthetic native wheel input still
produced no wheel event or movement. The stereo compositor image changed and
the `/dev/video99` mirror snapshot contained varied, nonblack pixels. This
single mirror image does not establish moving passthrough content or what a
wearer sees. The direct overlay image read returned error 24 and zero size,
so it is not used as texture evidence.
The private report and room-bearing images are in the test host's temporary
output folder. Reported hashes are stereo before
`84fe777535870c54cb381bbee3223684d1e0e4f2e6626e6d74ee902a4eec9c1b`,
stereo after
`4666d2b0908657489b8cc21520afc3deed4afa26a4c555c782298992b72abdd1`,
and mirror
`0769bbc45bd37266cc9ad80154cbd8a44ae3e1577cfa0d419e6af012afbf0fb4`.
No raw image is committed.
The exercise exited 0 with no cleanup errors, removed its temporary services
and power recovery timer, restored and read back the original
`power.pauseCompositorOnStandby=false` and
`power.turnOffScreensTimeout=5`, found battery 95% and released only its lock.
Its immediate post-run activity was 0 after the temporary wake. A separate
locked read-only check at 10:31 UTC found activity **3 standby**, proximity
6.3125, battery 95%, no cleanup errors, and released its lock.
The sample is not an official plugin. This exercise does not test the new
receipt-bound chunk path on real hardware or show AlarMe inside VR. Backend
registration, synthetic pointer delivery and changing compositor pixels do
not prove physical-controller or wearer-visible usability. No wearer
confirmation was available, so `scripts/framey-worn-check.py` was not run;
the hourly worn-headset schedule remains enabled.
## Publication boundary
The version-bumped candidate commit `59ea093` also ran
`scripts/frame-smoke.sh` under its own shared-lock owner at 10:34 UTC. Its
locked preflight again found activity 3 standby, proximity 6.3125 and
battery 95%. The result was **12 passed / 1 optional x86 launch skipped /
0 failed** on the same Frame OS build. Owned title/process cleanup passed,
the command exited 0 and released its lock. The private result is
`tests/smoke/results/2026-10-26T103419Z-20261022.6153909.json` in the test
checkout. This evidence-only paragraph is the sole change after that smoke.
This is candidate evidence, not a published-release result. Exact-head PR
and post-merge CI, tagged installers, all ten installer digests with
`SHA256SUMS`, independent package checks, publisher dry run and public
`update.json` readback remain required before publication. Physical Mac and
Windows launch of the eventual tagged bytes, audible alarms, official-plugin
VR panels, optical readability, physical controllers, haptics and the VR
keyboard remain unverified.
+5 -2
View File
@@ -63,10 +63,13 @@ none of these frontend controls has passed a worn-headset check yet.
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Plugins may still use ordinary CSS overflow containers. | Parent/iframe message tests; physical controller scroll remains 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 |
+22 -22
View File
@@ -1,10 +1,10 @@
# Official Decky store compatibility with Framey
Snapshot: 2026-10-25T23:19:48.854504+00:00; 116 current visible official plugins from https://plugins.deckbrew.xyz/plugins.
Snapshot: 2026-10-26T06:10:58.537029+00:00; 116 current visible official plugins from https://plugins.deckbrew.xyz/plugins.
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
**Works: 1 · Partial: 45 · Broken: 59 · N/A: 11.**
**Works: 4 · Partial: 49 · Broken: 52 · 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. 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. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. 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. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional Line truncated
## 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,28 +55,28 @@ 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 | RunningApps and appStore are absent; Discord activity integration fails after initialization. | static archive, local browser, fake-Frame |
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | partial | 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 |
| 46: Steamback 1.0.0-1 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of undefined (reading 'm_CurrentUser'). Steam account/app context is unavailable. | static archive, local browser, fake-Frame |
| 46: Steamback 1.0.0-1 | legacy / legacy | partial | Panel opens using the real Frame current-user/library snapshot. Account setup and empty save-list reads pass through actual Decky 3.3.0 amd64 in isolation. ARM64 backend, supported-save discovery, automatic backups and restore remain unverified; no backup/restore ran. | static archive, local browser, fake-Frame, real Frame SSH |
| 48: Quick Launch 1.2.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | Empty-data panel rendered; cleanup was deliberately not run because it can remove local data. | static archive, local browser, fake-Frame |
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'type'). | static archive, local browser, fake-Frame |
| 53: Shotty 0.1.0 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of undefined (reading 'allApps'). | static archive, local browser, fake-Frame |
| 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 |
| 59: Web Browser 1.4.3 | legacy / legacy | broken | Frontend audit failed: deckyFrontendLib.getReactRoot is not a function. | static archive, local browser, fake-Frame |
| 58: Volume Boost 0.0.7 | legacy / legacy | works | Default-sink controls pass in local Chromium through real Decky 3.3.0 ARM64: linked slider/keyboard and numeric writes, separate channels, re-link and reopen, with independent Pulse readback including 125%/150% while muted. Exact original volume/mute restored. Audible output, Bluetooth sinks 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 | The exact frontend now initializes with a null Steam React-root lookup. The browser itself still requires Steam GamepadUI CDP injection and a backend WebSocket unavailable in Framey; no working browser tab was verified. | static archive, local browser, fake-Frame, focused local browser |
| 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 | broken | Private SteamClient JSON cache now works; initialization still fails on missing Steam appStore.m_mapApps. Live app data and MobX game observations are unavailable; backend actions were denied. | static archive, local browser, fake-Frame |
| 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 | partial | Official Decky 3.3.0 ARM64 static menu/backend calls pass; local Chromium Games/Epic navigation, About/Dependencies tabs, Back and reopen render with real Frame library metadata. Legendary is absent; dependency install, login, game operations, RSS, optional websocket and worn VR remain unverified. Existing installs need update/reinstall for script permissions. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
| 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 |
@@ -99,7 +99,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 90: Picture in Picture 1.0.0 | 1 / 2 | partial | Initializer succeeds; global picture-in-picture injection cannot escape the isolated panel. | static archive, local browser, fake-Frame |
| 91: PlayCount 1.7 | 1 / 2 | partial | Initializer succeeds; Steam game-page play-count patch has no supported VR target. | static archive, local browser, fake-Frame |
| 92: Crosshair 1.1.0 | 1 / 2 | partial | Initializer succeeds; global crosshair injection has no supported compositor target. | static archive, local browser, fake-Frame |
| 93: Deck Settings 1.2.6 | 1 / 2 | broken | Real Steam install folders are now available; game selection still fails on missing collectionStore.allGamesCollection. Steam library context, proxy fetch and backend actions remain unsupported/unverified. | static archive, local browser, fake-Frame |
| 93: Deck Settings 1.2.6 | 1 / 2 | partial | Game picker renders with detached real Frame library/install-folder reads; search modal opens in local Chromium. Snapshot refreshes on panel reopen. Remote reports/proxy fetch, settings changes and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 94: Decky DoT DNS 0.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 95: Decky-SpoofDPI 0.12.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-spoof-dpi/bin/spoofdpi. | static archive, local browser, fake-Frame |
| 96: Simple Timer 1.0.0 | 1 / 2 | partial | Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
@@ -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 |
@@ -129,7 +129,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 120: Reroll 0.2.1 | 1 / 2 | broken | Frontend now initializes with inert game-action subscriptions, but the archive contains x86_64 memscan.so and requests root. No process-memory access was run. | static archive, local browser, fake-Frame |
| 121: DeckPass 1.1.1 | 1 / 2 | partial | Initializer succeeds; KeePass Flatpak discovery and secrets integration were not exercised. | static archive, local browser, fake-Frame |
| 123: Steam Status 1.2.3 | 2 / 2 | partial | Settings/public status rendered locally; cache errors fixed. Read-only get_settings RPC returned actual Decky 3.3.0 defaults on ARM64 Frame. Persistence, live backend diagnostics and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 124: AlarMe 1.6.3 | 1 / 2 | partial | Initializer succeeds; timers, reminders, suspend inhibition and audio are unverified. | static archive, local browser, fake-Frame |
| 124: AlarMe 1.6.3 | 1 / 2 | partial | Silent labeled timer create/ticks/pause/cancel, preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with the official Decky 3.3.0 ARM64 backend. Audio, alarms/reminders, suspend inhibition, Steam overlays and worn VR remain unverified; notifications require the panel to stay open. | static archive, local browser, fake-Frame, real Frame SSH |
| 125: Decky Localsend 0.4.5 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core. | static archive, local browser, fake-Frame |
| 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
| 127: Decky Translator 0.9.0 | 1 / 2 | partial | Initializer succeeds; x86_64 download and hidraw/controller assumptions need an ARM64 audit. OCR/translation not exercised. | static archive, local browser, fake-Frame |
@@ -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
@@ -252,7 +252,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
| Reroll | Apps.RegisterForGameActionUserRequest, GameSessions.RegisterForAppLifetimeNotifications, Settings.GetCurrentLanguage | ButtonItem, DialogButton, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, SliderField, libraryAssetImageClasses, mainMenuAppRunningClasses, quickAccessMenuClasses, showModal | SteamUIStore, QAM/global tabs, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| DeckPass | Apps.AddShortcut, Apps.RunGame, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Input.ControllerKeyboardSendText | Button, ButtonItem, DialogButton, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, TextField, findSP, showModal, sleep, staticClasses | Router, appDetailsStore, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | flatpak |
| Steam Status | none detected | ButtonItem, ConfirmModal, DialogButton, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, showModal, staticClasses | toaster, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| AlarMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SidebarNavigation, SliderField, TextField, ToggleField, findModuleChild, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| AlarMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SidebarNavigation, SliderField, TextField, ToggleField, findModuleChild, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | Timers/stopwatch use Python. Optional sleep inhibition opens /dev/uinput; sound and auto-suspend are unverified. Steam game overlays have no target in the isolated panel. |
| Decky Localsend | none detected | ButtonItem, DialogBody, DialogButton, DialogButtonPrimary, DialogFooter, DialogHeader, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, TextField, ToggleField, showModal, staticClasses | Router, routerHook, toaster, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
| Launch Options | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogButtonPrimary, DialogControlsSection, DialogControlsSectionHeader, DialogFooter, DialogHeader, Dropdown, Field, Focusable, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanel, SidebarNavigation, SteamSpinner, Tabs, TextField, Toggle, ToggleField, afterPatch, createReactTreePatcher, fakeRenderComponent, findInReactTree, findInTree, findModule, findModuleByExport, showModal, staticClasses, useParams | SteamUIStore, routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | flatpak; gamescope |
| Decky Translator | User.RegisterForPrepareForSystemSuspendProgress | ButtonItem, DialogButton, Dropdown, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, findModuleChild, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | /dev/hidraw; /sys/class/hidraw/hidraw{i}; /sys/class/hidraw/hidraw{i}/device/uevent; x86_64 |
+74 -11
View File
@@ -16,6 +16,10 @@ plugins → Open official store**. Search the catalogue, confirm an install, and
choose **Open panel** from the installed plugin list. The panel contains the
plugin's own controls and settings. Update and Remove affect only plugin
folders that Frame Control's store manager installed.
Use **Scroll plugin up** and **Scroll plugin down** above an open panel when
wheel scrolling is unavailable. Point at a scrollable part of the plugin first
to move that section; otherwise the buttons move the panel document. Each
press moves about three quarters of the visible section and stops at its edge.
Removing a plugin keeps its Decky settings. Removing the Frame Control headset
panel or Decky backend keeps plugin folders until each is removed separately.
@@ -70,10 +74,23 @@ 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,
and backend calls for the plugin that owns the panel. Steam library/store page
and backend calls for the plugin that owns the panel. The headset's scroll
buttons send only a direction to the isolated iframe. They target the last
scrollable region reached by a trusted pointer move and fall back to the iframe
document when that region disappears. A result message reports whether content
moved; this bridge has no backend or Steam privilege. Steam library/store page
patches, global Steam UI components, file pickers, and privileged root methods
have no VR target. A plugin that depends on them can render only part of its UI
or show an incompatibility message. A Decky plugin's Python backend still runs as
@@ -106,9 +123,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 +133,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 +201,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 +212,19 @@ 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.
Chunked frontends load modules through URLs bound to the installed store
receipt. If a desktop store update replaces the plugin during panel startup,
old module requests fail and the panel must be reopened; Framey does not mix
chunks from two installed versions.
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 +245,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)
+10 -1
View File
@@ -53,7 +53,16 @@ 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.
The exercise then clicks the visible **Scroll plugin down/up** buttons through
the same native OpenVR pointer transport and requires the test region to move
down and return to the top. This proves the button path only; it does not
identify a physical controller or repair native wheel delivery.
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
View File
@@ -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`,
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+10 -1
View File
@@ -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);
}
}
+49 -6
View File
@@ -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;
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,18 +45,24 @@ 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:steam')event.source.postMessage({type:'framey:reply',id:data.id,error:'Read-only compatibility audit: live Steam API blocked'},'*');
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'})},'*');
if(data.type==='framey:call'){
if(!active.methods.includes(data.method))active.methods.push(data.method);
event.source.postMessage({type:'framey:reply',id:data.id,error:'Read-only compatibility audit: backend method blocked'},'*');}
if(data.type==='framey:cache:init')event.source.postMessage({type:'framey:cache:seed',session:data.session,entries:{}},'*');
});
window.runAudit=async(ids)=>{
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:[]};
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],
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;
@@ -72,9 +78,25 @@ def main():
parser.add_argument('--cache', type=Path, required=True)
parser.add_argument('--catalog', type=Path, default=ROOT/'ui/framey-compatibility.json')
parser.add_argument('--port', type=int, default=18765)
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}
library = None
if args.library_snapshot:
raw = args.library_snapshot.read_bytes()
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, *_):
@@ -83,10 +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).encode()
body = PAGE.replace('CATALOG_DATA', catalog).replace(
'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])]
@@ -101,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',
@@ -112,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)
+15 -2
View File
@@ -54,8 +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. '
'Other official backends remain unexecuted. '
'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. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. 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. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional websocket and worn VR are unverified. 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 '
@@ -63,6 +72,10 @@ def render(data):
'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. '
'A [Web Browser frontend follow-up](evidence/framey-web-browser-2026-10-26.md) verified its exact archive now initializes with an inert null Steam React-root lookup. Its GamepadUI CDP/browser backend path remains unavailable, so it stays broken. '
'A [dialog and activation follow-up](evidence/framey-dialog-controls-2026-10-26.md) fixes DFL Focusable pointer/keyboard activation and confirmation descriptions, cancel/disabled actions, scoped close callbacks and nested dialogs. '
'AlarMe silent timer ticks/pause/cancel, private preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with its official Decky 3.3.0 ARM64 backend. '
'AlarMe stays partial: audio, alarms/reminders, sleep inhibition, Steam overlays and worn VR are unverified; notifications require the panel to remain open. '
'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 '
+10 -2
View File
@@ -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:
+4 -2
View File
@@ -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):
+61 -7
View File
@@ -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)
@@ -67,10 +71,26 @@ def exercise(profile, plugin_id, folder):
if not hit:
raise RuntimeError('Native click hit another element: ' + str(panel.eval('({tag:__vrLastTarget?.tagName,text:__vrLastTarget?.textContent?.slice(0,100)})')))
def click_parent(selector):
parent.eval(f'document.querySelector({json.dumps(selector)}).scrollIntoView({{block:"center"}})')
time.sleep(.3)
pos = parent.eval(f'''(()=>{{let r=document.querySelector({json.dumps(selector)}).getBoundingClientRect();
return {{x:r.x+r.width/2,y:r.y+r.height/2,w:innerWidth,h:innerHeight}}}})()''')
sx, sy = vr.snapshot()['input']['scale']
x, y = pos['x'] * sx / pos['w'], sy - pos['y'] * sy / pos['h']
count = parent.eval('window.__vrParentClickCount')
vr.post_mouse(300, x, y)
vr.post_mouse(301, x, y, 1)
vr.post_mouse(302, x, y, 1)
wait(lambda:parent.eval('window.__vrParentClickCount'), lambda n:n > count, seconds=3)
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
raise RuntimeError('Native click missed parent control ' + selector)
try:
parent.eval(f'(async()=>{{await refreshDecky();openFrameyPanel({plugin_id},"Framey VR Check");return true}})()')
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
panel = connect_panel()
parent.eval('window.__vrParentClickCount=0;addEventListener("pointerup",e=>{window.__vrParentClickCount++;window.__vrParentLastTarget=e.target;});true')
initial = wait(text, lambda s:'Saved setting: off' in s or 'Could not read setting:' in s)
if 'Could not read setting:' in initial: raise RuntimeError(initial)
result['initialText'] = initial
@@ -113,19 +133,53 @@ 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']
click_parent('#frameyScrollDown')
result['buttonScrollAfterDown'] = wait(
lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n > 0)
click_parent('#frameyScrollUp')
result['buttonScrollAfterUp'] = wait(
lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n == 0)
result['buttonScrollMoved'] = result['buttonScrollAfterDown'] > 0 and result['buttonScrollAfterUp'] == 0
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"})')
result['finalOverlay'] = vr.snapshot()
result['passed'] = (result['trustedOverlayPointer'] and result['diskSetting'] == {'enabled':True}
and result['reopenedChecked'] and result['sliderValue'] != '10'
and result['textValue'] == 'framey-ci')
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved'])
return result
except Exception as exc:
result['error'] = str(exc)
+23 -8
View File
@@ -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)
+7 -2
View File
@@ -10,8 +10,13 @@ ARG BASE=archlinux:base
FROM ${BASE}
RUN (pacman-key --init && pacman-key --populate) >/dev/null 2>&1; \
pacman -Syu --noconfirm --needed openssh rsync python nodejs curl iproute2 procps-ng util-linux \
&& pacman -Scc --noconfirm
attempts=0; \
until pacman -Syu --noconfirm --needed openssh rsync python nodejs curl iproute2 procps-ng util-linux; do \
attempts=$((attempts + 1)); \
[ "$attempts" -lt 5 ] || exit 1; \
sleep 2; \
done; \
pacman -Scc --noconfirm
# The Frame's user is steamos (docs/ssh.md). Its password stands in for the
# Developer Mode password.
Binary file not shown.
+10 -1
View File
@@ -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.
+2
View File
@@ -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.
+257 -7
View File
@@ -3,11 +3,13 @@ import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI;\n})();');
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};
cloneElement:(element,props)=>({...element,props:{...element.props,...props}}),
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;}}
@@ -20,9 +22,13 @@ const window={React,ReactDOM:{createRoot:node=>{
const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)};
const context=vm.createContext({window,document:{getElementById:id=>{
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()},
navigator:{language:'en'}, parent,DOMException,addEventListener:(type,handler)=>listeners.push({type,handler}),
navigator:{language:'en'}, parent,DOMException,getComputedStyle:node=>({overflowY:node.overflowY||'visible'}),
addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(testSource,context);
// No Steam React tree exists inside the isolated Framey iframe.
assert.equal(window.DFL.getReactRoot({}),null);
assert.equal(window.DFL.findInReactTree(window.DFL.getReactRoot(null),()=>true),null);
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
const jsx=window.SP_JSX.jsx('p',{id:'message',children:'Saved'},'message-key');
assert.equal(jsx.type,'p');
@@ -98,14 +104,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 +190,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,10 +259,89 @@ 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);
for(const name of ['App','appStore','appInfoStore','collectionStore'])assert.equal(window[name],undefined);
const snapshot={apps:[[10,'Game Ten','ten',1,'10',180,60,1234],
[20,'Shortcut','shortcut',1073741824,'85899345920',0,0,0]],gameIds:[10]};
window.__library(snapshot);
assert.equal(window.appStore.GetAppOverviewByAppID(10).local_per_client_data,undefined);
snapshot.apps[0].push(true);snapshot.apps[1].push(null);
window.__library(snapshot);
assert.equal(window.appStore.GetAppOverviewByAppID(10).local_per_client_data.installed,true);
assert.deepEqual(Object.keys(window.appStore.GetAppOverviewByAppID(10).local_per_client_data),['installed']);
assert.equal(window.appStore.GetAppOverviewByAppID(20).local_per_client_data.installed,undefined);
window.appStore.GetAppOverviewByAppID(10).local_per_client_data.installed=false;
assert.equal(snapshot.apps[0][8],true); // Detached from the wire data.
window.__library({...snapshot,apps:[snapshot.apps[0].slice(0,8).concat(false),snapshot.apps[1]]});
assert.equal(window.appStore.GetAppOverviewByAppID(10).local_per_client_data.installed,false);
window.__library(snapshot);
assert.equal(window.appStore.allApps.length,2);
assert.equal(window.appStore.GetAppOverviewByAppID('10').display_name,'Game Ten');
assert.equal(window.appStore.GetAppOverviewByGameID('85899345920').appid,20);
assert.equal(window.appStore.GetAppOverviewByGameID('missing'),undefined);
assert.equal(window.collectionStore.allGamesCollection.allApps[0],window.appStore.m_mapApps.get(10));
window.appStore.m_mapApps.get(10).display_name='Plugin-local patch';
assert.equal(snapshot.apps[0][1],'Game Ten');
assert.equal(window.App,undefined); // No fabricated account identity.
window.__library({...snapshot,steamId:'76561198000000001'}); // Explicit test fixture, never a fallback.
assert.deepEqual(Object.keys(window.App.m_CurrentUser),['strSteamID']);
assert.equal(window.App.m_CurrentUser.strSteamID,'76561198000000001');
window.appStore.allApps[0].display_name='Plugin-local patch';
assert.equal(window.appInfoStore,undefined); // No fake MobX store.
for(const invalid of [null,{apps:[],gameIds:[10]},
{...snapshot,steamId:'invented'},
{...snapshot,apps:[snapshot.apps[0],snapshot.apps[0]]},
{...snapshot,apps:[[10,'Name','sort',1,'undefined',0,0,0]]},
{...snapshot,apps:[[10,'Name','sort',1,'10',-1,0,0]]},
...['true',1,{},undefined].map(installed=>({...snapshot,apps:[[10,'Name','sort',1,'10',0,0,0,installed]]})),
{apps:Array(10001).fill(snapshot.apps[0]),gameIds:[]}]){
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].slice(0,8)],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.GetAppOverviewByAppID(10).local_per_client_data.installed,true);
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();
@@ -226,7 +358,86 @@ await assert.rejects(window.SteamClient.Storage.SetObject('undefined',undefined)
assert.equal(window.indexedDB,undefined); // localForage must use bounded plugin storage.
const unpatch=window.DFL.beforePatch({},'method',()=>{});
assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch();
assert.equal(typeof window.DFL.showModal({type:'p'}).Close,'function');
// Official AlarMe uses Focusable onActivate for pause, save and delete, without onClick.
let activated=0, stopped=0;
const activatable=window.DFL.Focusable({onActivate:()=>activated++});
assert.equal(activatable.props.role,'button');assert.equal(activatable.props.tabIndex,0);
activatable.props.onClick({stopPropagation:()=>stopped++});
const ownTarget={};
for(const key of ['Enter',' '])activatable.props.onKeyDown({key,target:ownTarget,currentTarget:ownTarget,
preventDefault(){},stopPropagation:()=>stopped++});
activatable.props.onKeyDown({key:'Enter',target:{},currentTarget:ownTarget,preventDefault(){}});
assert.equal(activated,3);assert.equal(stopped,3);
const inertFocusable=window.DFL.Focusable({onActivate:()=>activated++,disabled:true});
inertFocusable.props.onClick({});
inertFocusable.props.onKeyDown({key:'Enter',target:ownTarget,currentTarget:ownTarget});
assert.equal(activated,3);assert.equal(inertFocusable.props['aria-disabled'],true);
let pointerCalls=0;
window.DFL.Focusable({onClick:()=>pointerCalls++,onActivate:()=>activated++}).props.onClick({stopPropagation(){}});
assert.equal(pointerCalls,1);assert.equal(activated,3);
// Dialog actions must preserve descriptions, cancellation and the dialog they belong to.
const modalRoot=roots.get(elements.get('framey-modal'));
let confirmed=0, cancelled=0, dismissed=0;
const modalHandle=window.DFL.showModal(React.createElement(window.DFL.ConfirmModal,{
strTitle:'Remove preset?',strDescription:'Only this private preset will be removed.',
strOKButtonText:'Remove',strCancelButtonText:'Keep',
onOK:()=>confirmed++,onCancel:()=>cancelled++,closeModal:()=>dismissed++}));
let modal=modalRoot.value.at(-1).children.at(-1);
let confirm=modal.type(modal.props);
assert.equal(nodes(confirm,'div')[0].children[0],'Only this private preset will be removed.');
assert.deepEqual(nodes(confirm,'button').map(button=>button.children[0]),['Remove','Keep']);
nodes(confirm,'button')[1].props.onClick();
await modalHandle.ClosedPromise;
assert.equal(confirmed,0);assert.equal(cancelled,1);assert.equal(dismissed,1);
assert.equal(modalRoot.value,null);
modalHandle.Close();assert.equal(dismissed,1);
// Opening a confirmation from another dialog preserves its state; stale Close handles
// cannot dismiss the newer dialog. Programmatic Close does not call plugin callbacks.
const parentModal=window.DFL.showModal(React.createElement('p',{closeModal:()=>dismissed++}));
const childModal=window.DFL.showModal(React.createElement(window.DFL.ConfirmModal,{
strTitle:'Confirm',bOKDisabled:true,bCancelDisabled:true,bMiddleDisabled:true,
onOK:()=>confirmed++,onCancel:()=>cancelled++,onMiddleButton:()=>confirmed++}));
assert.equal(modalRoot.value.length,2);assert.equal(modalRoot.value[0].props.hidden,true);
modal=modalRoot.value.at(-1).children.at(-1);confirm=modal.type(modal.props);
for(const button of nodes(confirm,'button')){assert.equal(button.props.disabled,true);button.props.onClick();}
assert.equal(confirmed,0);assert.equal(cancelled,1);
let prevented=0;
listeners.find(listener=>listener.type==='keydown').handler({key:'Escape',preventDefault:()=>prevented++});
assert.equal(prevented,1);assert.equal(modalRoot.value.length,2);
parentModal.Close();parentModal.Close();await parentModal.ClosedPromise;
assert.equal(modalRoot.value.length,1);assert.equal(dismissed,1);
childModal.Close();await childModal.ClosedPromise;assert.equal(modalRoot.value,null);
let replacement;
const first=window.DFL.showModal(React.createElement(window.DFL.ConfirmModal,{
onOK:()=>{replacement=window.DFL.showModal(React.createElement('p',null,'Next dialog'));}}));
modal=modalRoot.value.at(-1).children.at(-1);
nodes(modal.type(modal.props),'button')[0].props.onClick();
await first.ClosedPromise;assert.equal(modalRoot.value.length,1);
replacement.Update(React.createElement('p',null,'Updated'));
assert.equal(modalRoot.value[0].children.at(-1).children[0],'Updated');
replacement.Close();await replacement.ClosedPromise;
const alert=window.DFL.ConfirmModal({bAlertDialog:true,onCancel:()=>cancelled++});
assert.equal(nodes(alert,'button').length,1);
const escapeModal=window.DFL.showModal(React.createElement('p',{onCancel:()=>cancelled++}));
listeners.find(listener=>listener.type==='keydown').handler({key:'Escape',preventDefault(){}});
await escapeModal.ClosedPromise;assert.equal(cancelled,2);assert.equal(modalRoot.value,null);
// Updated confirmations retain scoped dismissal and use the replacement's callback.
for (const actionIndex of [0,1]) for (const withCallback of [false,true]) {
let oldCloses=0, updatedCloses=0, updatedActions=0;
const updated=window.DFL.showModal(React.createElement('p',{closeModal:()=>oldCloses++}));
updated.Update(React.createElement(window.DFL.ConfirmModal,{
strTitle:'Updated confirmation',onOK:()=>updatedActions++,onCancel:()=>updatedActions++,
...(withCallback ? {closeModal:()=>updatedCloses++} : {})}));
const updatedElement=modalRoot.value.at(-1).children.at(-1);
nodes(updatedElement.type(updatedElement.props),'button')[actionIndex].props.onClick();
assert.equal(modalRoot.value,null,'Updated OK/Cancel must close its own dialog');
await updated.ClosedPromise;
assert.equal(updatedActions,1);assert.equal(updatedCloses,Number(withCallback));assert.equal(oldCloses,0);
updated.Close();updatedElement.props.closeModal();
updated.Update(React.createElement('p',null,'Already closed'));
assert.equal(updatedCloses,Number(withCallback));assert.equal(modalRoot.value,null);
}
let selectedMenuItem=0;
window.DFL.MenuItem({onSelected:()=>selectedMenuItem++}).props.onClick({});
window.DFL.MenuItem({disabled:true,onSelected:()=>selectedMenuItem++}).props.onClick({});
@@ -257,6 +468,35 @@ assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
// Event subscription is acknowledged before a method that immediately emits.
const onMessage=listeners.find(x=>x.type==='message').handler;
// The headset's two scroll buttons target the last trusted pointer's nested
// scroll region, with a document fallback after that region is removed.
const documentScroll={scrollTop:0,clientHeight:500,scrollHeight:900,isConnected:true,overflowY:'auto'};
const nestedScroll={scrollTop:0,clientHeight:160,scrollHeight:716,isConnected:true,
overflowY:'scroll',parentElement:documentScroll};
const child={scrollTop:0,clientHeight:0,scrollHeight:0,parentElement:nestedScroll};
context.document.scrollingElement=documentScroll;
const pointerMove=listeners.find(x=>x.type==='pointermove').handler;
pointerMove({isTrusted:true,target:child});
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}});
assert.equal(nestedScroll.scrollTop,120);
assert.equal(messages.at(-1).moved,true);
pointerMove({isTrusted:false,target:documentScroll});
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'up'}});
assert.equal(nestedScroll.scrollTop,0,'Untrusted movement cannot retarget scroll');
nestedScroll.scrollTop=550;
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}});
assert.equal(nestedScroll.scrollTop,556,'Scroll stops at the content end');
assert.equal(messages.at(-1).moved,true);
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}});
assert.equal(messages.at(-1).moved,false);
const scrollReplies=messages.length;
onMessage({source:parent,data:{type:'framey:scroll',session:'stale',direction:'up'}});
onMessage({source:{},data:{type:'framey:scroll',session:'',direction:'up'}});
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'sideways'}});
assert.equal(messages.length,scrollReplies);
nestedScroll.isConnected=false;
onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}});
assert.equal(documentScroll.scrollTop,375);
const eventValues=[];
const callback=(...args)=>eventValues.push(args);
assert.equal(api.addEventListener('timer:tick',callback),callback);
@@ -286,3 +526,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);
+1 -1
View File
@@ -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;
+46 -1
View File
@@ -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,45 @@ 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);
const scrollSession=vm.runInContext('frameySession',context);
el('frameyScrollDown').onclick();
assert.equal(replies.at(-1).type,'framey:scroll');
assert.equal(replies.at(-1).session,scrollSession);
assert.equal(replies.at(-1).direction,'down');
await send({type:'framey:scroll:result',session:'old',direction:'down',moved:true});
assert.equal(el('frameyPanelStatus').textContent,'Loading plugin panel…');
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:true});
assert.equal(el('frameyPanelStatus').textContent,'Scrolled plugin down');
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
el('frameyClose').onclick();
const replyCount=replies.length;
el('frameyScrollUp').onclick();
assert.equal(replies.length,replyCount);
+1 -1
View File
@@ -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.
+56
View File
@@ -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);
+11 -2
View File
@@ -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);
+113
View File
@@ -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
View File
@@ -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():
+9
View File
@@ -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)
+25
View File
@@ -138,6 +138,31 @@ class StoreTransaction(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertTrue(external.exists())
def test_installed_script_entrypoints_execute_without_zip_exec_bits(self):
data, _ = archive(extra=('Example/scripts/start.py',
b'#!/bin/sh\nprintf "Framey script: %s\\n" "$1"\n'))
result = self.apply('install', data)
self.assertEqual(result.returncode, 0, result.stderr)
script = self.folder / 'scripts/start.py'
self.assertTrue(script.stat().st_mode & 0o100)
self.assertFalse(script.stat().st_mode & 0o6111 & ~0o100)
ran = subprocess.run([str(script), 'ready'], text=True, capture_output=True, timeout=5)
self.assertEqual(ran.returncode, 0, ran.stderr)
self.assertEqual(ran.stdout, 'Framey script: ready\n')
self.assertFalse((self.folder / 'plugin.json').stat().st_mode & 0o111)
self.assertFalse((self.folder / 'dist/index.js').stat().st_mode & 0o111)
def test_declared_executable_is_owner_only_without_privilege_bits(self):
info = zipfile.ZipInfo('Example/bin/entrypoint')
info.create_system = 3
info.external_attr = 0o104777 << 16
data, _ = archive(extra=(info, b'entrypoint payload'))
result = self.apply('install', data)
self.assertEqual(result.returncode, 0, result.stderr)
executable = self.folder / 'bin/entrypoint'
self.assertEqual(executable.stat().st_mode & 0o6111, 0o100)
self.assertEqual(executable.read_bytes(), b'entrypoint payload')
def test_remove_while_service_is_stopped_does_not_restart_it(self):
data, _ = archive()
self.assertEqual(self.apply('install', data).returncode, 0)
+10 -3
View File
@@ -33,7 +33,8 @@ class OfficialBundles(unittest.TestCase):
self.assertIn(b'deckyFrontendLib.definePlugin', frontend)
bundle.extractall(root)
folder = Path(root) / plugin['folder']
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
(folder / '.frame-control-store.json').write_text(json.dumps({
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
with patch.object(framey_panel, 'PLUGINS', Path(root)):
page = framey_panel.panel(plugin['id']).decode()
encoded = re.search(r'<script id="framey-bundle" type="application/json">([^<]+)</script>', page)
@@ -71,12 +72,18 @@ class OfficialBundles(unittest.TestCase):
self.assertEqual(manifest.get('api_version', 'legacy'), plugin['api_version'])
z.extractall(plugins)
folder = plugins / plugin['folder']
(folder / '.frame-control-store.json').write_text(json.dumps({'storeId': plugin['id']}))
(folder / '.frame-control-store.json').write_text(json.dumps({
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
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(f'data-module="/framey/module/135/{plugin["sha256"]}/index.js"', page)
with patch.object(framey_panel, 'PLUGINS', plugins):
self.assertEqual(framey_panel.local_module(135, plugin['sha256'], 'chunks/pluginApi-B8sV7970.js'),
(folder/'dist/chunks/pluginApi-B8sV7970.js').read_bytes())
if __name__ == '__main__':
+191 -25
View File
@@ -18,25 +18,78 @@ 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 -')
compile(stdin, '<Steam read>', 'exec')
self.assertIn(expression, stdin)
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
@@ -44,7 +97,8 @@ class Panel(unittest.TestCase):
closed=[]
page=SimpleNamespace(eval=lambda _:value,sock=SimpleNamespace(close=lambda:closed.append(True)))
fake_module=SimpleNamespace(Page=lambda:page)
script=framey_panel.STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__',repr('safe()'))
script=framey_panel.STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__',repr('safe()')).replace(
'__FRAMEY_STEAM_LIMIT__',str(256*1024))
with patch.dict(sys.modules, {'frame_steam':fake_module}), patch('builtins.print') as output:
if oversized:
with self.assertRaisesRegex(ValueError,'too large'):exec(script,{})
@@ -53,12 +107,61 @@ class Panel(unittest.TestCase):
self.assertEqual(json.loads(output.call_args.args[0]),{'result':'AU'})
self.assertEqual(closed,[True])
def test_library_snapshot_copies_only_bounded_scalar_fields(self):
expression = framey_panel.STEAM_READS['GetLibrarySnapshot']
script = '''const assert=require('node:assert/strict');
const app={appid:10,display_name:'Game',sort_as:'game',app_type:1,gameid:'10',
minutes_playtime_forever:60,minutes_playtime_last_two_weeks:20,rt_last_time_played:123,
owner_account_id:'PRIVATE',per_client_data:{path:'PRIVATE'},
local_per_client_data:{installed:true,clientid:'PRIVATE',install_path:'PRIVATE'}};
global.window=global;global.appStore={allApps:[app]};
global.App={m_CurrentUser:{strSteamID:'76561198000000001',strAccountBalance:'PRIVATE'}};
global.collectionStore={allGamesCollection:{allApps:[app]}};
const value=eval(EXPRESSION);
assert.deepEqual(value,{apps:[[10,'Game','game',1,'10',60,20,123,true]],gameIds:[10],steamId:'76561198000000001'});
assert.equal(JSON.stringify(value).includes('PRIVATE'),false);
app.local_per_client_data.installed=false;
assert.equal(eval(EXPRESSION).apps[0][8],false);
delete app.local_per_client_data.installed;
assert.equal(eval(EXPRESSION).apps[0][8],null);
app.local_per_client_data=undefined;
assert.equal(eval(EXPRESSION).apps[0][8],null);
app.local_per_client_data={installed:'true'};
assert.equal(eval(EXPRESSION).apps[0][8],null);
appStore.allApps=Array(10001).fill(app);
assert.throws(()=>eval(EXPRESSION),/snapshot limit/);
appStore=undefined;
assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
'''.replace('EXPRESSION',json.dumps(expression))
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
self.assertEqual(result.returncode,0,result.stderr)
def test_library_sized_plugin_arguments_remain_bounded(self):
# Shotty sends appid/name pairs at startup; a normal library exceeds 8K.
args=[{'allapps':[[id,'Game '+str(id)] for id in range(7000)]}]
self.assertGreater(len(json.dumps(args)),128*1024)
def fake_ssh(command, stdin, **_):
import ast
self.assertEqual(command,'python3 -')
compile(stdin,'<large method>','exec')
assignment=next(n for n in ast.parse(stdin).body if isinstance(n,ast.Assign) and
any(isinstance(t,ast.Name) and t.id=='payload' for t in n.targets))
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','sha256':RECEIPT}]}):
self.assertEqual(framey_panel.call_method(fake_ssh,
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,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'):
@@ -77,8 +180,11 @@ class Panel(unittest.TestCase):
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),
@@ -89,7 +195,7 @@ class Panel(unittest.TestCase):
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'}):
@@ -97,8 +203,8 @@ class Panel(unittest.TestCase):
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')],
@@ -121,7 +227,7 @@ class Panel(unittest.TestCase):
self.plugins = Path(tmp.name)/'plugins'
self.folder = self.plugins/'protondb-decky'
(self.folder/'dist').mkdir(parents=True)
(self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4'}))
(self.folder/'.frame-control-store.json').write_text(json.dumps({'storeId':14,'version':'1.3.4','sha256':RECEIPT}))
(self.folder/'plugin.json').write_text(json.dumps({'name':'ProtonDB Badges','api_version':2,'flags':[]}))
(self.folder/'dist/index.js').write_text("export default () => ({content: SP_REACT.createElement('p', null, 'OK')});")
home = patch.object(framey_panel, 'PLUGINS', self.plugins)
@@ -147,21 +253,63 @@ class Panel(unittest.TestCase):
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(f'data-module="/framey/module/14/{RECEIPT}/index.js"', page)
self.assertEqual(framey_panel.local_module(14, RECEIPT, '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, RECEIPT, name)
(chunks/'linked.js').symlink_to(chunks/'view.js')
with self.assertRaises(FileNotFoundError):
framey_panel.local_module(14, RECEIPT, 'chunks/linked.js')
with self.assertRaises(FileNotFoundError):
framey_panel.local_module(15, RECEIPT, 'chunks/view.js')
def test_chunked_panel_rejects_modules_from_a_replaced_store_receipt(self):
(self.folder/'dist/index.js').write_text("import './chunks/view.js'; export default () => ({});")
chunks = self.folder/'dist/chunks'
chunks.mkdir()
(chunks/'view.js').write_text('export const version = 1;')
old_url = f'/framey/module/14/{RECEIPT}/chunks/view.js'
self.assertIn(f'/framey/module/14/{RECEIPT}/index.js', framey_panel.panel(14).decode())
self.assertEqual(framey_panel.local_module(14, RECEIPT, 'chunks/view.js'), b'export const version = 1;')
(self.folder/'.frame-control-store.json').write_text(json.dumps({
'storeId':14,'version':'2.0','sha256':'b'*64}))
(chunks/'view.js').write_text('export const version = 2;')
with self.assertRaisesRegex(FileNotFoundError, 'changed'):
framey_panel.local_module(14, RECEIPT, 'chunks/view.js')
self.assertEqual(framey_panel.local_module(14, 'b'*64, 'chunks/view.js'), b'export const version = 2;')
self.assertNotIn(old_url, framey_panel.panel(14).decode())
with self.assertRaises(FileNotFoundError):
framey_panel.local_module(14, 'invalid', 'chunks/view.js')
def test_legacy_keyword_rpc_is_scoped_and_validated(self):
import shlex
def fake_ssh(command, stdin=None, **_):
payload = json.loads(shlex.split(command)[2])
import ast
self.assertEqual(command,'python3 -')
assignment=next(node for node in ast.parse(stdin).body if isinstance(node,ast.Assign) and
any(isinstance(target,ast.Name) and target.id=='payload' for target in node.targets))
payload=json.loads(ast.literal_eval(assignment.value.args[0]))
self.assertEqual(payload, {'name':'Legacy', 'method':'read_setting',
'args':[{'key':'mode'}], 'legacy':True})
self.assertIn('loader/call_legacy_plugin_method', stdin)
return json.dumps({'result':{'success':True, 'result':'dark'}})
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':14,'name':'Legacy'}]}):
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True),
'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'
@@ -186,16 +334,21 @@ class Panel(unittest.TestCase):
def test_backend_method_is_scoped_to_store_receipt(self):
def fake_ssh(command, stdin=None, **_):
self.assertIn('python3 - ', command)
self.assertEqual(stdin, framey_panel.METHOD_SCRIPT)
self.assertEqual(command, 'python3 -')
compile(stdin, '<remote method>', 'exec')
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)',[])
compile(framey_panel.METHOD_SCRIPT, '<remote method>', 'exec')
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):
packed = frame_headset.bundle()
@@ -204,7 +357,7 @@ class Panel(unittest.TestCase):
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)
@@ -228,6 +381,19 @@ class Panel(unittest.TestCase):
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(f'/framey/module/14/{RECEIPT}/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(f'/framey/module/14/{RECEIPT}/chunks/../plugin.json')[0], 404)
self.assertEqual(get(f'/framey/module/15/{RECEIPT}/chunks/view.js')[0], 404)
(self.folder/'.frame-control-store.json').write_text(json.dumps({
'storeId':14,'version':'2.0','sha256':'b'*64}))
self.assertEqual(get(f'/framey/module/14/{RECEIPT}/chunks/view.js')[0], 404)
self.assertEqual(get(f'/framey/module/14/{"b"*64}/chunks/view.js')[0], 200)
if __name__ == '__main__':
unittest.main()
+3
View File
@@ -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
+2
View File
@@ -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,
+21 -3
View File
@@ -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'):
+20 -3
View File
@@ -426,10 +426,27 @@ class OneServer(unittest.TestCase):
# Stopped as the app stops it: by closing stdin (the only way on Windows,
# where terminate() is a hard kill) and, elsewhere, by SIGTERM too.
for stop in ["stdin"] + (["sigterm"] if os.name != "nt" else []):
for stop in ["stdin"] + (["sigterm", "stdin+late-sigterm"] if os.name != "nt" else []):
with self.subTest(stop=stop):
left_by_dead = [staged(tmp, "frame-vr-", dead.pid), staged(index_dir, ".download-", dead.pid)]
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
command = [sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"]
if stop == "stdin+late-sigterm":
# Deliver the app's second stop mechanism exactly as cleanup
# replaces the signal handler: the race observed on a real Mac.
wrapper = (
"import os, runpy, signal, sys; from pathlib import Path\n"
"original = signal.signal\n"
"def install(sig, handler):\n"
" if sig == signal.SIGTERM and handler == signal.SIG_IGN:\n"
" os.kill(os.getpid(), signal.SIGTERM)\n"
" return original(sig, handler)\n"
"signal.signal = install\n"
"sys.argv = sys.argv[1:]\n"
"sys.path.insert(0, str(Path(sys.argv[0]).parent))\n"
"runpy.run_path(sys.argv[0], run_name='__main__')\n"
)
command = [sys.executable, "-c", wrapper, *command[1:]]
proc = subprocess.Popen(command,
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
@@ -437,7 +454,7 @@ class OneServer(unittest.TestCase):
self.assertEqual([d for d in left_by_dead if d.exists()], [])
in_flight = [staged(tmp, "frame-vr-", proc.pid), staged(tmp, "frame-agent-", proc.pid),
staged(index_dir, ".download-", proc.pid)]
if stop == "stdin":
if stop.startswith("stdin"):
proc.stdin.close()
else:
proc.terminate()
+54
View File
@@ -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
View File
@@ -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"])
+6
View File
@@ -192,6 +192,12 @@ class Probe(unittest.TestCase):
@unittest.skipUnless(wireless.frame_host.WINDOWS, 'Live read-only Windows inventory runs on Windows CI')
def test_live_windows_inventory(self):
r = wireless.diagnose()
# Shared Windows runners can spend the entire probe budget starting
# PowerShell. Retry only that transient timeout; a second failure must
# still fail this live inventory gate.
if (r['state'] == 'unavailable' and
r.get('message') == 'Windows wireless inventory timed out. No settings were changed.'):
r = wireless.diagnose()
self.assertIn(r['state'], ('ok', 'partial'), r)
self.assertEqual(r['sources']['adapters'], 'ok', r['sources'])
self.assertTrue(r['devices'], 'Windows runner should expose at least its virtual network interface')
+10
View File
@@ -177,6 +177,16 @@ try:
seen.add(f.filename)
stage.mkdir(mode=0o700)
z.extractall(stage)
# zipfile drops execute bits. Legacy plugins also publish shebang
# entrypoints as 0644 because Decky's installer chmods recursively.
# Grant only owner execute for scripts or declared executables;
# never restore archive privilege/write bits or mark other data executable.
for member in files:
if member.is_dir(): continue
entry=stage/member.filename
with entry.open('rb') as source: script=source.read(2)==b'#!'
if script or (member.external_attr>>16)&0o111:
entry.chmod(entry.stat().st_mode|0o100)
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
# Recheck under the transaction lock. Another store operation may
+234 -57
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"checkedAtUtc": "2026-10-25T23:19:48.854504+00:00",
"checkedAtUtc": "2026-10-26T06:10:58.537029+00:00",
"source": "https://plugins.deckbrew.xyz/plugins",
"hostCommit": "779f9c5",
"method": "Exact SHA-256 verified archives. All frontends attempted in opaque-iframe Chromium for 1.2 s with backend calls and network denied; ready means initializer only. Backend calls were denied in the baseline browser audit. Scoped live backend follow-ups are recorded separately in docs/framey-compatibility.md.",
@@ -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,13 +2880,14 @@
"https://github.com/andrewburgess/steamdeck-discord-status",
"https://github.com/andrewburgess/steamdeck-discord-status.git"
],
"status": "broken",
"reason": "RunningApps and appStore are absent; Discord activity integration fails after initialization.",
"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",
"local browser",
"fake-Frame"
"fake-Frame",
"real Frame SSH"
],
"browser": {
"id": 37,
@@ -2911,6 +2922,16 @@
"sha256": "00a45dd919abb6e050685e1e408ce8023b9c4a09d6e91ba528d50aed2a63bf45",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"libraryFollowUp": {
"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
}
},
{
@@ -2984,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,
@@ -3358,13 +3380,14 @@
"https://github.com/geeksville/steamback",
"https://github.com/geeksville/steamback.git"
],
"status": "broken",
"reason": "Frontend audit failed: Cannot read properties of undefined (reading 'm_CurrentUser'). Steam account/app context is unavailable.",
"status": "partial",
"reason": "Panel opens using the real Frame current-user/library snapshot. Account setup and empty save-list reads pass through actual Decky 3.3.0 amd64 in isolation. ARM64 backend, supported-save discovery, automatic backups and restore remain unverified; no backup/restore ran.",
"blockInstall": true,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"real Frame SSH"
],
"browser": {
"id": 46,
@@ -3391,6 +3414,16 @@
"archiveValidation": "Unsafe plugin archive member",
"result": "blocked",
"reason": "ValueError: Unsafe plugin archive member. Frontend audit failed: App is not defined."
},
"libraryFollowUp": {
"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
}
},
{
@@ -3842,12 +3875,13 @@
"https://github.com/safijari/Shotty"
],
"status": "broken",
"reason": "Frontend audit failed: Cannot read properties of undefined (reading 'allApps').",
"reason": "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.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"real Frame SSH"
],
"browser": {
"id": 53,
@@ -3873,6 +3907,11 @@
"sha256": "ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"libraryFollowUp": {
"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
}
},
{
@@ -3966,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
}
},
{
@@ -4011,14 +4055,15 @@
"https://github.com/saumya-banthia/volume-boost",
"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.",
"status": "works",
"reason": "Default-sink controls pass in local Chromium through real Decky 3.3.0 ARM64: linked slider/keyboard and numeric writes, separate channels, re-link and reopen, with independent Pulse readback including 125%/150% while muted. Exact original volume/mute restored. Audible output, Bluetooth sinks 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,
@@ -4139,12 +4184,13 @@
"https://github.com/jessebofill/DeckWebBrowser.git"
],
"status": "broken",
"reason": "Frontend audit failed: deckyFrontendLib.getReactRoot is not a function.",
"reason": "The exact frontend now initializes with a null Steam React-root lookup. The browser itself still requires Steam GamepadUI CDP injection and a backend WebSocket unavailable in Framey; no working browser tab was verified.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"focused local browser"
],
"browser": {
"id": 59,
@@ -4196,6 +4242,17 @@
"sha256": "1192864fddc5d72550346b8dff02c21d71f0c40a06ac41c4e67bb6220d267f9d",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"focusedBrowser": {
"hostChange": "DFL.getReactRoot returns null without exposing a Steam tree",
"ready": true,
"error": null,
"methods": [
"set_main_tab",
"load"
],
"backendAndNetwork": "denied",
"scope": "initializer only; no usable browser tab or real backend verified"
}
},
{
@@ -4385,8 +4442,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",
@@ -4396,10 +4453,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,
@@ -4420,6 +4484,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
}
},
{
@@ -4936,13 +5005,14 @@
"https://github.com/pnpm/pnpm",
"https://github.com/your-username/PlayTime"
],
"status": "broken",
"reason": "Private SteamClient JSON cache now works; initialization still fails on missing Steam appStore.m_mapApps. Live app data and MobX game observations are unavailable; backend actions were denied.",
"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. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"real Frame SSH"
],
"browser": {
"id": 66,
@@ -4983,6 +5053,16 @@
"sha256": "10fc3beea48991c77cb02867e919b618d4cd6d5e9c6a6d0ccd5266c13214039f",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"libraryFollowUp": {
"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
}
},
{
@@ -6437,27 +6517,25 @@
"https://github.com/ebenbruyns/junkstore",
"https://github.com/ebenbruyns/junkstore.git"
],
"status": "broken",
"reason": "Route registration succeeds; rendering fails on a missing Steam ContainerBackground component.",
"status": "partial",
"reason": "Official Decky 3.3.0 ARM64 static menu/backend calls pass; local Chromium Games/Epic navigation, About/Dependencies tabs, Back and reopen render with real Frame library metadata. Legendary is absent; dependency install, login, game operations, RSS, optional websocket and worn VR remain unverified. Existing installs need update/reinstall for script permissions.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"isolated Linux backend",
"real Frame SSH"
],
"browser": {
"id": 84,
"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": {
@@ -6479,6 +6557,30 @@
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"localBackend": {
"runtime": "Decky 3.3.0 amd64",
"evidence": "docs/evidence/framey-script-entrypoints-2026-10-26.json",
"before": "init and InitActions return inner Type:Error",
"after": "init Type:Success; InitActions Type:ActionSet/MainMenu"
},
"realFrame": {
"runtime": "Decky 3.3.0 ARM64",
"evidence": "docs/evidence/framey-script-entrypoints-2026-10-26.json",
"staticActions": [
"JunkStoreActions/GetContent",
"JunkStoreActions/GetEpicActions",
"MainMenu/GetContent",
"MainMenu/JunkStoreInit",
"init/InitActions"
],
"browser": "main menu; Games/Epic static tab; back; close/reopen",
"installedContext": {
"evidence": "docs/evidence/framey-installed-context-2026-10-26.json",
"field": "local_per_client_data.installed",
"browser": "About/Dependencies, Back, close/reopen; real library projection; static ARM64 backend calls",
"installedUnknown": "preserved as undefined; no fabricated false"
}
}
},
{
@@ -6891,6 +6993,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
}
},
{
@@ -7212,13 +7319,14 @@
"https://github.com/DeckSettings/game-reports-steamos",
"https://github.com/SteamDeckHomebrew/decky-frontend-lib"
],
"status": "broken",
"reason": "Real Steam install folders are now available; game selection still fails on missing collectionStore.allGamesCollection. Steam library context, proxy fetch and backend actions remain unsupported/unverified.",
"status": "partial",
"reason": "Game picker renders with detached real Frame library/install-folder reads; search modal opens in local Chromium. Snapshot refreshes on panel reopen. Remote reports/proxy fetch, settings changes and ARM64 backend remain unverified.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"real Frame SSH"
],
"browser": {
"id": 93,
@@ -7254,6 +7362,16 @@
"sha256": "23c2a933a6883e2622f0184c33a76ce4a4e88c0a7dcd730f9bab71b8c3702132",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"libraryFollowUp": {
"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
}
},
{
@@ -7684,6 +7802,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
}
},
{
@@ -7871,6 +7994,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
}
},
{
@@ -8443,7 +8571,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",
@@ -8454,7 +8582,7 @@
"id": 102,
"name": "DeckSP",
"ready": false,
"error": "Cannot read properties of null (reading 'GamepadUIAudio')",
"error": "Cannot access 'PluginManager' before initialization",
"logs": [],
"methods": []
},
@@ -8572,6 +8700,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
}
},
{
@@ -8681,6 +8814,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
}
},
{
@@ -9675,6 +9813,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
}
},
{
@@ -10733,12 +10876,13 @@
"https://github.com/mpcallahan/DeckyHourNotifications"
],
"status": "partial",
"reason": "Initializer succeeds; timers, reminders, suspend inhibition and audio are unverified.",
"reason": "Silent labeled timer create/ticks/pause/cancel, preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with the official Decky 3.3.0 ARM64 backend. Audio, alarms/reminders, suspend inhibition, Steam overlays and worn VR remain unverified; notifications require the panel to stay open.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"real Frame SSH"
],
"browser": {
"id": 124,
@@ -10798,7 +10942,7 @@
"folder": "alarme-decky-plugin"
},
"backend": "Python",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"hardware": "Timers/stopwatch use Python. Optional sleep inhibition opens /dev/uinput; sound and auto-suspend are unverified. Steam game overlays have no target in the isolated panel.",
"uiSurfaces": [
"local/Steam routes",
"toasts",
@@ -10810,6 +10954,17 @@
"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
},
"controlsFollowUp": {
"evidence": "docs/evidence/framey-dialog-controls-2026-10-26.json",
"mode": "local opaque-iframe Chromium with receipt-bound official ARM64 backend RPC/events; private silent data only",
"frontendReady": true,
"status": "partial"
}
},
{
@@ -11172,6 +11327,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
}
},
{
@@ -11656,6 +11816,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
}
},
{
@@ -11856,9 +12021,9 @@
"archiveBytes": 862269,
"api": 1,
"flags": [],
"modernBridge": false,
"modernBridge": true,
"frontend": true,
"frontendApi": "unknown",
"frontendApi": "1",
"pythonFiles": [
"Deck-Shelves/main.py"
],
@@ -11886,8 +12051,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",
@@ -11921,6 +12086,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
}
},
{
@@ -13187,6 +13358,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
}
},
{
@@ -13233,14 +13409,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,
+359 -26
View File
@@ -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'],
@@ -27,6 +34,25 @@
const root = document.getElementById('framey-root');
const error = document.getElementById('framey-error');
const toastRoot = document.getElementById('framey-toasts');
let lastScrollTarget = null;
function scrollTarget(element) {
for (let node = element; node && node !== document; node = node.parentElement) {
if (node.scrollHeight <= node.clientHeight + 1) continue;
if (node === document.scrollingElement || /^(auto|scroll|overlay)$/.test(getComputedStyle(node).overflowY))
return node;
}
return document.scrollingElement;
}
function scrollPlugin(direction) {
if (direction !== 'up' && direction !== 'down') return false;
const target = lastScrollTarget?.isConnected ? lastScrollTarget : document.scrollingElement;
if (!target) return false;
const before = target.scrollTop;
const maximum = Math.max(0, target.scrollHeight - target.clientHeight);
const step = Math.max(120, Math.round(target.clientHeight * 0.75));
target.scrollTop = Math.max(0, Math.min(maximum, before + (direction === 'down' ? step : -step)));
return target.scrollTop !== before;
}
const pending = new Map();
const routes = new Map();
const routeListeners = new Set();
@@ -132,6 +158,14 @@
return request(method, args, legacy);
}
addEventListener('message', event => {
if (event.source === parent && event.data?.type === 'framey:scroll' &&
event.data.session === cacheSession) {
if (event.data.direction !== 'up' && event.data.direction !== 'down') return;
const moved = scrollPlugin(event.data.direction);
parent.postMessage({type:'framey:scroll:result', session:cacheSession,
direction:event.data.direction, moved}, '*');
return;
}
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
event.data.session === cacheSession && acceptSeed) {
const entries = event.data.entries;
@@ -158,8 +192,31 @@
addEventListener('pointerup', event => {
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
}, {passive:true});
addEventListener('pointermove', event => {
if (event.isTrusted) lastScrollTarget = scrollTarget(event.target);
}, {passive:true});
const passthrough = tag => ({children, ...props}) => R.createElement(tag, props, children);
const Focusable = ({children, onActivate, onClick, onKeyDown, disabled, tabIndex, role, ...props}) => {
const actionable=typeof onActivate==='function' || typeof onClick==='function';
const activate = event => {
if (disabled || event.defaultPrevented) return;
event.stopPropagation();
(onClick || onActivate)?.(event);
};
return R.createElement('div', {...props, role:role || (actionable ? 'button' : undefined),
tabIndex:tabIndex ?? (actionable ? (disabled ? -1 : 0) : undefined),
'aria-disabled':actionable && disabled ? true : undefined,
onClick:actionable ? activate : undefined, onKeyDown:event=>{
onKeyDown?.(event);
if (actionable && !disabled && !event.defaultPrevented && event.target===event.currentTarget &&
(event.key==='Enter' || event.key===' ')) {
event.preventDefault();
event.stopPropagation();
(onActivate || onClick)?.(event);
}
}}, children);
};
const Field = ({label, description, children}) => R.createElement('label', null,
R.createElement('strong', null, label), description && R.createElement('small', null, description), children);
const ToggleField = ({label, description, checked, disabled, onChange}) => R.createElement(Field, {label, description},
@@ -226,11 +283,61 @@
const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) =>
R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled),
onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children);
const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null,
R.createElement('h2', null, strTitle), children,
R.createElement('button', {type:'button', onClick:()=>{onOK?.(); closeModal();}}, strOKButtonText || 'Close'));
const ConfirmModal = ({strTitle, strDescription, strOKButtonText, strCancelButtonText,
strMiddleButtonText, onOK, onCancel, onMiddleButton, closeModal,
bOKDisabled, bCancelDisabled, bMiddleDisabled, bAlertDialog, children}) => {
const action = (callback, disabled) => () => {
if (disabled) return;
callback?.();
closeModal?.();
};
return R.createElement('section', null,
R.createElement('h2', null, strTitle),
strDescription && R.createElement('div', null, strDescription), children,
R.createElement('button', {type:'button', disabled:!!bOKDisabled,
onClick:action(onOK, bOKDisabled)}, strOKButtonText || 'OK'),
onMiddleButton && R.createElement('button', {type:'button', disabled:!!bMiddleDisabled,
onClick:action(onMiddleButton, bMiddleDisabled)}, strMiddleButtonText || 'More'),
!bAlertDialog && R.createElement('button', {type:'button', disabled:!!bCancelDisabled,
onClick:action(onCancel, bCancelDisabled)}, strCancelButtonText || 'Cancel'));
};
const modalRoot = window.ReactDOM.createRoot(document.getElementById('framey-modal'));
const closeModal = () => modalRoot.render(null);
const modals = [];
let modalId = 0;
const renderModals = () => {
document.getElementById('framey-root').inert = modals.length > 0;
modalRoot.render(modals.length ? modals.map((modal,index) =>
R.createElement('section', {key:modal.id, className:'framey-dialog', tabIndex:-1,
ref:node=>{modal.node=node;}, hidden:index!==modals.length-1,
role:'dialog', 'aria-modal':index===modals.length-1 ? true : undefined,
'aria-label':typeof modal.element?.props?.strTitle==='string' ? modal.element.props.strTitle : 'Plugin dialog'},
!modal.element?.props?.bHideCloseIcon && R.createElement('button', {
type:'button', disabled:!!modal.element?.props?.bCancelDisabled,
onClick:()=>dismissModal(modal)}, 'Close'), modal.element)) : null);
};
function dismissModal(modal) {
if (modal.element?.props?.bCancelDisabled) return;
modal.element?.props?.onCancel?.();
modal.dismiss();
}
addEventListener('keydown', event => {
const modal=modals.at(-1);
if (!modal) return;
if (event.key === 'Tab') {
const controls = [...(modal.node?.querySelectorAll('button,input,select,textarea,a[href],[tabindex]') || [])]
.filter(node=>!node.disabled && node.tabIndex>=0 && node.getClientRects().length);
const index=controls.indexOf(document.activeElement);
if (index < 0 || (!event.shiftKey && index===controls.length-1) || (event.shiftKey && index===0)) {
event.preventDefault();
(event.shiftKey ? controls.at(-1) : controls[0])?.focus();
}
return;
}
if (event.key !== 'Escape') return;
event.preventDefault();
if (modal.element?.props?.onEscKeypress) modal.element.props.onEscKeypress();
else dismissModal(modal);
});
const localPath = path => typeof path === 'string' && path.length <= 120 &&
/^\/[A-Za-z0-9/_-]+$/.test(path) && !path.includes('//');
const routePattern = path => typeof path === 'string' && path.length <= 120 &&
@@ -284,15 +391,88 @@
};
const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;};
const showModal = element => {
modalRoot.render(R.createElement('section', null,
R.createElement('button', {type:'button',onClick:closeModal}, 'Close'), element));
return {Close:closeModal};
let resolveClosed;
const ClosedPromise = new Promise(resolve=>{resolveClosed=resolve;});
const modal = {id:++modalId, previousFocus:document.activeElement, source:element};
const Close = () => {
const index=modals.indexOf(modal);
if (index < 0) return;
const wasTop=index===modals.length-1;
modals.splice(index,1);
renderModals();
resolveClosed();
if (wasTop) setTimeout(()=>{
if (modals.length) modals.at(-1).node?.focus();
else if (modal.previousFocus?.isConnected) modal.previousFocus.focus();
},0);
};
modal.dismiss = () => {
if (!modals.includes(modal)) return;
const callback=modal.source?.props?.closeModal;
if (callback !== modal.dismiss) callback?.();
Close();
};
modal.element = R.isValidElement(element) ? R.cloneElement(element, {closeModal:modal.dismiss}) : element;
modals.push(modal);
renderModals();
setTimeout(()=>{if(modals.at(-1)===modal) modal.node?.focus();},0);
return {Close, ClosedPromise, Update:next=>{
if (!modals.includes(modal)) return;
// Keep replacement dialogs dismissible inside this isolated host too.
modal.source=next;
modal.element=R.isValidElement(next) ? R.cloneElement(next, {closeModal:modal.dismiss}) : next;
renderModals();
}};
};
// 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,
Dropdown: DropdownItem, ButtonItem, DialogCheckbox, TextField, SliderField,
Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
Focusable, ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
@@ -301,6 +481,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 +495,35 @@
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,
// The isolated panel has no Steam React root. Legacy plugins can inspect
// this null result and keep their own controls without patching Steam UI.
getReactRoot: () => 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 +542,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 +574,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 +630,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,17 +689,135 @@
},
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
};
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 && row.length !== 9) || !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]) ||
typeof row[4] !== 'string' || !/^\d{1,20}$/.test(row[4]) ||
row.slice(5,8).some(value=>value != null && (!Number.isFinite(value) || value < 0)) ||
(row.length === 9 && row[8] !== null && typeof row[8] !== 'boolean'))
throw Error('Invalid Steam app overview');
const [appid,display_name,sort_as,app_type,gameid,
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played] = row;
return {appid,display_name,sort_as,app_type,gameid,
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played,
// null means Steam supplied no installed boolean, not false. Keep the
// detached metadata object for legacy readers; never copy paths/client IDs.
// Older/running snapshots omit this field and must not erase library data.
...(row.length === 9 ? {local_per_client_data:row[8] === null ? {} : {installed:row[8]}} : {})};
});
}
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.
if (/\b(?:appStore|collectionStore|m_CurrentUser)\b/.test(bundleText)) {
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(new TextDecoder().decode(bytes));
const url = URL.createObjectURL(new Blob(legacy ?
['const __frameyLegacy =\n', bytes, '\nexport default __frameyLegacy;'] : [bytes], {type:'text/javascript'}));
const legacy = /^\s*\(function\s*\(/.test(bundleText);
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;
+68
View File
@@ -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};
})
+146 -36
View File
@@ -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,18 @@ class PanelError(ValueError):
pass
def local_bundle(plugin_id):
"""Read a modern Decky bundle with a store receipt; reject linked paths."""
def _receipt_digest(path):
if path.is_symlink() or not path.is_file() or path.stat().st_size > 16_384:
raise FileNotFoundError('Framey plugin store receipt unavailable')
meta = json.loads(path.read_text())
digest = meta.get('sha256') if isinstance(meta, dict) else None
if not isinstance(digest, str) or not RECEIPT.fullmatch(digest):
raise FileNotFoundError('Framey plugin store receipt unavailable')
return meta, digest
def _owned_dist(plugin_id, expected_receipt=None):
"""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():
@@ -29,16 +41,17 @@ def local_bundle(plugin_id):
manifest = folder/'plugin.json'
dist = folder/'dist'
entry = dist/'index.js'
if any(p.is_symlink() for p in (receipt, manifest, dist, entry)):
if any(p.is_symlink() for p in (manifest, dist, entry)):
continue
try:
if not receipt.is_file() or receipt.stat().st_size > 16_384:
continue
meta = json.loads(receipt.read_text())
if not isinstance(meta, dict):
continue
if meta.get('storeId') != plugin_id:
continue
meta, digest = _receipt_digest(receipt)
except (OSError, ValueError, UnicodeError):
continue
if meta.get('storeId') != plugin_id:
continue
try:
if expected_receipt is not None and digest != expected_receipt:
raise FileNotFoundError('Framey plugin changed; reopen its panel')
if not manifest.is_file() or manifest.stat().st_size > 16_384:
raise FileNotFoundError('Framey plugin manifest unavailable')
data = json.loads(manifest.read_text())
@@ -55,24 +68,82 @@ 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')
if _receipt_digest(receipt) != (meta, digest):
raise FileNotFoundError('Framey plugin changed; reopen its panel')
return dist, bundle, digest
except FileNotFoundError:
raise
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, receipt, 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')
if not isinstance(receipt, str) or not RECEIPT.fullmatch(receipt):
raise FileNotFoundError('Framey module receipt unavailable')
dist, bundle, _ = _owned_dist(plugin_id, receipt)
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')
data = target.read_bytes()
if _receipt_digest(dist.parent/'.frame-control-store.json')[1] != receipt:
raise FileNotFoundError('Framey plugin changed; reopen its panel')
return data
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, receipt = _owned_dist(plugin_id)
encoded = base64.b64encode(bundle).decode('ascii')
module = f' data-module="/framey/module/{plugin_id}/{receipt}/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}
.framey-dialog{position:fixed;inset:12px;margin:0;z-index:100;overflow:auto;box-shadow:0 0 0 24px #0d1925e6}
.framey-dialog[hidden]{display:none}</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()
@@ -81,7 +152,7 @@ METHOD_SCRIPT = r'''
import json, pathlib, sys
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
from frame_steam import Page
payload = json.loads(sys.argv[1])
payload = json.loads(__FRAMEY_METHOD_PAYLOAD__)
route = 'loader/call_legacy_plugin_method' if payload.get('legacy') else 'loader/call_plugin_method'
arguments = [payload['name'], payload['method'], *payload['args']]
expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call(" + ','.join(json.dumps(x) for x in [route, *arguments]) + ')})()'
@@ -91,21 +162,32 @@ 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):
raise PanelError('Invalid plugin method')
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args)) > 8192:
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args).encode('utf-8')) > 256*1024:
raise PanelError('Plugin method arguments are too large')
if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))):
raise PanelError('Legacy plugin methods need one keyword argument object')
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
if len(found) != 1:
raise PanelError('The plugin is not managed by the Frame Control Decky store')
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)),
stdin=METHOD_SCRIPT, timeout=25))
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)))
return json.loads(ssh('python3 -', stdin=script, timeout=25))
# Only parameterless read APIs. Never resolve a plugin-supplied property path
@@ -114,6 +196,20 @@ STEAM_READS = {
'GetIPCountry': 'SteamClient.User.GetIPCountry()',
'GetInstallFolders': 'SteamClient.InstallFolder.GetInstallFolders()',
'GetSystemInfo': 'SteamClient.System.GetSystemInfo()',
# Copy selected scalar fields, never Steam objects, methods, credentials or
# paths. Only the current public Steam ID is needed for per-user plugin data.
# Compact rows keep even a large library inside the response budget.
'GetLibrarySnapshot': r'''(()=>{
if (!window.appStore?.allApps || !window.collectionStore?.allGamesCollection?.allApps)
throw Error('Steam library is unavailable');
const apps = Array.from(appStore.allApps);
if (apps.length > 10000) throw Error('Steam library exceeds the Framey snapshot limit');
return {apps:apps.map(a=>[a.appid,a.display_name,a.sort_as,a.app_type,String(a.gameid),
a.minutes_playtime_forever,a.minutes_playtime_last_two_weeks,a.rt_last_time_played,
typeof a.local_per_client_data?.installed === 'boolean' ? a.local_per_client_data.installed : null]),
gameIds:Array.from(collectionStore.allGamesCollection.allApps,a=>a.appid),
steamId:window.App?.m_CurrentUser?.strSteamID || null};
})()''',
}
STEAM_READ_SCRIPT = r'''
import json, pathlib, sys
@@ -122,24 +218,23 @@ from frame_steam import Page
page = Page()
try:
encoded = json.dumps({'result':page.eval(__FRAMEY_STEAM_READ__)})
if len(encoded.encode('utf-8')) > 256*1024:
if len(encoded.encode('utf-8')) > __FRAMEY_STEAM_LIMIT__:
raise ValueError('Steam read response is too large')
print(encoded)
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))
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
'__FRAMEY_STEAM_LIMIT__', str(2*1024*1024 if method == 'GetLibrarySnapshot' else 256*1024))
return json.loads(ssh('python3 -', stdin=script, timeout=25))
@@ -155,7 +250,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')
@@ -166,11 +261,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))
+93 -19
View File
@@ -53,7 +53,8 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
<label>Show <select id="deckyStoreFilter"><option value="all">All plugins</option><option value="compatible">No known install blocker</option><option value="installed">Installed plugins</option></select></label></div>
<p id="deckyStoreSummary" role="status" aria-live="polite"></p><div id="deckyStoreList" class="stack"></div>
<div class="row" style="margin-top:14px"><button id="deckyStorePrevious" class="secondary">Previous</button><span id="deckyStorePage"></span><button id="deckyStoreNext" class="secondary">Next</button></div></div>
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button></div>
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button>
<button id="frameyScrollUp" class="secondary">Scroll plugin up</button><button id="frameyScrollDown" class="secondary">Scroll plugin down</button></div>
<p id="frameyCompatibility"></p>
<p id="frameyPanelStatus" role="status">Loading plugin panel…</p>
<iframe id="frameyFrame" title="Framey Decky plugin panel" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer"></iframe></div>
@@ -105,22 +106,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 +166,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 +194,70 @@ 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 scrollFramey(direction){
if(activeFrameyId===null||!frameySession||!['up','down'].includes(direction))return;
byId('frameyFrame').contentWindow?.postMessage({type:'framey:scroll',session:frameySession,direction},'*');
}
byId('frameyScrollUp').onclick=()=>scrollFramey('up');
byId('frameyScrollDown').onclick=()=>scrollFramey('down');
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
function frameyCacheRead(id){
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
@@ -232,6 +278,12 @@ function frameyCacheSave(id,entries){
window.addEventListener('message',async event=>{
if(activeFrameyId===null||event.source!==byId('frameyFrame').contentWindow)return;
const req=event.data;if(!req||typeof req!=='object')return;
if(req.type==='framey:scroll:result'&&req.session===frameySession){
if(['up','down'].includes(req.direction)&&typeof req.moved==='boolean')
byId('frameyPanelStatus').textContent=req.moved?'Scrolled plugin '+req.direction:
'No more plugin content in that direction';
return;
}
if(req.type==='framey:cache:init'&&req.session===frameySession){
event.source.postMessage({type:'framey:cache:seed',session:frameySession,
entries:frameyCacheRead(activeFrameyId)},'*');return;
@@ -244,6 +296,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 +323,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,'*');
@@ -262,10 +335,11 @@ 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'].includes(req.method)))return;
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
if(steamRead&&(req.args.length!==0||!['GetIPCountry','GetInstallFolders','GetSystemInfo','GetLibrarySnapshot'].includes(req.method)))return;
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,'*');
});
+32 -7
View File
@@ -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, receipt, name = path.removeprefix('/framey/module/').split('/', 2)
module = framey_panel.local_module(int(plugin_id), receipt, 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',
@@ -4172,8 +4194,12 @@ def main():
LINK.start()
if not PRIVATE: # one worker per user, in the app's server
threading.Thread(target=autosync.run, args=(_stop_sync,), daemon=True).start()
def request_shutdown(*_):
# shutdown() must run outside the serve_forever thread. Do not raise
# from SIGTERM: the app can send it after stdin EOF has begun cleanup.
threading.Thread(target=httpd.shutdown, daemon=True).start()
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
signal.signal(signal.SIGTERM, request_shutdown)
if args.exit_on_eof:
def watch_stdin():
# os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock,
@@ -4181,7 +4207,7 @@ def main():
# when it can't take that lock back from this thread.
while os.read(0, 4096):
pass
threading.Thread(target=httpd.shutdown, daemon=True).start()
request_shutdown()
threading.Thread(target=watch_stdin, daemon=True).start()
try:
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
@@ -4190,8 +4216,7 @@ def main():
except KeyboardInterrupt:
pass
finally:
# The app both closes stdin and sends SIGTERM on quit; a second signal
# mid-cleanup would abort it and leave the SSH master running.
# Cleanup is already underway; later stop signals need no more workers.
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
_power.shutdown()