Compare commits

...
Author SHA1 Message Date
Alex Southwell af8c0a533f Merge pull request #404 from saphid/fix/framey-storage-cleaner-delete
fix(framey): make Storage Cleaner deletion bounded and reliable
2026-10-29 02:53:12 +00:00
SAPHID 081ce61adb fix(framey): make Storage Cleaner deletion bounded and reliable 2026-10-29 02:45:45 +00:00
Alex Southwell 7c30f3311b Merge pull request #403 from saphid/test/framey-deck-shelves-native
test(framey): verify Deck Shelves settings in native Decky
2026-10-29 02:37:36 +00:00
SAPHID 81c2e86f20 Merge remote-tracking branch 'origin/main' into test/framey-deck-shelves-native
# Conflicts:
#	tests/decky-runtime/check.py
2026-10-29 02:27:17 +00:00
Alex Southwell ddbaff8f6a Merge pull request #402 from saphid/fix/framey-radiyo-tls
fix(framey): verify RadiYo HTTPS certificates
2026-10-29 02:26:24 +00:00
SAPHID 250f2b9674 test(framey): verify Deck Shelves settings in native Decky 2026-10-29 02:23:57 +00:00
SAPHID 3495a017d1 fix(framey): verify RadiYo HTTPS certificates 2026-10-29 02:16:19 +00:00
Alex Southwell 2a18f2bda7 Merge pull request #401 from saphid/fix/release-audit-full-pagination
Audit every release-list page before choosing a draft
2026-10-29 01:54:14 +00:00
SAPHID f7ff94aae4 Record live release lookup for audit fix 2026-10-29 01:44:03 +00:00
SAPHID f4c8308f8a Merge remote-tracking branch 'origin/main' into fix/release-audit-full-pagination 2026-10-29 01:41:38 +00:00
Alex Southwell a2e578f6c3 Merge pull request #400 from saphid/fix/release-gate-smoke-proof-20261029
Reject unreadable Frame smoke sensor values
2026-10-29 01:41:02 +00:00
SAPHID e8fb154107 Audit every release-list page before choosing a draft 2026-10-29 01:33:33 +00:00
SAPHID 35b59e62da Document Frame smoke sensor gate evidence 2026-10-29 01:29:48 +00:00
SAPHID be07dbeeeb Reject unreadable Frame smoke sensor values 2026-10-29 01:25:26 +00:00
Alex Southwell b123f5ff34 Merge pull request #399 from saphid/test/framey-gamesync-native
Verify Game Sync configuration in native Decky
2026-10-29 01:07:59 +00:00
SAPHID 08c99c2d7d docs: scope Game Sync ARM64 limit to cloud sync 2026-10-29 01:00:28 +00:00
SAPHID b3e3e21f46 test: remove unverified rclone path assertion 2026-10-29 00:58:58 +00:00
SAPHID 84bb7fa0b1 test: verify Game Sync configuration in native Decky 2026-10-29 00:55:54 +00:00
Alex Southwell 01e5d8b2db Merge pull request #398 from saphid/test/framey-radiyo-native
Verify RadiYo backend through isolated Decky
2026-10-29 00:49:26 +00:00
SAPHID 130d82128b test: include RadiYo native probe in runtime inventory 2026-10-29 00:38:22 +00:00
SAPHID 65e629f8c3 Verify RadiYo backend through isolated native Decky 2026-10-29 00:32:26 +00:00
Alex Southwell e33ed00945 Merge pull request #397 from saphid/fix/release-all-tag-runs
Reject earlier failed builds for a release tag
2026-10-29 00:09:11 +00:00
SAPHID 419d69ef8a Reject prior failed tagged release runs before publishing 2026-10-28 23:58:30 +00:00
Alex Southwell 8cf2fb9da3 Merge pull request #395 from saphid/ci/frame-smoke-source-commit
Bind Frame smoke evidence to release commit and lock cleanup
2026-10-28 23:39:18 +00:00
SAPHID ff2eb87ac1 Merge remote-tracking branch 'origin/main' into ci/frame-smoke-source-commit 2026-10-28 23:30:51 +00:00
Alex SouthwellandSAPHID c611f4aeb7 Run live Windows wireless inventory only in dedicated CI job (#396)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-28 23:30:18 +00:00
SAPHID f981ba9315 Require exact locked Frame smoke before publication 2026-10-28 23:19:35 +00:00
SAPHID 014e6b3531 Merge remote-tracking branch 'origin/main' into ci/frame-smoke-source-commit 2026-10-28 23:15:37 +00:00
SAPHID 8b6c32ba37 Bind Frame smoke evidence to release commit and lock cleanup 2026-10-28 23:15:01 +00:00
Alex SouthwellandSAPHID 96294f5137 Clarify two official Framey compatibility limits (#394)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-28 23:13:01 +00:00
Alex SouthwellandSAPHID 6f544474ce Document v0.4.54 x86 runtime blocker (#393)
* Document v0.4.54 x86 runtime blocker

* Clarify FEX wrapper runtime dependency

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-28 22:54:41 +00:00
Alex SouthwellandSAPHID b40bf89565 Test Shotty screenshot copies through native Decky RPC (#392)
* Test Shotty screenshot copies through native Decky RPC

* Clarify local Shotty RPC evidence limit

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-28 22:35:12 +00:00
Alex Southwell 760c89fec8 Merge pull request #391 from saphid/docs/framey-shotty-native-evidence
Align native Decky evidence for four official plugins
2026-10-28 22:23:51 +00:00
SAPHID 584970b82f Align native Decky evidence for managed plugins 2026-10-28 22:11:14 +00:00
SAPHID d319c5ef4d Correct Shotty native Decky compatibility evidence 2026-10-28 22:04:02 +00:00
Alex Southwell dad5f78a02 Merge pull request #390 from saphid/test/framey-e2e-isolation
Clear managed Decky state between fake Frame tests
2026-10-28 21:58:29 +00:00
SAPHID 36da680e14 Merge remote-tracking branch 'origin/main' into test/framey-e2e-isolation 2026-10-28 21:46:58 +00:00
SAPHID 841c0b84be Clear managed Decky state between fake Frame tests 2026-10-28 21:46:41 +00:00
Alex Southwell 68f46bdd60 Merge pull request #389 from saphid/fix/framey-fileserver-url
Show DeckyFileServer HTTPS address in Framey
2026-10-28 21:46:05 +00:00
Alex Southwell 7eaa878827 Merge branch 'main' into fix/framey-fileserver-url 2026-10-28 21:28:27 +00:00
Alex Southwell c8577804df Merge pull request #388 from saphid/docs/freeloader-native-accuracy
Align Free Loader guide with native Decky evidence
2026-10-28 21:27:57 +00:00
SAPHID eda0a563c0 Expect adapted DeckyFileServer bytes in fake Frame e2e 2026-10-28 21:19:32 +00:00
SAPHID 4f1072cb4d Cover DeckyFileServer address shim in native Decky gate 2026-10-28 21:09:22 +00:00
Alex Southwell 38dbd4cc0b Merge branch 'main' into docs/freeloader-native-accuracy 2026-10-28 21:04:55 +00:00
SAPHID 326078cc24 Show DeckyFileServer HTTPS address in Framey 2026-10-28 21:02:07 +00:00
Alex Southwell 0c0df7a243 Merge pull request #387 from saphid/fix/frame-smoke-skip-exit
Fail Frame smoke when a launch is skipped
2026-10-28 20:51:24 +00:00
SAPHID 04ccdda050 Correct Free Loader native Decky verification summary 2026-10-28 20:44:36 +00:00
SAPHID 6c37fbbd37 Fail Frame smoke when any launch is skipped 2026-10-28 20:35:06 +00:00
Alex Southwell e7b037ce88 Merge pull request #386 from saphid/docs/support-matrix-v0453
Align current support matrix with v0.4.53 evidence
2026-10-28 20:25:46 +00:00
SAPHID f1c8017873 Align current support matrix with v0.4.53 evidence 2026-10-28 20:13:02 +00:00
Alex Southwell defa6f49f0 Merge pull request #385 from saphid/test/framey-freeloader-native
Test official Free Loader with native Decky settings and empty deals
2026-10-28 20:08:51 +00:00
SAPHID 89e5ee610d Merge remote-tracking branch 'origin/main' into test/framey-freeloader-native 2026-10-28 19:58:54 +00:00
SAPHID 2b8836929e Test official Free Loader in native Decky runtime 2026-10-28 19:58:54 +00:00
Alex Southwell 248e5b3382 Merge pull request #384 from saphid/fix/mac-updater-preserve-quarantine
Preserve macOS quarantine metadata during auto-update
2026-10-28 19:58:16 +00:00
SAPHID 3dcba52e91 Preserve macOS quarantine metadata during auto-update 2026-10-28 19:41:56 +00:00
Alex Southwell efda14776d Merge pull request #383 from saphid/ci/audit-published-update-manifest
Verify the published update manifest in release audits
2026-10-28 19:25:21 +00:00
SAPHID 6bcce9cbf7 Gate publication on uploaded manifest readback 2026-10-28 19:14:10 +00:00
SAPHID 8f1a811e1d Verify published update manifest in release asset audit 2026-10-28 19:08:59 +00:00
Alex Southwell 9658f38382 Merge pull request #382 from saphid/ci/reusable-release-assets-audit
Make release installer checks repeatable
2026-10-28 18:55:14 +00:00
SAPHID eb77fe1d2f Make release installer checks repeatable 2026-10-28 18:47:40 +00:00
Alex Southwell c733fab6ae Merge pull request #381 from saphid/docs/v0453-release-verification
Record v0.4.53 release verification
2026-10-28 18:28:40 +00:00
SAPHID 2fcf50e59a docs: record v0.4.53 release verification 2026-10-28 18:16:29 +00:00
Alex Southwell e4ce91d686 Merge pull request #380 from saphid/release/v0.4.53
Prepare Frame Control v0.4.53 candidate
2026-10-28 17:47:40 +00:00
SAPHID a1a376b4ee docs: confirm v0.4.53 source after support matrix merge 2026-10-28 17:23:18 +00:00
SAPHID fe8d94b00b Merge remote-tracking branch 'origin/main' into release/v0.4.53 2026-10-28 17:22:47 +00:00
Alex Southwell 05be1e2c13 Merge pull request #379 from saphid/docs/support-matrix-v0452
docs: align support matrix with v0.4.52 evidence
2026-10-28 17:22:17 +00:00
SAPHID e0bdaebe4f docs: compare candidate source with locked smoke 2026-10-28 17:09:57 +00:00
SAPHID d977bc3d54 Merge remote-tracking branch 'origin/docs/support-matrix-v0452' into release/v0.4.53 2026-10-28 17:09:17 +00:00
SAPHID d12071a1e7 docs: record v0.4.53 candidate smoke 2026-10-28 17:07:36 +00:00
SAPHID 2aa77b30d6 Prepare Frame Control v0.4.53 candidate 2026-10-28 16:59:05 +00:00
SAPHID a1bc084400 Merge remote-tracking branch 'origin/main' into docs/support-matrix-v0452 2026-10-28 16:55:13 +00:00
Alex Southwell 978af2f287 Merge pull request #377 from saphid/fix/direct-local-transfer
fix: copy large desktop files without HTTP staging
2026-10-28 16:54:36 +00:00
SAPHID fbeb24d754 docs: align support matrix with v0.4.52 evidence 2026-10-28 16:40:32 +00:00
SAPHID dc139633a9 Merge remote-tracking branch 'origin/main' into fix/direct-local-transfer 2026-10-28 16:35:27 +00:00
SAPHID 85c0bf542c docs: record final direct-transfer test suite 2026-10-28 16:35:14 +00:00
Alex Southwell 0f421bfd2d Merge pull request #378 from saphid/fix/windows-denied-post-test
test: avoid unread body in Windows auth guard probe
2026-10-28 16:35:02 +00:00
SAPHID 27b5bf27f3 test: exercise selected-headset direct copy over HTTP 2026-10-28 16:29:40 +00:00
SAPHID c33dc0ccb6 test: avoid unread body in Windows auth guard probe 2026-10-28 16:22:47 +00:00
SAPHID 78634c09a9 Merge remote-tracking branch 'origin/main' into fix/direct-local-transfer 2026-10-28 16:17:10 +00:00
SAPHID 5a83989138 fix: copy large desktop files without HTTP staging 2026-10-28 16:16:48 +00:00
Alex Southwell b5bbcec1a0 Merge pull request #376 from saphid/docs/v0452-release-verification
docs: record v0.4.52 release verification
2026-10-28 16:14:01 +00:00
SAPHID bd36929053 docs: record v0.4.52 release verification 2026-10-28 16:00:46 +00:00
Alex Southwell ac04407bcc Merge pull request #375 from saphid/release/v0.4.52
Prepare Frame Control v0.4.52 candidate
2026-10-28 15:20:04 +00:00
SAPHID 432b29f961 Record locked v0.4.52 source smoke and cleanup 2026-10-28 14:47:33 +00:00
SAPHID c88df07c81 Record merged installer fix in v0.4.52 candidate 2026-10-28 14:40:51 +00:00
SAPHID 423a86fefc Merge remote-tracking branch 'origin/main' into release/v0.4.52 2026-10-28 14:40:13 +00:00
Alex Southwell 5847a81066 Merge pull request #374 from saphid/fix/installer-fetch-transient-retry
Retry transient installer dependency downloads
2026-10-28 14:39:50 +00:00
SAPHID 3a8fee1d45 Prepare Frame Control v0.4.52 candidate 2026-10-28 14:38:10 +00:00
65 changed files with 2509 additions and 113 deletions

No files matched your search

+5
View File
@@ -77,6 +77,11 @@ jobs:
- name: OBS lifecycle tests
run: node app/test/obs-source.test.js
- name: Server tests
# The dedicated wireless-windows job runs the live inventory on a fresh
# Windows runner; do not repeat it after the full server suite has
# consumed this runner's probe budget.
env:
FRAME_SKIP_WINDOWS_WIRELESS_LIVE: "1"
run: python -m unittest discover -s tests -v
# Fast, PC-only Windows query validation, without capture libraries or a headset.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.51",
"version": "0.4.53",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.51",
"version": "0.4.53",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.51",
"version": "0.4.53",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
-1
View File
@@ -181,7 +181,6 @@ old="$stage/old.app"
if mv "$app" "$old"; then
if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
fi
xattr -dr com.apple.quarantine "$app" 2>/dev/null
rm -rf "$stage"
open "$app"
`;
+4 -4
View File
@@ -1,11 +1,11 @@
# What we have tested
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.
Current published release: Frame Control **v0.4.53**, 28 October 2026. Rows identify the last direct check of each feature; an older row does not mean the latest installer was retested on that hardware. “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. 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): 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) |
| Core title smoke test | A Linux host ran the v0.4.53 versioned source candidate against the real Frame under a unique lock; only documentation changed before the tagged release, leaving its executable source the same: 12 steps passed, one optional x86_64 launch was skipped, zero failed; a separate locked cleanup audit passed | No | The headset was in standby and unworn. Steam Linux Runtime 4 was absent, so the x86_64 test executable did not launch. Worn rendering and physical-controller input were not checked | 28 Oct, v0.4.53 tag `v0.4.53`, Frame OS 0.5.6 build `20261022.6153909`: [release verification](evidence/v0453-release-2026-10-28.md), [candidate smoke](evidence/v0453-candidate-2026-10-28.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) |
@@ -24,7 +24,7 @@ Release baseline: Frame Control **v0.4.41**, 26 October 2026. Rows also identify
| 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.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) |
| Experimental Decky Loader manager | Earlier real Frame checks passed opt-in ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove. The current exact-version catalog records six scoped works: Notebook default drawing, Volume Mixer and Mixer silent per-app volume, Volume Boost muted sink controls, DeckySpy metrics, and Simple Timer silent in-panel timers | No | v0.4.53 classifies 116 exact-version plugins as **6 works / 54 partial / 45 broken / 11 N/A**. “Works” applies only to each documented behavior, never worn VR. Network Sentinel and Battery Tracker are partial; their managed real-Frame ARM64 behavior is unverified. Most other backends, audible output, physical-controller use and wearer-visible official panels remain unverified | 28 Oct, v0.4.53: [exact-version table](framey-compatibility.md), [release verification](evidence/v0453-release-2026-10-28.md), [DeckySpy evidence](evidence/framey-deckyspy-2026-10-26.md), [Simple Timer evidence](evidence/framey-simple-timer-2026-10-26.md), [audio evidence](evidence/framey-audio-arm64-2026-10-26.md) |
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 exercise showed the repository sample panel in stereo compositor and mirror captures. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file, and confirmed it after a fresh iframe reopen; temporary services were removed and original power settings restored | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. Fake-Frame install/remove and local Chromium plugin initialization do not prove an official plugin is visible or usable in worn VR | 28 Oct, v0.4.53 catalog with earlier 25–26 Oct VR evidence: [unattended sample evidence](evidence/framey-vr-2026-10-25/README.md), [compatibility table](framey-compatibility.md), [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,31 @@
# Large Send to Frame files: source and offline verification
[Issue #373](https://github.com/saphid/frame-control/issues/373) reports that
a 26.5 GB file selected from external USB media failed with “empty or
too-large upload” in Frame Control 0.4.8. Current main still imposed an
8 GiB `POST /api/upload` body cap for **Send to Frame**, even in the desktop
app. The desktop already knows the selected local file path, but previously
sent the whole file through HTTP into another temporary copy before SSH.
The desktop page now passes that path to `POST /api/transfer`, which is
enabled in the app-started server. It checks for an absolute, nonempty
regular file and copies it directly by the
existing rsync/scp route to `~/Downloads`. This avoids both the 8 GiB HTTP
cap and a duplicate temporary file on the computer. The endpoint preserves
the selected-headset work guard. Large copies have a finite timeout that
scales with source size. Plain browser uploads still have an 8 GiB cap and
now state it before sending bytes; the page says which route applies.
Offline checks use a mocked 27 GiB file size without allocating that space.
They verify the direct server copy path, a timeout longer than one hour, the
page's desktop-versus-browser choice, and early rejection of an oversized
browser upload. Loopback tests confirm the browser cannot call the direct
route, reject a mismatched selected headset with HTTP 409, and accept the
matching desktop request with HTTP 200. Unit tests reject missing, relative,
directory, hidden and empty source paths before any SSH. The full Python
suite on the final branch passed **1,690 tests, 16 skipped**. Hosted CI is a
separate merge gate.
An actual 26.5 GB transfer from USB media to a Frame, end-to-end throughput,
resume after interruption and free-space behavior remain unverified. No real
headset or user file was touched for this change.
@@ -0,0 +1,20 @@
# Frame smoke sensor validation, 29 October 2026
The locked real Frame smoke reads SteamVR activity, IIO proximity and battery
before installing a test title. The proximity reader converts sensor text to a
Python float. Python accepts `NaN` and infinity as floats, while range
comparisons with `NaN` do not reject it. Before this change, a proximity array
containing `NaN` could pass the standby preflight and the saved-result release
verifier.
Focused local tests supplied `NaN`, positive and negative infinity to both
the live preflight parser and the release-result verifier. They also supplied
boolean, nonfinite, below-threshold and above-100 battery values. Every invalid
sample was rejected. Valid standby, finite proximity and 99–100% integer
battery samples continued to pass. The 25 smoke/cleanup tests, six result-gate
tests and 16 publisher tests passed locally.
This evidence uses simulated sensor responses. It does not record a real Frame
smoke, a worn-headset check, or controller input. Release publication still
requires a complete locked smoke from the exact clean release commit, including
the x86 launch that was skipped in the held v0.4.54 candidate.
@@ -9,3 +9,13 @@ Under a unique owner token on `/tmp/frame-test.lock`, a **read-only** real Frame
In network/device-isolated, unprivileged **actual Decky 3.3.0 amd64**, the exact adapted archive was discovered. A private Frame-shaped supply fixture produced history and per-app power through public legacy RPC. The complete native regression runner, including existing negative controls, passed. Local managed install/remove tests and the exact-source/manifest guard passed. CI's native ARM64 and fake Frame e2e jobs remain required before merge.
This supports **partial** compatibility. The official frontend initialized in the earlier opaque Chromium audit with backend calls denied; it has not been tested with these live samples. Actual managed recording on Frame ARM64, long-term history, foreground-app attribution, panel controls, and worn VR remain unverified. Existing copies need update or reinstall.
## Hosted ARM64 follow-up
The [main native Decky run](https://github.com/saphid/frame-control/actions/runs/40776292569)
on `dad5f78a02dd477a689689865cd4ef4205b87e22` passed the exact Battery
Tracker case on native amd64 **and ARM64** runners. Both logs report `PASS 67`
for discovery, Frame-shaped private battery samples, history and per-app power
through actual Decky 3.3.0 RPC. These were disposable fixture supplies inside
isolated containers. The managed plugin still has not recorded a real Frame
battery sample; the remaining limits above still apply.
@@ -0,0 +1,22 @@
# Deck Shelves 2.0.0 native settings probe, 29 October 2026
The unchanged official archive for store ID 135 has SHA-256
`b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91`.
The earlier [browser evidence](framey-deck-shelves-browser-2026-10-26.md)
proved only local frontend settings rendering and a stubbed Python backend.
`python3 scripts/framey-test-decky-runtime.py --case 135 --output /tmp/framey-deck-shelves-native-20261029.json`
passed with the published Decky 3.3.0 x86_64 loader in a disposable container
with no external network, host devices, session bus or user home. Decky
discovered the official API v1 plugin. Its public `get_settings` returned
disabled defaults; `set_settings` saved one private test shelf; the JSON file
and a fresh `get_settings` agreed. The same shelf ID and title returned after
stopping and restarting the Decky loader. The runner removed its container
and image. No headset lock, real Frame service, Steam library or external
account was used.
This verifies only private backend settings persistence. The plugin's primary
Steam Home shelf injection has no verified target in Framey's isolated panel.
Real Frame ARM64 settings, wearer-visible VR and physical controller input
remain unverified. Deck Shelves stays **partial**; compatibility counts do not
change.
@@ -0,0 +1,29 @@
# DeckyFileServer 1.2.0 address correction, 28 October 2026
Official store ID 60 and ZIP SHA-256
`7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0`.
The exact bundled frontend `dist/index.js` has SHA-256
`791015d8b3f234fad89ce422b09fb32bbad05e7c867d501706cfbcf9f7c6187f`.
Only that pinned managed archive receives this change. A changed archive keeps
the normal native/root rejection.
The [pinned corresponding Go source](../../ui/vendor/deckyfileserver-1.2.0/README.md)
calls `ListenAndServeTLS` for every server start. The official frontend instead
constructs `http://steamdeck:<port>` when uploads are off, and toggles the
second address between HTTP and HTTPS using the upload setting. Neither rule
matches the server. The `steamdeck` hostname also has no verified resolution
on Frame. Earlier [real Frame backend evidence](framey-fileserver-arm64-2026-10-27.md)
confirmed private HTTPS listing and download with uploads disabled.
The managed installer now checks the original frontend hash and replaces the
two displayed addresses with one `https://<backend IP>:<port>` address. A
rendered-element check executes the installed official bundle and verifies
that URL with uploads off and on; it also checks that no `steamdeck` address
remains. Receipts add `deckyfileserver-https-address`. Older managed copies
without it display an update or reinstall notice; the compatibility status
stays **partial**.
This check does not prove a live browser can reach every returned IP, or that
the folder picker works in Framey. Use a preconfigured directory. Uploads,
generated thumbnails, worn VR, physical controllers and wearer readability
remain unverified. This correction did not touch the real headset.
@@ -0,0 +1,35 @@
# Free Loader 1.6.0: private Decky backend check
The official store archive for ID 63 was downloaded and matched SHA-256
`05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a`.
The published Decky 3.3.0 amd64 loader matched SHA-256
`e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181`.
An unprivileged, disposable Debian container ran that loader and the exact
official plugin with no network, host devices, host session or writable host
mount. Decky discovered Free Loader as enabled. Its API 1 methods returned 14
default settings and an empty local deals map. A private `larger_text` setting
changed to `true` and read back; startup created only the container's private
settings file, and `read_deals` created an empty private deals cache. No feed,
notification, Steam store or external account method was called. The local
probe exited successfully; its container was removed. The committed native
Decky runtime case repeats these calls and also checks persistence after a
loader restart on the CI runner's native architecture. The local x86_64 run
passed all 14 runtime cases, including Free Loader and the existing negative
regressions. The runner removed its temporary containers and image.
This strengthens the **partial** label. It does not verify a live feed refresh,
notification delivery, real Frame ARM64 service, rendered deal cards, worn VR
or controller interaction. The earlier opaque Chromium check had denied backend
and network calls, so it could not establish that deal cards render with live
data. No headset was touched for this check.
## Hosted ARM64 follow-up
The [main native Decky run](https://github.com/saphid/frame-control/actions/runs/40776292569)
on `dad5f78a02dd477a689689865cd4ef4205b87e22` passed the exact Free Loader
case on native amd64 **and ARM64** runners. Both logs report `PASS 63` for
discovery, default settings, empty local deals, private setting write/read and
restart persistence. The runners used separate network and device isolated
containers; no feed was fetched and no real Frame service was started. The
remaining partial-compatibility limits above still apply.
@@ -0,0 +1,24 @@
# Game Sync 1.0.4 isolated backend check, 29 October 2026
The official Decky archive for Game Sync (ID 104) is 2,228,806 bytes with
SHA-256 `65e021ebb37d2ebfbfbe3dbc8af95aef9556cf484730362c6b26c4ecc3e62ccd`.
Its unmodified ZIP is pinned as `tests/fixtures/framey-official/104.zip`.
`python3 scripts/framey-test-decky-runtime.py --case 104 --output /tmp/framey-gamesync-native-104.json`
passed with the published Decky 3.3.0 x86_64 loader, SHA-256
`e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181`.
The disposable container had no external network or host devices, dropped
capabilities and used a private home. The real loader discovered the exact
plugin. Its `get_config` returned defaults with automatic game sync and
capture upload disabled. A private `advanced_mode` setting changed from false
to true and remained true after the loader restarted. The runner removed its
container and image; a separate `docker ps` and image-list audit found no
owned resources.
The original opaque-iframe browser audit blocked `get_config` before panel
initialization completed, so it did not prove a usable frontend. This native
test confirms only a configuration backend path. No cloud authentication,
rclone download or sync, screenshot deletion, process signal, game event,
real Frame backend, VR visibility or physical controller was exercised.
The published `rclone_manager.py` names an amd64 rclone download, so this
result does not establish ARM64 sync support. Game Sync remains **partial**.
@@ -9,3 +9,13 @@ The managed install checks the complete archive digest, manifest and Python sour
Local transaction tests installed and removed the exact archive, checked the native wheel and unmodified license, and rejected an altered archive. In network/device-isolated, unprivileged **actual Decky 3.3.0 amd64**, the managed plugin was discovered, imported native psutil, returned default and updated settings, reported empty history and bandwidth, started its monitoring loop, produced a history sample with quality and bandwidth fields, then stopped. The full native regression runner also passed its other cases, including FileServer and negative controls. The first run's existing large FileServer install timed out at 15 seconds under host load; its bounded timeout is now 30 seconds and the complete rerun passed. No external network call succeeded in the isolated container. Native ARM64 and fake Frame install/remove are CI gates for this change; neither is claimed by this local evidence.
The preexisting local Chromium audit initialized the exact frontend with backend calls denied. It did not prove a functioning panel. External latency/DNS, Wi-Fi scans, actual Frame ARM64 runtime, frontend controls, and worn VR remain unverified. This is a **partial** compatibility result, not a full monitoring or headset-usability claim. No real Frame, normal service, power value, or physical network setting was touched in this check.
## Hosted ARM64 follow-up
The [main native Decky run](https://github.com/saphid/frame-control/actions/runs/40776292569)
on `dad5f78a02dd477a689689865cd4ef4205b87e22` passed the exact Network
Sentinel case on native amd64 **and ARM64** runners. Both logs report `PASS 114`
for discovery, native psutil import, private settings and one isolated
monitoring-loop sample through actual Decky 3.3.0 RPC. The containers had no
external network or Frame device access. Real Frame monitoring, external
latency/DNS, panel controls and worn VR remain unverified.
@@ -0,0 +1,46 @@
# RadiYo! 1.3 isolated backend check, 29 October 2026
The official Decky store archive for RadiYo! (ID 24) is 33,686 bytes with
SHA-256 `647111578aa7a5ecd16af8d7fe6c068a6d7ea8737c6dce5ac2d52e84a549ec43`.
Its unmodified ZIP is pinned as `tests/fixtures/framey-official/24.zip` and
passes Frame Control's archive validation and fake Frame panel packaging.
`python3 scripts/framey-test-decky-runtime.py --case 24 --output /tmp/framey-radiyo-decky-runtime-20261029.json`
ran the published Decky 3.3.0 x86_64 loader, digest
`e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181`,
in a disposable container with `--network none`, no host devices, dropped
capabilities and a private home. The real loader discovered the exact plugin
and dispatched its legacy `fetch` and `getRedirectUrl` RPCs. A server bound to
container loopback returned a fixed station marker: regular fetch returned it,
custom-station fetch omitted the body as upstream intends, and a redirect
resolved to the same fixture. The runner removed its container and image.
The previously recorded opaque-iframe browser audit showed frontend
initialization, but denied backend and network calls. This native probe adds
backend transport evidence only. No external radio station, HTTPS endpoint,
audio playback, real Frame service, VR visibility or controller was tested.
The published backend sets `check_hostname=False` and `CERT_NONE` for its
outbound HTTPS requests; the loopback HTTP test did not exercise that path.
RadiYo! remains **partial**. The compatibility count does not change.
## Managed TLS repair
The exact official archive's `radiyo-steam-deck/main.py` has SHA-256
`868332dbe3feb1e7a81e35a0c01fdc0a6b68421ec1f84e238564bfad4946e87e`.
The managed installer checks that hash, removes the two certificate and
hostname bypasses from its private copy, and formats certificate errors as
strings for both `fetch` and `getRedirectUrl`. The official ZIP is unchanged;
an already installed managed copy needs Update to receive this repair.
`python3 scripts/framey-test-decky-runtime.py --case 24 --output /tmp/framey-radiyo-tls-runtime-20261029.json`
passed on native x86_64 with the published Decky 3.3.0 loader in a disposable,
network-isolated container. HTTP loopback fetch, custom fetch and redirect
still passed. Both HTTPS methods returned `CERTIFICATE_VERIFY_FAILED` for the
loopback self-signed test certificate. The disposable container and image were
removed. The 40 store transaction tests passed, including managed RadiYo
install, receipt check and removal. No headset lock was taken, and no real
Frame or external station was contacted.
This proves rejection of an untrusted certificate in the isolated backend.
Trusted HTTPS stations, audio playback, live frontend controls, real Frame and
worn VR behavior remain unverified. RadiYo! stays **partial**.
@@ -0,0 +1,28 @@
# Shotty native Decky backend check, 28 October 2026
The exact official Shotty 0.1.0 archive (store ID 53, SHA-256
`ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe`)
receives Frame Control's digest-scoped `shotty-click-app-dir` module during a
managed install. The [earlier isolated Python check](framey-shotty-python-2026-10-26.md)
copied one synthetic screenshot into a temporary `Pictures` folder.
The [main native Decky run](https://github.com/saphid/frame-control/actions/runs/40776292569)
on commit `dad5f78a02dd477a689689865cd4ef4205b87e22` passed Shotty's
`tests/decky-runtime/cases.json` case on both native amd64 and ARM64 runners.
The test used the digest-verified published Decky 3.3.0 loader and exact archive
in separate disposable, network and device isolated containers. It installed
the managed archive, checked the shim receipt and plugin discovery, then called
the legacy backend methods through Decky's real websocket RPC:
| Runner | Observed result |
| --- | --- |
| Ubuntu amd64 | `was_rescued` returned `false`; `aggregate_all(allapps=[])` returned `0` |
| Ubuntu ARM64 | `was_rescued` returned `false`; `aggregate_all(allapps=[])` returned `0` |
Both job logs report `PASS 53: discovery, backend import and empty screenshot
aggregation passed`. The containers were removed by the test runner. This
corrects the prior catalog statement that actual Decky ARM64 backend loading
was unverified. The empty RPCs do not exercise a real screenshot or the
continuous rescuer. The managed backend on a real Frame, live screenshot
notifications and copies, wearer-visible panel and physical controllers remain
unverified, so Shotty stays **partial**.
@@ -0,0 +1,27 @@
# Shotty screenshot copy through native Decky RPC, 28 October 2026
The exact official Shotty 0.1.0 archive (store ID 53, SHA-256
`ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe`)
was installed with Frame Control's digest-scoped `shotty-click-app-dir` shim.
The local `scripts/framey-test-decky-runtime.py` run used the published,
digest-verified Decky 3.3.0 amd64 loader in a disposable unprivileged container
with no network, host devices or writable host mount. Its full 14-case native
suite, including three negative controls, exited successfully.
The Shotty case placed two small marker files under a private synthetic Steam
screenshots directory in the container's temporary home. Calls went through
Decky's real legacy websocket RPC. `set_id_map_fronend` supplied a private game
name; `copy_screenshot` returned `true` for the selected file. Independent
reads of `Pictures/Screenshots/Private Game/selected.jpg` and
`Pictures/Screenshots/most_recent.jpg` matched the marker bytes. A later
`aggregate_all` returned `1`, and the historical file in the named folder
also matched its source bytes. Empty aggregation and `was_rescued=false` still
passed. The runner removed its owned containers and image; no headset or user
screenshot was accessed.
This local run checks the real Decky RPC and same-filesystem copy path with
synthetic files on amd64. The PR's hosted native ARM64 gate must pass before
merge; this local result alone does not establish ARM64 behavior. Steam's live
screenshot notification, cross-filesystem copies, the continuous rescuer, real
Frame installation, wearer-visible VR and physical controller use remain
unverified. Shotty remains **partial**.
@@ -0,0 +1,27 @@
# Static limits for two official Decky archives, 28 October 2026
The published archives below were downloaded from Decky's version CDN and
checked against the catalog's exact SHA-256 before inspection. No plugin code,
backend, plugin-initiated network request or headset operation ran in this
audit. Their status labels remain unchanged.
## IsThereAnyDeal for Deck 1.1.1
Store ID 81, SHA-256
`18e133b2e6203f16fd1ee2ed83b5a90bad34491515bef7ec1c82d525f0bfadef`.
The backend exposes settings load/save. The published frontend uses
`fetchNoCors` for deal lookups with an embedded upstream API key and attempts
to find a Steam store debugger tab through `localhost:8080/json`. The key was
not decoded or used. The existing real Frame country read and local settings
render remain the verified scope; live deals, settings writes and Steam page
integration remain unverified. Status stays **partial**.
## Decky Clipboard 1.0.1
Store ID 119, SHA-256
`d283bd9a5c35b3fac3788391736a6763f3e8c4615831a7c560c9fa6e6c0950ac`.
The manifest requests root and the ZIP bundles an x86-64 `bin/xclip`. In the
published `main.py`, `auto_start` defaults to true; `WebServer.start` binds
`0.0.0.0:8765`, and the clipboard GET/POST routes have no authentication
check. A managed adaptation would need to address that behavior before any
Frame install. The archive remains **broken** and was not installed or run.
@@ -0,0 +1,27 @@
# Storage Cleaner 1.4.0 managed backend, 29 October 2026
The exact official store archive for ID 50 has SHA-256
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`.
Its `decky-storage-cleaner/main.py` has SHA-256
`6fd4d012568edae22ba421495455b3c02899155e4c7484f5aa5be6ce9b14dd5f`.
The original `delete_cache` awaits synchronous `shutil.rmtree`: a disposable
fixture was deleted, then the method raised `TypeError`. The managed installer
checks both digests before adapting its private copy. It calls `rmtree`
synchronously and accepts only `shadercache`, `compatdata`, or a numeric app-ID
child under either. It rejects traversal and symlink paths. The official ZIP
is unchanged; an already installed managed copy needs Update.
`python3 scripts/framey-test-decky-runtime.py --case 50 --output /tmp/framey-storage-cleaner-native-20261029.json`
passed on native x86_64 with the published Decky 3.3.0 loader. Its disposable
container had no external network, host devices, session bus, or user home.
Decky discovered the official plugin, returned the private fixture's size
and game-list row, deleted one selected shader-cache app directory, then
deleted the whole private compatdata fixture. Both RPCs completed without
the previous error. The 41 store transaction tests passed, including guard
checks for traversal and symlink paths, install receipt, and remove. The
container and image were removed. No real Frame lock or device was used.
The deletion fixture does not prove real Steam cache cleanup or recovery.
Real Frame ARM64 backend, frontend confirmation flow, worn VR and physical
controller behavior remain unverified. Storage Cleaner stays **partial**;
the compatibility counts do not change.
@@ -0,0 +1,18 @@
# Main Windows test gate, 28 October 2026
Exact main commit `b5bbcec1a0cc3f8b3d25ef2686fd8315e209f167`
([checks run 40723931638](https://github.com/saphid/frame-control/actions/runs/40723931638))
failed in `server-tests (windows-latest, 3.12)`. Its server test step had one
error: `test_settings_need_and_accept_the_ui_key` received Windows
`ConnectionAbortedError [WinError 10053]` while reading an unauthorized
POST response. The test sent a JSON body, but the missing-key guard rejects
the request before reading it. Closing a connection with an unread body can
reset it on Windows; that is the likely cause, rather than a settings write
failure. The other Windows tests completed, and the exact-main native Decky
runtime run passed.
The test now sends an empty POST when checking that a missing key is refused.
Its subsequent authorized POST still sends a settings body and verifies the
saved value. The local `tests/test_server.py` module passed 45 tests after
this change. Hosted Windows CI on the repair commit and green exact-main CI
remain required before further merges.
@@ -0,0 +1,35 @@
# Repeatable installer audit command, 28 October 2026
The independent installer check used for v0.4.53 was previously a local
one-off script. The new script at scripts/audit-release-assets.py makes that
check repeatable before future releases are published. It reads the release
by exact tag or the draft's exact version title, requires ten installers plus
SHA256SUMS, downloads each by asset ID, checks size and SHA-256 against GitHub
metadata, matches the downloaded checksum file byte for byte, and inspects
package formats and versions. On an x86_64 Linux host, its optional flag also
launches the tagged x86_64 AppImage under isolated Xvfb.
The command writes a JSON report only after all checks pass and refuses to
overwrite an existing report. It performs no headset operation and makes no
GitHub changes. It does not prove physical Mac or Windows installer launch.
## Verification
- Eight focused offline tests passed, including incomplete metadata, wrong
Mac bundle version, wrong AppImage architecture, a bad downloaded checksum
file, and refusal to overwrite a report. Three existing checksum-generator
tests passed.
- After v0.4.53 publication, the new command audited its public release with
the tag v0.4.53, report path /tmp/frame-v0453-reusable-audit.json, and the
--launch-x86-appimage option. It exited successfully after independently
downloading all ten installers. Their names, sizes, hashes and package
checks matched the [prepublication asset audit](v0453-assets-2026-10-28.json),
and the downloaded SHA256SUMS had the same SHA-256:
813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c.
The x86_64 AppImage's bundled server returned HTTP 200 with FrameControl/1
and stopped after app exit. The local JSON report had SHA-256
ad8d7feaf6c0398b216f43c00206dd01d6efc714e3b341048bece96c0e5be376.
This postpublication repeat verifies the new command; it does not replace
the prepublication v0.4.53 release gates recorded in
[release verification](v0453-release-2026-10-28.md).
@@ -0,0 +1,20 @@
# Release audit pagination check, 29 October 2026
The independent installer audit previously returned on the first GitHub
release-list page containing a matching tag or draft title. A second matching
draft on a later page was never inspected. The audit could therefore report
one release while the complete list was ambiguous.
Local tests mocked two GitHub pages with the same version draft. The old code
accepted the first draft; the corrected lookup rejects the duplicate after
reading both pages. A second test finds a release on page two, and a full
20-page response fails closed because uniqueness cannot be established. All
13 focused `test_audit_release_assets.py` tests passed locally.
The corrected lookup also read GitHub's live release list without downloading
assets or changing a release. It found the published `v0.4.53` release as ID
`427006429`.
The duplicate and pagination cases use mocked API responses. No release asset
was downloaded, no release was changed, and these results are not a full release
verification or real Frame smoke.
@@ -0,0 +1,76 @@
{
"tag": "v0.4.52",
"releaseId": 426854020,
"tagNameAtAudit": "v0.4.52",
"draftAtAudit": true,
"sha256sumsSha256": "71306c21818a8842f60633540a16457e962e9ffc7fac01a547a5a2fd17f32904",
"assets": [
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 177628410,
"sha256": "2dad765390965eaa01ab9a1737bffddcb6c8d481e4d255322e663bcdb7ad1e30",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 177451653,
"sha256": "abb17c3e3de2ada967d10b7e277c9f122fb731108efc6d70fed9b87e92e66106",
"zipCrc": "ok",
"members": 1673,
"bundleVersion": "0.4.52"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 184701118,
"sha256": "1317ee7b30de46bc69a19d42d68245dbf55ca02fbc6ceee5761534ddb8706bab",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 184538083,
"sha256": "4d0fbf1904073a7b28d0e968013bd7ca34600536538db32d00b03cf6aaf5b3b4",
"zipCrc": "ok",
"members": 1669,
"bundleVersion": "0.4.52"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 151885028,
"sha256": "bb190c20abb2fae1878b732819ae64c214c0cfd627c3a6279b25e9d9c0517740",
"peMachine": 332
},
{
"name": "Frame-Control-win-x64.zip",
"size": 198840260,
"sha256": "ef7d248967bda011951bd703972f3a1cb00a43e484af0daf58a5f754e2f2486c",
"zipCrc": "ok",
"members": 1517
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 179997222,
"sha256": "6241e068222a2c05f41946e94dedb1456dce25ae94d4857bcb7a4d1891feac7d",
"elfMachine": 62
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 171579364,
"sha256": "27da455144f8c88ba6a56d6eed90e1806c098d5f6806790089513ba77ec79f52",
"elfMachine": 183
},
{
"name": "Frame-Control-linux-amd64.deb",
"size": 146749772,
"sha256": "97425cbd4d460460b9f0574519bbf11afa1b8cc8003e1b784d6c002244da4075",
"debVersion": "0.4.52",
"architecture": "amd64"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 133832104,
"sha256": "58c98ed203caca60ce022c90232c13591d11c72ff5b28c5860aa7b221ec3e918",
"debVersion": "0.4.52",
"architecture": "arm64"
}
]
}
@@ -0,0 +1,63 @@
# v0.4.52 candidate gate, 28 October 2026
This candidate bumps the desktop app and lockfile to 0.4.52. It contains
installer-fetch [PR #374](https://github.com/saphid/frame-control/pull/374),
merged to main at `5847a810667724ef423f278a83650f9f1c6d18b5`. Its exact
reviewed head `082af24115a87b3bf11a77c2cf93b5bcdd3b4409` passed
[CI and e2e](https://github.com/saphid/frame-control/actions/runs/40706672771),
[native Decky](https://github.com/saphid/frame-control/actions/runs/40706672854)
and [all four installer previews](https://github.com/saphid/frame-control/actions/runs/40706672774).
The merge commit's own CI is pending. No release has been published from this
branch. The versioned source candidate tested below was
`c88df07c816f1475268760add93a5ab072463688`.
Since v0.4.51, main added the exact-archive Network Sentinel and Battery
Tracker Framey adaptations and CI/release safety fixes. PR #374 retries
transient installer dependency failures, including responses aborted after
partial bytes; pinned SHA-256 verification remains required before writing a
download. The exact-version official Decky catalog is **6 works / 54 partial /
45 broken / 11 N/A**. Network Sentinel and Battery Tracker are partial;
managed real Frame operation, worn visibility and physical-controller input
remain unverified.
## Locked real Frame smoke
At 14:42:13 UTC, `scripts/frame-smoke.sh` ran from the versioned source
candidate under its unique-owner atomic `/tmp/frame-test.lock`. Its read-only
preflight found Frame OS 0.5.6 build `20261022.6153909`, SteamVR activity
**3 (standby)**, proximity raw **2.3125**, and battery **100%**. ARM64 and
Windows/Proton test titles installed, launched and were removed. The x86_64
title installed and was removed; its optional launch was skipped because
Steam Linux Runtime 4 was absent. The result was **12 passed, one optional
skip, zero failed**. Owned cleanup passed and the runner released only its
lock. The private result is
`/tmp/frame-control-v0452-smoke-results/2026-10-28T144213Z-20261022.6153909.json`
(SHA-256 `42ea66740d001cc097ce9e871be6b01be1a10f184ab4480019b0a306851cfc57`).
A separate read-only audit acquired a fresh unique lock after smoke. It
found activity **3**, proximity raw **2.3125**, battery **100%**, no exact
reserved smoke title files or metadata, and no processes owned by those
title paths. It released its own lock. The private audit result is
`/tmp/frame-v0452-cleanup-audit.json` (SHA-256
`daebfb5c02d6cf59e5cee092e3fa90a07b3dc9f105e167d46a4f4327c541c7ce`).
No power setting or security state was changed. No wearer was confirmed; no
worn panel, physical-controller or official plugin interaction was tested.
The hourly worn check remains enabled.
## Remaining release gates
- Main's #374 merge commit needs green exact-commit CI, e2e and native Decky
runtime before the candidate can merge.
- This candidate needs its own exact-head CI, e2e, native Decky runtime and
all four installer previews after its sync with the final main merge.
- The locked source smoke and independent cleanup audit above passed. Their
optional x86_64 runtime skip does not prove that launch; no worn display or
physical-controller claim follows from standby smoke.
- After candidate merge, the exact main commit needs green CI, e2e and native
Decky runtime. The annotated tag then needs all four installer builds and
checksums on that same commit, independent downloads and package checks,
release verification and a publisher dry run. Any failed, cancelled,
skipped, pending or unverified required gate holds publication.
The published release and update channel remain at v0.4.51 until all gates
pass and the draft is explicitly published.
+70
View File
@@ -0,0 +1,70 @@
# v0.4.52 tagged release verification, 28 October 2026
The annotated `v0.4.52` tag dereferences to merged main commit
`ac04407bcc799e9e88ce31c3f602020e9f3b1cee`. Its tree
`864fde2ec006afe00eb886279af9361a6e3b657e` exactly matches reviewed
[candidate PR #375](https://github.com/saphid/frame-control/pull/375) head
`432b29f9618d1e300abdf8437a2bd8360d54cffb`. The candidate's locked
source smoke and cleanup are recorded in [its evidence](v0452-candidate-2026-10-28.md).
## Exact commit and tagged build gates
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40714882563)
passed on the tag commit: all seven jobs on the first attempt, including
e2e, iOS, Windows, macOS, Linux and the aggregate check.
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40714882617)
passed on the tag commit: both published Decky 3.3.0 AMD64 and ARM64 jobs
on the first attempt.
- The [exact-tag release workflow](https://github.com/saphid/frame-control/actions/runs/40717151326)
passed all six jobs on the first attempt: draft, Linux, Windows, Intel Mac,
Apple Silicon Mac and checksums. No tagged job failed, was cancelled,
skipped or overridden.
Before publication, the unpublished draft held exactly ten installers and
`SHA256SUMS`. Every installer was independently downloaded by GitHub asset
ID. Each downloaded size and SHA-256 matched the asset metadata; the
downloaded `SHA256SUMS` matched all ten entries byte for byte. Its SHA-256 was
`71306c21818a8842f60633540a16457e962e9ffc7fac01a547a5a2fd17f32904`.
The [sanitized asset audit](v0452-assets-2026-10-28.json) records all ten
names, sizes, hashes and package checks. It checked both Mac ZIP CRCs and
bundle version `0.4.52`, both DMG UDIF trailers, Windows ZIP CRC and PE
installer header, both AppImage ELF architectures, and both DEB version and
architecture fields.
The independently downloaded Linux x86_64 AppImage had SHA-256
`6241e068222a2c05f41946e94dedb1456dce25ae94d4857bcb7a4d1891feac7d`.
It launched under isolated Xvfb with a temporary profile, telemetry and
update checks disabled. Its bundled server returned HTTP 200 with
`FrameControl/1` and stopped after app exit. No physical Mac or Windows
launch of these exact installers was performed.
## Real Frame and publication
The [candidate evidence](v0452-candidate-2026-10-28.md) records a unique-lock
real Frame smoke on the version-bumped source: **12 passed, one optional
x86_64 launch skipped, zero failed**. Steam Linux Runtime 4 was absent, so
no x86_64 launch is claimed. A separate fresh-lock audit found no reserved
smoke title files, metadata or owned processes; both locks were released.
SteamVR activity was 3 (standby), proximity raw 2.3125 and battery 100%.
There was no wearer confirmation, wake command or worn interaction test.
The private smoke result's SHA-256 is
`42ea66740d001cc097ce9e871be6b01be1a10f184ab4480019b0a306851cfc57`.
`scripts/publish-release.sh --dry-run v0.4.52` passed exact-main, native
runtime and tagged release jobs, all ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update-manifest checks. Its local log had
SHA-256 `d4264b5ad3106387b63d3fc800f558c912ff36b546ea83d792a5a0e8a4949cc6`.
The real publisher repeated those gates, attached `update.json`, and published
[v0.4.52](https://github.com/saphid/frame-control/releases/tag/v0.4.52) at
**15:52:29 UTC**. GitHub's latest-release API returned `draft=false`, ten
installers, `SHA256SUMS` and `update.json`. A fresh public latest-download of
`update.json` reported version `0.4.52`, the correct release page, ten
names, sizes and hashes matching the independent downloads, and the explicit
limit that no person wore the headset. The public manifest's SHA-256 was
`486bafc8ae4f1feed0f3a1db28b5034498eed78af2a11083be48175489e6d461`.
The exact-version Decky catalog is **6 works / 54 partial / 45 broken /
11 N/A**. Network Sentinel and Battery Tracker remain **partial**. Their
real managed ARM64 operation and wearer-visible behavior are unverified.
Worn VR visibility and physical-controller interaction also remain
unverified. The separate hourly worn check remains enabled.
@@ -0,0 +1,76 @@
{
"tag": "v0.4.53",
"releaseId": 427006429,
"tagNameAtAudit": "v0.4.53",
"draftAtAudit": true,
"sha256sumsSha256": "813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c",
"assets": [
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 177629060,
"sha256": "8ae712108aa3fdfb5f40a2f205db65ffd7694914bab2ac0af7a85dafb1c8c31e",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 177452293,
"sha256": "f6f683e43b397d51432a8651d0b54d5d96cfd3e9d75e0fe4a9769cdd10ccf33b",
"zipCrc": "ok",
"members": 1673,
"bundleVersion": "0.4.53"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 184700330,
"sha256": "7c25a0d6633aa5312c5e7a1abf8d1abd656e109cc78093a2c5bd29bf790d8e03",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 184538726,
"sha256": "fe7c5b46ea718a5e8f5d8cc511813d420fdbdfea4858e4c4c20f92b7aaaed529",
"zipCrc": "ok",
"members": 1669,
"bundleVersion": "0.4.53"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 151885488,
"sha256": "9bdbafc00fd170a4e31dbaafc73ce89c952c85a1cbad9198822ea2c473489597",
"peMachine": 332
},
{
"name": "Frame-Control-win-x64.zip",
"size": 198840858,
"sha256": "8360ea2427a84374a2106267d0440dbf5e4d9b22f9ef44e465d31f81c7ebfaff",
"zipCrc": "ok",
"members": 1517
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 179997230,
"sha256": "7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b",
"elfMachine": 62
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 171579370,
"sha256": "985b0a1c3e04af5cc1a41e2e7ad9e8481aac45762b369044bf314972e894dc64",
"elfMachine": 183
},
{
"name": "Frame-Control-linux-amd64.deb",
"size": 146412012,
"sha256": "2adbef7c1bec3e9e40244984598a2e2c9816eb2c21fdb21d6996f9d30f2d0fec",
"debVersion": "0.4.53",
"architecture": "amd64"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 133498848,
"sha256": "0625dfdb1959f8eab52ac3f6c4bf909b7de3966c9ca959c391e43630516ff9c7",
"debVersion": "0.4.53",
"architecture": "arm64"
}
]
}
@@ -0,0 +1,58 @@
# v0.4.53 candidate smoke, 28 October 2026
The versioned source candidate `2aa77b30d684ee8dc9a3384648bcd9bfd5c634e5`
bumps only `app/package.json` and its lockfile from 0.4.52 to 0.4.53 on
main commit `978af2f2879d80a22a72ea5a6992884424ed3480`. That main commit
contains [PR #377](https://github.com/saphid/frame-control/pull/377), which
copies desktop-selected files directly rather than staging them through the
8 GiB HTTP upload route. Browser uploads keep the 8 GiB limit. A physical
26.5 GB USB-to-Frame transfer has **not** been verified.
## Locked real Frame smoke
At 17:04:22 UTC, `scripts/frame-smoke.sh` ran from that candidate on lxso2.
Before any status read or title change, it acquired `/tmp/frame-test.lock`
atomically with its unique owner token. Its read-only preflight found
SteamVR activity **3 (standby)**, proximity raw **2.3125**, and battery
**100%**. It did not request pairing or VR input.
On Frame OS 0.5.6 build `20261022.6153909`, ARM64 and Windows/Proton tiny
test titles installed, launched and were removed. The x86_64 title installed
and was removed; its optional launch was **skipped** because Steam Linux
Runtime 4 was absent. The result was **12 passed, one optional skip, zero
failed**. The runner's cleanup passed and it released only its own lock.
The private JSON result is
`/tmp/frame-control-v0453-smoke-results/2026-10-28T170422Z-20261022.6153909.json`
with SHA-256
`5a5bc8b1da1ef405235d660a2587c84204afd0816c80f8d9dfd9577dac72d547`.
A separate read-only audit acquired and released a fresh unique-owner lock.
It found activity **3**, proximity raw **2.3125**, battery **100%**, no
reserved smoke title files or metadata, and no processes owned by those
title paths. Its private JSON result is
`/tmp/frame-v0453-cleanup-audit.json` with SHA-256
`6ab884b6adffb14c4643e30ee2e5fe247b2ebba7a3ad5424515927cd40146e79`.
No power or security setting was changed. Nobody was confirmed wearing the
Frame; wearer-visible VR, physical-controller use and the optional x86_64
launch remain unverified. The separate hourly worn check stays enabled.
After this smoke, the local release branch merged the exact green head
`a1bc084400058908132e6937322bc97e71ed5d68` of support-matrix PR #379.
Compared with the smoked commit `2aa77b3`, only this evidence file and
`docs/current-support-matrix.md` changed. A diff over `app`, `ui`, `frame`,
`scripts`, `tests`, `desktop`, `mac` and `ios` was empty; the executable
source tested by smoke remains the same. The branch then merged PR #379's
main commit `05be1e2c1341ad9f36c6d5bbda27e7eaaf84ef59`; the executable
diff remained empty. This does not replace final-commit CI or installer
verification.
## Gates still required
This smoke is evidence for the versioned source candidate, not a release.
The final candidate must pass its own exact-head CI, e2e, native Decky
runtime and four installer
previews. Its final merged main commit must then pass exact-commit CI, e2e
and native runtime. The tag must build all four installer jobs and checksums
without failed, cancelled, skipped or overridden gates. Independent package
checks, release verification and a successful publisher dry run are required
before publication. Any unfinished or failed gate holds v0.4.53 as a draft.
+73
View File
@@ -0,0 +1,73 @@
# v0.4.53 release verification, 28 October 2026
The annotated `v0.4.53` tag dereferences to merged main commit
`e4ce91d6868665e6689a4aa17a27a46bf9abd089`. Its tree matches reviewed
[candidate PR #380](https://github.com/saphid/frame-control/pull/380) head
`a1a376b4ee9464ac9d97426cce21e662e3a9bf1e`; the diff is empty.
## Exact commit gates
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40739079630)
passed all seven jobs on the first attempt, including e2e, iOS, Windows,
macOS, Linux and aggregate checks.
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40739079822)
passed both published Decky 3.3.0 AMD64 and ARM64 jobs on the first attempt.
- Tagged installer and checksum workflow:
[run 40740373224](https://github.com/saphid/frame-control/actions/runs/40740373224).
All six jobs passed on the first attempt: draft, Linux, Windows, Intel Mac,
Apple Silicon Mac and checksums. No tagged job failed, was cancelled,
skipped or overridden.
Before publication the draft held exactly ten installers and `SHA256SUMS`.
Every installer was independently downloaded by GitHub asset ID; each size
and SHA-256 matched the asset metadata. The downloaded `SHA256SUMS` matched
all ten entries byte for byte and had SHA-256
`813441ac5c121e2c9effd274576aa6db1ff01187f811463a1014f86e0172608c`.
The [sanitized asset audit](v0453-assets-2026-10-28.json) records names,
sizes, hashes and package checks. It verified both Mac ZIP CRCs and bundle
version `0.4.53`, both DMG UDIF trailers, Windows ZIP CRC and PE header,
both AppImage ELF architectures, and both DEB version and architecture fields.
## Real Frame and package checks
The [candidate smoke evidence](v0453-candidate-2026-10-28.md)
records a unique-lock source smoke: 12 passed, one optional x86_64 launch
skipped because Steam Linux Runtime 4 was absent, zero failed. Independent
fresh-lock cleanup found no reserved test titles or owned processes. The
source tree changed only in documentation after the smoke, and the merged
release tree matches the PR head. SteamVR was in standby; nobody was confirmed
wearing the Frame, and no VR input was sent.
The private smoke result had SHA-256
`5a5bc8b1da1ef405235d660a2587c84204afd0816c80f8d9dfd9577dac72d547`;
the fresh-lock cleanup audit had SHA-256
`6ab884b6adffb14c4643e30ee2e5fe247b2ebba7a3ad5424515927cd40146e79`.
The tagged x86_64 AppImage downloaded independently by asset ID had SHA-256
`7755621360a1aafb6e1bdf2b41838d172a3f6d7f5662d2b5217f6aae08486c2b`.
It launched under isolated Xvfb with a temporary profile, telemetry and update
checks disabled; its bundled server returned HTTP 200 with `FrameControl/1`
and stopped after app exit. No physical Mac or Windows launch of these exact
installers was performed.
## Publication
`scripts/publish-release.sh --dry-run v0.4.53` passed exact-main, native
runtime and tagged release jobs, all ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update-manifest checks. Its log had SHA-256
`ddad938f25d4321a8d24b29ff895a20431ec6d1c2c6faf8fd42f939100c97bff`.
The publisher repeated those gates, attached `update.json`, and published
[v0.4.53](https://github.com/saphid/frame-control/releases/tag/v0.4.53) at
**18:14:22 UTC**. Its log had SHA-256
`b54cd7f87c0ba858cc61086fc6293287bdb7555c6a49d3f1c9b8216b39d0dcf3`.
GitHub's latest-release API returned `draft=false`, `prerelease=false`, the
correct tag, ten installers, `SHA256SUMS` and `update.json`. A fresh download
from the public `releases/latest/download/update.json` route reported version
`0.4.53`, the correct release page, all ten names, sizes and hashes matching
the independent downloads, and the explicit 26.5 GB transfer limit. The
public manifest had SHA-256
`b873a3422e60e2fec2ff4a08f0a1fe610b9df18eb94a1a72e259beefb763bb38`.
The exact-version Decky catalog remains **6 works / 54 partial / 45 broken /
11 N/A**. A physical 26.5 GB transfer, wearer-visible VR, physical-controller
use and the optional x86_64 title launch are unverified. The separate hourly
worn check remains enabled.
@@ -0,0 +1,42 @@
# v0.4.54 candidate hold, 28 October 2026
The local, unpushed `release/v0.4.54` candidate is commit
`3eefedd8d4dd2ce8db7d62d0dd2d7cff1fe2089b`, based on main
`e7b037ce883e45990bf239aee29b05434a39a399`. It changes only the
version in `app/package.json` and `app/package-lock.json`. It has not been
tagged or published. The latest public release remains
[v0.4.53](https://github.com/saphid/frame-control/releases/tag/v0.4.53).
The locked real Frame smoke at 20:28 UTC read SteamVR activity 3 (standby),
proximity 2.3125 and battery 100% on Frame OS build `20261022.6153909`.
ARM64 and Windows/Proton test titles launched. The x86_64 test title did
**not** launch: Steam reported that Steam Linux Runtime 4.0 was found but
not installed. The result has **12 passed, 1 skipped, 0 failed**. Its final
cleanup step passed and its unique lock was released. The private JSON result
is `/tmp/frame-v0454-smoke-results/2026-10-28T202826Z-20261022.6153909.json`
with SHA-256 `d86edaec0b86faf130c21aa3d7113cc1ac5b2ae0869dbd15f811bf3bb5b76b88`.
A separate read-only audit acquired and released a fresh unique headset lock.
It read activity 3, proximity 2.3125 and battery 100%, and found no reserved
smoke title directories or owned test processes. It did not wake the Frame or
send input. Nobody confirmed wearing the Frame, so no wearer-visible or
physical-controller result follows.
Follow-up probes at about 22:40–22:45 UTC each acquired a new unique owner in
the shared headset lock before reading status. SteamVR stayed at activity 3
(standby), proximity was 2.3125–2.375 and battery was 100%. A bounded,
read-only `FEXBash --help` probe reported that `SteamLinuxRuntime_4` did not
exist in Steam's common directory. A separate read of `/usr/bin/FEXBash` found
that its wrapper exits when that directory is absent. The probes did not
install a runtime, launch a title, wake the headset or send input. Each owner
released its lock. The available FEX wrapper therefore did not provide an
immediate x86 Linux launch path; this does not establish that every possible
runtime source is absent.
**Release decision: hold v0.4.54.** The smoke script at the candidate commit
returned exit 0 even though it recorded a skipped launch. Under the current
release rule, a skip cannot satisfy the smoke gate. The accompanying change
makes a skipped smoke return nonzero while preserving the explicit skipped
step and cleanup record. Exact release-commit CI, release verification, tagged
installer builds, checksums and publisher dry run have not been performed for
this local candidate.
+15 -15
View File
@@ -25,13 +25,13 @@ python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives
Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.
A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.
A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. A later [native Decky 3.3.0 check](evidence/framey-free-loader-native-2026-10-28.md) verified plugin discovery, default settings, empty local deals and private setting persistence after restart in network-isolated amd64 and ARM64 containers. Live feed updates, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified.
A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck's advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.
A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.
A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64 and ARM64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, a real Frame backend, panel controls and worn VR remain unverified; this is partial compatibility.
A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.
A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 and ARM64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.
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.
@@ -56,7 +56,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 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 |
| 24: RadiYo! 1.3 | legacy / legacy | partial | Initializer succeeds; radio discovery and playback remain unverified. | static archive, local browser, fake-Frame |
| 24: RadiYo! 1.3 | legacy / legacy | partial | Initializer succeeds. The exact official legacy backend passes isolated Decky 3.3.0 discovery and loopback HTTP station fetch, custom fetch and redirect. A digest-scoped managed install restores TLS certificate and hostname checks; its fetch and redirect methods reject a loopback self-signed HTTPS endpoint. External radio services, trusted HTTPS, audio playback, real Frame backend and worn VR remain unverified. Older installed copies need an update for the TLS fix. | static archive, local browser, fake-Frame, isolated Decky 3.3.0 x86_64 backend |
| 26: HLTB for Deck 2.0.12 | 1 / 2 | partial | Settings render without the prior IndexedDB SecurityError; Steam library badges have no patch target, and live lookup is unverified. | static archive, local browser, fake-Frame |
| 27: AutoSuspend 2.2.0 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of null (reading '6'). The exact frontend also contains SteamOS suspend and shutdown paths; threshold behavior and safe Frame operation are unverified. | static archive, local browser, fake-Frame |
| 28: Emuchievements 2.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: Emuchievements/bin/hash. | static archive, local browser, fake-Frame |
@@ -72,20 +72,20 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 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 | The exact official ZIP contains a 60,776,600-byte x86_64 rclone member, above the 30 MiB member limit and unusable on ARM64. Installation remains blocked. | 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 |
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | The empty-data panel renders locally. Exact managed 1.4.0 installs repair the backend delete-then-TypeError bug and restrict deletion to shadercache/compatdata or numeric app-ID children. Published Decky 3.3.0 x86_64 RPC passes private size/list and selected/whole fixture deletion in a network-isolated container. Real Steam cache deletion, Frame ARM64 backend, frontend confirmation flow and worn VR remain unverified; older copies need Update. | static archive, local browser, fake-Frame, isolated Decky 3.3.0 x86_64 backend |
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | partial | An inert local component lets the exact frontend initialize and render its Settings view in opaque Chromium. Backend get_setting and plugin network were denied in the audit; Steam game-page theme injection has no verified target in Framey. Music search, playback, downloads, settings persistence and worn VR remain unverified. | static archive, local browser, fake-Frame |
| 53: Shotty 0.1.0 | legacy / legacy | partial | The frontend starts with the read-only Frame library snapshot. Frame Control adds a digest-scoped click.get_app_dir compatibility module to the managed install; the exact backend then imports and copies a synthetic Steam screenshot into a temporary Pictures folder in isolated Python. Actual Decky 3.3.0 ARM64 loading, live screenshots/rescue, in-VR panel and physical controller use remain unverified. | static archive, local browser, fake-Frame, real Frame SSH, isolated Python backend |
| 53: Shotty 0.1.0 | legacy / legacy | partial | The frontend starts with the read-only Frame library snapshot. The exact-digest click.get_app_dir shim lets actual Decky 3.3.0 in isolated amd64 and ARM64 containers discover Shotty and complete empty screenshot RPCs; isolated Python also copied a synthetic screenshot into a temporary Pictures folder. The managed backend on the real Frame, live screenshots/rescue, in-VR panel and physical controller use remain unverified. | static archive, local browser, fake-Frame, real Frame SSH, isolated Python backend, isolated Decky 3.3.0 amd64/ARM64 empty backend RPCs |
| 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 | 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 | partial | Managed install supplies a native ARM64 helper and removes the exact archive's unnecessary root request. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. Frontend address/picker limitations remain; use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame, real Decky 3.3.0 amd64, real Frame SSH |
| 60: DeckyFileServer 1.2.0 | 1 / 2 | partial | Managed install supplies a native ARM64 helper, removes the exact archive's unnecessary root request, and displays the backend-returned IP with HTTPS. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. The Steam folder picker remains unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Existing managed copies need update or reinstall for the address fix. | static archive, local browser, fake-Frame, real Decky 3.3.0 amd64, real Frame SSH |
| 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 | partial | The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend |
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's byte-identical duplicate ZIP members pass guarded install/remove. Actual Decky 3.3.0 in private network-isolated amd64 and ARM64 containers discovers the plugin, reads default settings and empty local deals, and retains a settings change after restart. Live deal-feed refresh, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified; no feed request was made. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend, isolated Decky 3.3.0 amd64/ARM64 backend |
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. 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 | partial | Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 amd64 recorded Frame-shaped private battery samples and returned history/power data. A locked real Frame read verified the required sysfs fields, not the managed plugin. Live ARM64 recording, long-term battery history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall. | static archive, local browser, fake-Frame, isolated Decky amd64 backend, real Frame read-only sysfs |
| 67: Battery Tracker 0.2.0 | legacy / legacy | partial | Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 in isolated amd64 and ARM64 containers recorded Frame-shaped private battery samples and returned history/per-app power. A locked real Frame read verified the required sysfs fields, not the managed plugin. Real Frame ARM64 recording, long-term history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall. | static archive, local browser, fake-Frame, isolated Decky 3.3.0 amd64/ARM64 backend, real Frame read-only sysfs |
| 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 |
| 72: ScreenshotUploader 0.0.3 | legacy / legacy | partial | Initializer succeeds; screenshot upload requires external credentials/network and was not run. | static archive, local browser, fake-Frame |
@@ -97,7 +97,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 78: Brightness Bar 1.0.4 | legacy / legacy | N/A | Not supported on Frame: Targets Steam/QAM brightness shortcuts and a global Steam overlay; neither is attached to the Framey panel. | static archive, local browser, fake-Frame |
| 79: Decky Notifications 1.0.1 | legacy / legacy | partial | Initializer succeeds; device/account setup and notification delivery unverified. | static archive, local browser, fake-Frame |
| 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 |
| 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. The published frontend attempts deal lookups with an embedded upstream API key and a Steam store CDP tab; neither was exercised in Framey. Settings writes and Steam page integration remain unverified. | 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 | 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 |
@@ -118,7 +118,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 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 | 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 |
| 104: Game Sync 1.0.4 | 1 / 2 | partial | Exact official backend discovers under Decky 3.3.0 amd64; default get_config and a private setting read/write survive a loader restart. The isolated browser baseline still stops at its denied get_config call. Cloud login, rclone download/sync (including ARM64), process signals, real Frame and VR are unverified. The published updater names an amd64 rclone download, so no ARM64 sync claim follows. | static archive, local browser, fake-Frame, native Decky amd64 configuration |
| 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 |
| 106: LetMeReShade 1.8 | 1 / 2 | partial | Initializer succeeds; Deck-model assumptions, shader downloads and game modifications unverified. | static archive, local browser, fake-Frame |
| 107: Decky Sunshine 2025.10.27-dddf365 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
@@ -128,12 +128,12 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 111: Music Control 1.1.7 | legacy / legacy | partial | Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply. | static archive, local browser, fake-Frame, isolated Python backend, isolated Decky 3.3.0 + MPRIS fixture, real Frame SSH |
| 112: Better Keyboard 0.3.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: window.SteamClient.UI.GetUIMode is not a function. | static archive, local browser, fake-Frame |
| 113: Decky LSFG-VK 0.12.2 | 1 / 2 | partial | Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 114: Network Sentinel 1.0.4 | 1 / 2 | partial | Exact managed install removes the unnecessary root flag and adds pinned plugin-local psutil wheels. Actual Decky 3.3.0 amd64 in network isolation loads the backend and records a network sample; private settings and read-only RPCs pass. External ping/DNS, real Frame ARM64, Wi-Fi scan behavior, panel controls and worn VR remain unverified. Existing copies need update or reinstall. | static archive, local browser, fake-Frame, isolated Decky amd64 backend |
| 114: Network Sentinel 1.0.4 | 1 / 2 | partial | Exact managed install removes the unnecessary root flag and adds pinned plugin-local psutil wheels. Actual Decky 3.3.0 in isolated amd64 and ARM64 containers loads the backend and records a private network sample; settings and read-only RPCs pass. External ping/DNS, real Frame ARM64 backend, Wi-Fi scan behavior, panel controls and worn VR remain unverified. Existing copies need update or reinstall. | static archive, local browser, fake-Frame, isolated Decky 3.3.0 amd64/ARM64 backend |
| 115: Decky Clash 1.3.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 116: Muon 0.2.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 117: DeckyParty File Manager 1.0.0 | legacy / legacy | partial | Initializer succeeds; privileged file operations and backend are unverified. No file changes were run. | static archive, local browser, fake-Frame |
| 118: Speed Test 1.0.2 | 1 / 2 | partial | Initializer succeeds; automatic bandwidth tests were blocked by the audit network policy. | static archive, local browser, fake-Frame |
| 119: Decky Clipboard 1.0.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-clipboard/bin/xclip. | static archive, local browser, fake-Frame |
| 119: Decky Clipboard 1.0.1 | 1 / 2 | broken | The exact archive is blocked by its x86-64 xclip binary and root request. Its published backend defaults to auto-starting an unauthenticated clipboard GET/POST API on 0.0.0.0:8765; this was inspected statically and never run on Frame. | static archive, local browser, fake-Frame |
| 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 |
@@ -148,7 +148,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 / 1 | partial | The exact chunked frontend renders settings in opaque local Chromium. The official Python backend saved a shelf and returned it after reopen with a stub Decky module and temporary settings directory. Actual Decky RPC, real Frame settings, Steam Home shelf injection and worn VR remain unverified. | static archive, local browser, fake-Frame, isolated Python backend |
| 135: Deck Shelves 2.0.0 | 1 / 1 | partial | The exact chunked frontend renders settings in opaque local Chromium. The official Python backend now passes published Decky 3.3.0 x86_64 discovery, private shelf settings save/readback and persistence after a loader restart in a network-isolated container. Steam Home shelf injection has no verified target in the isolated panel; real Frame ARM64 settings, worn VR and physical controller use remain unverified. | static archive, local browser, fake-Frame, isolated Decky 3.3.0 x86_64 backend |
| 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 |
@@ -287,7 +287,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. The three Windows DLLs belong to Decky-Framegen. Except for the exact managed DeckyFileServer and Decky Localsend adaptations below, the installer still rejects these archives. Apart from LocalSend and DeckyFileServer, ARM64 builds were not attempted: the constraints below remain unresolved.
A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. The plugin moves from broken to partial: its frontend advertises HTTP/steamdeck URLs and needs a Steam folder picker. Use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.
A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. The plugin remains partial. A [frontend URL follow-up](evidence/framey-fileserver-url-2026-10-28.md) changes only the exact managed bundle to display the backend-returned IP with HTTPS, matching the helper. Existing managed copies need update or reinstall. The Steam folder picker is still unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.
[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) covers an isolated real-Frame protocol 2.0 info response from an earlier helper build. [Exact official source matching](evidence/framey-localsend-source-match-2026-10-27.md) now identifies the official helper source and a reproducible ARM64 build from that revision. The exact official x86 backend passed one local private-namespace transfer, and the rebuilt ARM64 helper passed one locked real-Frame private marker transfer. Published Decky 3.3.0 AMD64/ARM64 runtime checks discovered the adapted plugin and read its stopped status and configuration. Only the exact managed 0.4.5 archive can replace its x86 helper on Frame. The actual managed Decky service on Frame, user-facing transfer, local discovery and worn VR remain unverified ([managed adaptation evidence](evidence/framey-localsend-managed-2026-10-27.md)).
+38 -6
View File
@@ -27,8 +27,22 @@ count. So a build reaches people only when you publish it, after testing it.
temporary profile. This CI test does not test a physical display, FUSE mount
or a headset.
2. Download the draft's installers and test them. An installed copy of the
previous version won't offer the draft, so install it directly.
2. Independently download and inspect the draft's installers on an x86_64
Linux host with `gh`, `dpkg-deb` and `xvfb-run` installed:
```sh
scripts/audit-release-assets.py v0.4.0 \
--report /tmp/frame-v0.4.0-assets.json --launch-x86-appimage
```
The command downloads each of the ten installers by asset ID, verifies its
size and SHA-256 against GitHub metadata, matches the downloaded
`SHA256SUMS` byte for byte, checks package formats and versions, and starts
the x86_64 AppImage with a temporary profile and isolated Xvfb display. It
writes the report only after all checks pass. This does not prove that the
Mac or Windows installers launch on physical machines. Test those packages
separately when the required machines are available. An installed copy of
the previous version won't offer the draft, so install it directly.
3. Write the release notes on the draft. The update banner links to them.
@@ -43,6 +57,16 @@ count. So a build reaches people only when you publish it, after testing it.
publish unless every required job succeeded; it cannot prove physical
headset or installer behavior on its own.
Supply the saved smoke result to the publisher. Both its dry run and publish
command reject a result from another or dirty commit, incomplete title or
cleanup steps, a skip or failure, or an unconfirmed headset lock release:
```sh
export FRAME_SMOKE_RESULT=/path/to/smoke-result.json
python3 scripts/verify-frame-smoke-result.py "$FRAME_SMOKE_RESULT" \
"$(git rev-parse 'v0.4.0^{commit}')"
```
```sh
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
scripts/publish-release.sh v0.4.0
@@ -50,15 +74,17 @@ count. So a build reaches people only when you publish it, after testing it.
The script checks that the tag is on GitHub, that exact-commit main CI
(including e2e), native amd64/ARM64 Decky runtime checks, and the tagged
release workflow are green with no failed,
cancelled, skipped or running jobs in any run attempt, and that all ten installers
are attached, each with the SHA-256 digest GitHub records. It also checks
release workflow are green with no failed, cancelled, skipped or running
jobs in any exact-tag push run or attempt, and that all ten installers are
attached, each with the SHA-256 digest GitHub records. It also checks
that the draft does not begin with a `WITHDRAWN` notice, so the preserved
v0.4.48 draft cannot accidentally be offered again. It checks
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
installer digests. It attaches
`update.json` (the version, the notes, the release page and each
installer's digest), then publishes the release and marks it latest. It
installer's digest), verifies the uploaded asset's SHA-256 and downloads
its bytes for an exact readback before publishing the release and marking
it latest. It
refuses an already published release before replacing its live update
manifest; use a new tested release for changes after publication. It
uses only the REST API: `gh release view` can't find a draft whose
@@ -70,6 +96,12 @@ count. So a build reaches people only when you publish it, after testing it.
seconds after starting, then every 6 hours, and anyone can use **Check for
Updates…** (the app menu on macOS, the Help menu elsewhere).
After publication, rerun `audit-release-assets.py` with a new report path.
For a published release it also downloads `update.json`, verifies its
GitHub SHA-256, and checks that the offered version, release page, notes,
installer names, sizes, and digests match that release. This checks the
updater channel's published bytes, which the draft audit cannot inspect.
To pull a bad release, mark the previous one as latest
(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
Copies that already updated stay on it. Nothing downgrades them.
+7 -3
View File
@@ -215,7 +215,10 @@ acquires the shared `/tmp/frame-test.lock` with a unique owner token. It reads
SteamVR activity, proximity and battery while locked. An awake headset, unknown
activity, battery below 20%, or a busy lock stops the run without installing
anything. It releases only its own lock after cleanup. The JSON result includes
the read-only preflight for completed smoke runs.
the read-only preflight and the source checkout's commit and clean/dirty state.
For a release, run it from a clean checkout of the exact release commit;
`scripts/verify-frame-smoke-result.py` rejects a different or dirty checkout,
missing steps, skipped or failed launches, and an unconfirmed lock release.
It checks `properties.json` and the status, then installs, launches and
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
@@ -233,8 +236,9 @@ 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. 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.
exits 0 only when every step passed, 1 if a step failed or was skipped, or 2 if
the headset isn't reachable. A skipped x86-64 launch blocks a release smoke
gate even though it remains separately labeled in the result.
Smoke process cleanup runs before and after title removal and during the
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""Independently download and inspect every installer in a GitHub release.
Run on Linux after the tagged workflow finishes and before publishing its
draft. The JSON report is written only after every check passes.
"""
import argparse
import hashlib
import json
import platform
import plistlib
import re
import runpy
import shutil
import struct
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
REPO = "saphid/frame-control"
SCRIPTS = Path(__file__).resolve().parent
CHECKSUMS = runpy.run_path(str(SCRIPTS / "release-checksums.py"))
INSTALLERS = CHECKSUMS["INSTALLERS"]
class AuditError(Exception):
"""Release asset metadata or downloaded bytes failed a check."""
def github_json(path):
result = subprocess.run(
["gh", "api", f"repos/{REPO}/{path}"],
capture_output=True, text=True, check=False, timeout=60,
)
if result.returncode:
raise AuditError(f"GitHub read failed for {path}: {result.stderr.strip()}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as error:
raise AuditError(f"invalid GitHub JSON for {path}: {error}") from error
def find_release(tag):
releases = []
for page in range(1, 21):
batch = github_json(f"releases?per_page=100&page={page}")
if not isinstance(batch, list):
raise AuditError("GitHub release list is not an array")
releases.extend(batch)
if len(batch) < 100:
break
else:
raise AuditError("release list exceeded 20 pages; cannot verify uniqueness")
try:
return CHECKSUMS["find_release"](
(json.dumps(item) for item in releases), tag
)
except ValueError as error:
raise AuditError(str(error)) from error
def validate_assets(release):
assets = release.get("assets")
expected = set(INSTALLERS) | {"SHA256SUMS"}
if release.get("draft") is False:
expected.add("update.json")
if not isinstance(assets, list) or len(assets) != len(expected):
raise AuditError(f"expected exactly {len(expected)} release assets")
by_name = {asset.get("name"): asset for asset in assets if isinstance(asset, dict)}
if set(by_name) != expected or len(by_name) != len(assets):
raise AuditError("release has missing, duplicate, or unexpected assets")
for name, asset in by_name.items():
if (not isinstance(asset.get("id"), int) or asset["id"] <= 0
or not isinstance(asset.get("size"), int) or asset["size"] <= 0
or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", asset.get("digest") or "")):
raise AuditError(f"{name} has no valid ID, size, or SHA-256 digest")
try:
expected_sums = CHECKSUMS["checksums"](release).encode("ascii")
except ValueError as error:
raise AuditError(str(error)) from error
return by_name, expected_sums
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def download_asset(asset, path):
with path.open("wb") as target:
result = subprocess.run(
["gh", "api", "-H", "Accept: application/octet-stream",
f"repos/{REPO}/releases/assets/{asset['id']}"],
stdout=target, stderr=subprocess.PIPE, check=False, timeout=1800,
)
if result.returncode:
raise AuditError(f"download failed for {asset['name']}: "
f"{result.stderr.decode(errors='replace').strip()}")
if path.stat().st_size != asset["size"]:
raise AuditError(f"{asset['name']} size differs from GitHub metadata")
digest = sha256(path)
if digest != asset["digest"][7:].lower():
raise AuditError(f"{asset['name']} SHA-256 differs from GitHub metadata")
return digest
def validate_update_manifest(path, release):
"""Check the bytes running copies read against the published release."""
try:
manifest = json.loads(path.read_text(encoding="utf-8"))
except (UnicodeError, json.JSONDecodeError) as error:
raise AuditError(f"update.json is not valid UTF-8 JSON: {error}") from error
if not isinstance(manifest, dict):
raise AuditError("update.json is not an object")
tag = release.get("tag_name")
if not isinstance(tag, str) or not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
raise AuditError("published release has an invalid tag")
if manifest.get("version") != tag[1:]:
raise AuditError("update.json version differs from the release tag")
page = f"https://github.com/{REPO}/releases/tag/{tag}"
if manifest.get("page") != page:
raise AuditError("update.json release page differs from the tag page")
if manifest.get("notes") != (release.get("body") or "")[:4000]:
raise AuditError("update.json notes differ from the release notes")
expected = {asset["name"]: {key: asset[key] for key in ("name", "size", "digest")}
for asset in release["assets"] if asset["name"] in INSTALLERS}
assets = manifest.get("assets")
if not isinstance(assets, list) or len(assets) != len(expected):
raise AuditError("update.json has an incomplete installer list")
names = [asset.get("name") for asset in assets if isinstance(asset, dict)]
if (len(names) != len(assets) or not all(isinstance(name, str) for name in names)
or len(names) != len(set(names))):
raise AuditError("update.json has invalid or duplicate installer entries")
if {asset["name"]: asset for asset in assets} != expected:
raise AuditError("update.json installers differ from the release assets")
return {"version": manifest["version"], "installers": len(assets)}
def inspect_package(path, name, version):
if name.endswith(".zip"):
with zipfile.ZipFile(path) as archive:
bad = archive.testzip()
if bad is not None:
raise AuditError(f"{name} has a bad ZIP member: {bad}")
detail = {"zipCrc": "ok", "members": len(archive.infolist())}
if "-mac-" in name:
matches = [entry for entry in archive.namelist()
if entry.endswith("Frame Control.app/Contents/Info.plist")]
if len(matches) != 1:
raise AuditError(f"{name} has {len(matches)} app Info.plist files")
found = plistlib.loads(archive.read(matches[0])).get("CFBundleShortVersionString")
if found != version:
raise AuditError(f"{name} bundle version {found!r} is not {version}")
detail["bundleVersion"] = found
return detail
if name.endswith(".deb"):
result = subprocess.run(
["dpkg-deb", "-f", str(path), "Version", "Architecture"],
capture_output=True, text=True, check=False, timeout=60,
)
fields = [line.partition(":")[2].strip() for line in result.stdout.splitlines()]
architecture = "arm64" if "arm64" in name else "amd64"
if result.returncode or fields != [version, architecture]:
raise AuditError(f"{name} DEB version/architecture mismatch: {fields}")
return {"debVersion": fields[0], "architecture": fields[1]}
if name.endswith(".AppImage"):
with path.open("rb") as source:
head = source.read(20)
machine = struct.unpack("<H", head[18:20])[0] if len(head) >= 20 else None
expected = 183 if "arm64" in name else 62
if head[:4] != b"\x7fELF" or machine != expected:
raise AuditError(f"{name} ELF machine {machine!r} is not {expected}")
return {"elfMachine": machine}
if name.endswith(".dmg"):
if path.stat().st_size < 512:
raise AuditError(f"{name} is too small for a UDIF trailer")
with path.open("rb") as source:
source.seek(-512, 2)
trailer = source.read(512)
if trailer[:4] != b"koly":
raise AuditError(f"{name} has no UDIF trailer")
return {"udifTrailer": "ok"}
if name.endswith(".exe"):
with path.open("rb") as source:
head = source.read(64)
if len(head) < 64 or head[:2] != b"MZ":
raise AuditError(f"{name} has no MZ header")
source.seek(struct.unpack("<I", head[60:64])[0])
pe = source.read(6)
machine = struct.unpack("<H", pe[4:6])[0] if len(pe) == 6 else None
if pe[:4] != b"PE\x00\x00" or machine not in (0x14C, 0x8664):
raise AuditError(f"{name} has an invalid PE header or machine")
return {"peMachine": machine}
raise AuditError(f"no package inspection for {name}")
def audit(tag, report_path, launch_x86_appimage=False):
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
raise AuditError("expected a version tag such as v0.4.54")
if report_path.exists():
raise AuditError(f"report already exists; choose a new path: {report_path}")
if not report_path.parent.is_dir():
raise AuditError(f"report directory does not exist: {report_path.parent}")
if not shutil.which("gh") or not shutil.which("dpkg-deb"):
raise AuditError("gh and dpkg-deb are required on the audit host")
if launch_x86_appimage and not shutil.which("xvfb-run"):
raise AuditError("xvfb-run is required for --launch-x86-appimage")
if launch_x86_appimage and platform.machine().lower() not in ("x86_64", "amd64"):
raise AuditError("--launch-x86-appimage requires an x86_64 audit host")
release = find_release(tag)
by_name, expected_sums = validate_assets(release)
with tempfile.TemporaryDirectory(prefix=f"frame-{tag}-assets-") as directory:
scratch = Path(directory)
sums_path = scratch / "SHA256SUMS"
sums_hash = download_asset(by_name["SHA256SUMS"], sums_path)
if sums_path.read_bytes() != expected_sums:
raise AuditError("SHA256SUMS bytes differ from the ten GitHub asset digests")
manifest_detail = None
if not release.get("draft"):
manifest_path = scratch / "update.json"
manifest_digest = download_asset(by_name["update.json"], manifest_path)
manifest_detail = validate_update_manifest(manifest_path, release)
manifest_detail["sha256"] = manifest_digest
rows = []
for name in INSTALLERS:
path = scratch / name
asset = by_name[name]
digest = download_asset(asset, path)
detail = inspect_package(path, name, tag[1:])
if launch_x86_appimage and name == "Frame-Control-linux-x86_64.AppImage":
path.chmod(0o755)
result = subprocess.run(
[sys.executable, str(SCRIPTS / "check-appimage-launch.py"), str(path)],
capture_output=True, text=True, check=False, timeout=120,
)
if result.returncode or digest not in result.stdout:
raise AuditError(f"isolated AppImage launch failed: {result.stderr.strip()}")
detail["appimageLaunch"] = "HTTP 200; FrameControl/1; server stopped"
rows.append({"name": name, "size": asset["size"], "sha256": digest, **detail})
path.unlink()
print(f"verified {name}", flush=True)
report = {"tag": tag, "releaseId": release["id"],
"tagNameAtAudit": release.get("tag_name"), "draftAtAudit": release.get("draft"),
"sha256sumsSha256": sums_hash, "updateManifest": manifest_detail,
"assets": rows}
with report_path.open("x", encoding="utf-8") as output:
json.dump(report, output, indent=2)
output.write("\n")
print(f"verified {len(rows)} installer downloads; report {report_path}")
return report
def main():
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("tag", help="release tag, for example v0.4.54")
parser.add_argument("--report", required=True, type=Path, help="new JSON report path")
parser.add_argument("--launch-x86-appimage", action="store_true",
help="also launch the tagged x86_64 AppImage in isolated Xvfb")
args = parser.parse_args()
try:
audit(args.tag, args.report, args.launch_x86_appimage)
except (AuditError, OSError, subprocess.TimeoutExpired,
zipfile.BadZipFile, plistlib.InvalidFileException) as error:
parser.exit(1, f"asset audit failed: {error}\n")
if __name__ == "__main__":
main()
+4 -4
View File
@@ -40,10 +40,10 @@ def render(data):
'python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives',
'```', '',
'Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.', '',
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium and isolated Python settings/empty-deals reads passed with a Decky stub. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. Actual Decky, feed updates and worn VR remain unverified.', '',
'A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates-2026-10-26.md) accepts only byte-identical duplicate ZIP members whose modes differ at most in execute bits. The exact archive passed private local and focused fake-Frame install/remove; its frontend rendered in opaque Chromium. The earlier full 116-plugin fake-Frame audit blocked this archive before the fix. A later [native Decky 3.3.0 check](evidence/framey-free-loader-native-2026-10-28.md) verified plugin discovery, default settings, empty local deals and private setting persistence after restart in network-isolated amd64 and ARM64 containers. Live feed updates, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified.', '',
'A [2026-10-28 static action-limit review](evidence/framey-static-action-limits-2026-10-28.md) hash-checked the exact AutoSuspend and CheatDeck archives. AutoSuspend contains Steam suspend/shutdown paths, and CheatDeck\'s advertised trainer flow needs a Steam game context menu and launch-option writes. Neither plugin was executed; both retain their broken labels.', '',
'A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, Frame ARM64, panel controls and worn VR remain unverified; this is partial compatibility.', '',
'A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.', '',
'A [Network Sentinel user-service check](evidence/framey-network-sentinel-2026-10-28.md) adapts only the exact 1.0.4 digest. In isolated actual Decky 3.3.0 amd64 and ARM64, the pinned plugin-local psutil wheel, private settings RPC and one monitoring-loop sample pass. External ping/DNS, a real Frame backend, panel controls and worn VR remain unverified; this is partial compatibility.', '',
'A [Battery Tracker user-service check](evidence/framey-battery-tracker-2026-10-28.md) adapts only the exact 0.2.0 digest. A locked read-only Frame probe confirmed a Battery-type supply with the required fields; actual Decky 3.3.0 amd64 and ARM64 recorded private Frame-shaped samples and returned history/per-app power. The managed plugin has not run on the real Frame, and panel controls and worn VR remain unverified; this is partial compatibility.', '',
'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. '
@@ -131,7 +131,7 @@ def render(data):
'A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. '
'Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. '
'Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. '
'The plugin moves from broken to partial: its frontend advertises HTTP/steamdeck URLs and needs a Steam folder picker. Use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.', '',
'The plugin remains partial. A [frontend URL follow-up](evidence/framey-fileserver-url-2026-10-28.md) changes only the exact managed bundle to display the backend-returned IP with HTTPS, matching the helper. Existing managed copies need update or reinstall. The Steam folder picker is still unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.', '',
'[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) '
'covers an isolated real-Frame protocol 2.0 info response from an earlier helper build. '
'[Exact official source matching](evidence/framey-localsend-source-match-2026-10-27.md) '
+11 -3
View File
@@ -41,7 +41,8 @@ def check_coverage():
shims.add(node.args[0].value)
cases = json.loads((ROOT/'tests/decky-runtime/cases.json').read_text())
# Frontend-only compatibility probes need no archive/backend adaptation.
if shims != {case['shim'] for case in cases if case['shim'] is not None}:
covered = {shim for case in cases for shim in ([case['shim']] if case['shim'] else []) + case.get('additionalShims', [])}
if shims != covered:
raise ValueError('Every managed shim needs an actual Decky runtime case')
return cases
@@ -69,10 +70,15 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--loader', type=Path, help='Optional cached, digest-verified native Decky 3.3.0 binary')
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--case', type=int, help='Run one official plugin case locally; CI runs all cases')
args = parser.parse_args()
if sys.platform != 'linux' or not shutil.which('docker'):
parser.error('Linux with Docker is required')
cases = check_coverage()
if args.case is not None:
cases = [case for case in cases if case['id'] == args.case]
if not cases:
parser.error('Unknown official plugin case')
data, digest = loader_bytes(args.loader)
tag = 'framey-decky-runtime:'+uuid.uuid4().hex[:12]
report = {'decky': RELEASE, 'architecture': platform.machine(), 'loaderSha256': digest, 'cases': []}
@@ -84,8 +90,10 @@ def main():
loader.write_bytes(data)
loader.chmod(0o644) # Private container user copies/chmods its own executable.
probes = [(case['id'], None) for case in cases] + [
(111, '--reproduce-bound-method-regression'), (96, '--reproduce-timer-regression'),
(60, '--reproduce-fileserver-empty-flag-regression')]
(plugin_id, negative) for plugin_id, negative in [
(111, '--reproduce-bound-method-regression'), (96, '--reproduce-timer-regression'),
(60, '--reproduce-fileserver-empty-flag-regression')]
if args.case is None or args.case == plugin_id]
for plugin_id, negative in probes:
name = 'framey-decky-'+uuid.uuid4().hex[:12]
command = ['docker', 'run', '--rm', '--name', name, '--network', 'none', '--read-only',
+20 -4
View File
@@ -3,7 +3,7 @@
# (docs/releasing.md). Checks every installer is attached with a SHA-256
# digest first, since the app's updater refuses assets without one, then
# attaches update.json, the manifest the updater reads.
# Usage: scripts/publish-release.sh [--dry-run] v0.4.0
# Usage: FRAME_SMOKE_RESULT=/path/to/result.json scripts/publish-release.sh [--dry-run] v0.4.0
#
# Everything goes through the REST API (gh api), not `gh release view/edit`:
# those look a draft up by its tag, and a draft can show tag_name
@@ -33,8 +33,8 @@ gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
# A complete asset list is insufficient: a cancelled installer build can leave
# old uploaded bytes behind. Check the exact tagged commit's main/e2e jobs and
# the latest exact-tag release attempt before any release mutation.
python3 "$(dirname "$0")/verify-release-gates.py" "$tag"
# every exact-tag push run and attempt before any release mutation.
python3 "$(dirname "$0")/verify-release-gates.py" "$tag" "${FRAME_SMOKE_RESULT:-}"
# Every release, drafts included, one JSON object per line.
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
@@ -139,7 +139,23 @@ for asset in $old; do
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
done
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
-H "Content-Type: application/json" --input "$tmp/update.json" > "$tmp/uploaded-manifest.json"
asset_id=$(python3 - "$tmp/uploaded-manifest.json" "$tmp/update.json" <<'EOF'
import hashlib, json, pathlib, sys
asset = json.load(open(sys.argv[1], encoding="utf-8"))
body = pathlib.Path(sys.argv[2]).read_bytes()
digest = "sha256:" + hashlib.sha256(body).hexdigest()
if (asset.get("name") != "update.json" or not isinstance(asset.get("id"), int)
or asset["id"] <= 0 or asset.get("size") != len(body)
or asset.get("digest") != digest):
sys.exit("not publishing: uploaded update.json metadata differs from its source bytes")
print(asset["id"])
EOF
)
gh api -H "Accept: application/octet-stream" "repos/$repo/releases/assets/$asset_id" > "$tmp/update-readback.json"
cmp -s "$tmp/update.json" "$tmp/update-readback.json" || {
echo "not publishing: downloaded update.json differs from its source bytes" >&2; exit 1;
}
echo "ok update.json"
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -F draft=false -F prerelease=false \
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Check a locked Frame smoke result against the exact release commit."""
import json
import math
import re
import sys
from pathlib import Path
REQUIRED_STEPS = {
'cleanup: leftovers from earlier runs',
'devkit service: properties.json',
'status',
'cleanup: everything this run installed',
*(f'{kind}: {action}' for kind in ('arm64', 'x86_64', 'exe')
for action in ('install', 'launch', 'remove')),
}
def verify(result, commit):
if not isinstance(result, dict):
raise ValueError('smoke result is not an object')
if not re.fullmatch(r'[0-9a-f]{40}', commit):
raise ValueError('expected a full lowercase commit SHA-1')
if result.get('source') != {'commit': commit, 'clean': True}:
raise ValueError('smoke source is not the exact clean release commit')
if result.get('lock_released') is not True:
raise ValueError('smoke lock release was not recorded')
steps = result.get('steps')
if not isinstance(steps, list) or not steps:
raise ValueError('smoke steps are missing')
names = [step.get('step') for step in steps if isinstance(step, dict)]
if (len(names) != len(steps) or any(not isinstance(name, str) for name in names) or
len(names) != len(set(names)) or not REQUIRED_STEPS.issubset(names)):
raise ValueError('smoke steps are missing or duplicated')
passed = sum(step.get('ok') is True and not step.get('skipped') for step in steps)
if (passed != len(steps) or result.get('passed') != passed or
result.get('skipped') != 0 or result.get('failed') != 0):
raise ValueError('smoke contains a failed, skipped, or inconsistent step')
state = result.get('preflight') or {}
if (not isinstance(state, dict) or state.get('activity') != 3 or
not isinstance(state.get('proximity'), list) or not state['proximity'] or
any(type(value) not in (int, float) or not math.isfinite(value) or
value < 0 or value > 10
for value in state['proximity']) or
not isinstance(state.get('battery'), list) or not state['battery'] or
any(type(value) is not int or value < 20 or value > 100
for value in state['battery'])):
raise ValueError('locked standby preflight is missing')
def main(argv):
if len(argv) != 3:
raise ValueError('usage: verify-frame-smoke-result.py RESULT.json RELEASE_COMMIT_SHA')
result = json.loads(Path(argv[1]).read_text(encoding='utf-8'))
verify(result, argv[2])
print(f'Frame smoke passed for clean release commit {argv[2]}')
if __name__ == '__main__':
try:
main(sys.argv)
except (OSError, ValueError) as error:
sys.exit(f'Frame smoke gate failed: {error}')
+37 -8
View File
@@ -1,13 +1,14 @@
#!/usr/bin/env python3
"""Refuse publication unless the exact tag commit and tagged installers passed CI.
"""Refuse publication unless CI, tagged installers, and exact Frame smoke passed.
This checks GitHub's current run and job records, not a prior PR's check marks.
The real Frame smoke and independent package checks remain release-procedure
Independent package checks and release verification remain release-procedure
gates documented in docs/releasing.md.
"""
import json
import subprocess
import sys
from pathlib import Path
from urllib.parse import quote
@@ -60,14 +61,29 @@ def tag_commit(tag):
raise GateError(f"{tag} has too many nested tag objects")
def exact_run(workflow, branch, sha, event):
def exact_runs(workflow, branch, sha, event):
query = f"actions/workflows/{workflow}/runs?branch={quote(branch, safe='')}&head_sha={sha}&per_page=100"
runs = api(query).get("workflow_runs") or []
runs = []
for page in range(1, 21):
data = api(f"{query}&page={page}")
batch = data.get("workflow_runs")
total = data.get("total_count")
if not isinstance(batch, list) or type(total) is not int or total < 0:
raise GateError(f"invalid {workflow} run list for {branch} at {sha}")
runs.extend(batch)
if len(runs) >= total or len(batch) < 100:
break
if len(runs) != total or len({run.get('id') for run in runs}) != len(runs):
raise GateError(f"incomplete or duplicate {workflow} run list for {branch} at {sha}")
matching = [run for run in runs if run.get("head_sha") == sha
and run.get("head_branch") == branch and run.get("event") == event]
if not matching:
raise GateError(f"no exact {workflow} {event} run for {branch} at {sha}")
run = max(matching, key=lambda item: item["id"])
return sorted(matching, key=lambda item: item["id"])
def exact_run(workflow, branch, sha, event):
run = exact_runs(workflow, branch, sha, event)[-1]
if run.get("status") != "completed" or run.get("conclusion") != "success":
raise GateError(f"{workflow} run {run['id']} is {run.get('status')}/{run.get('conclusion')}")
return run
@@ -98,18 +114,31 @@ def successful_jobs(run, required):
print(f"gate passed: {run.get('html_url')} ({len(latest_jobs)} jobs, {attempt} attempts)")
def main(tag):
def main(tag, smoke_result):
if not tag.startswith("v") or not tag[1:]:
raise GateError("expected a version tag beginning with v")
if not smoke_result:
raise GateError("FRAME_SMOKE_RESULT is required for the locked real Frame smoke gate")
sha = tag_commit(tag)
successful_jobs(exact_run("checks.yml", "main", sha, "push"), MAIN_JOBS)
successful_jobs(exact_run("decky-runtime.yml", "main", sha, "push"), DECKY_JOBS)
successful_jobs(exact_run("release.yml", tag, sha, "push"), RELEASE_JOBS)
# A fresh run must not hide a failed, cancelled, or skipped installer in an
# earlier separate push run for the same tag and commit.
for run in exact_runs("release.yml", tag, sha, "push"):
if run.get("status") != "completed" or run.get("conclusion") != "success":
raise GateError(f"release.yml run {run['id']} is {run.get('status')}/{run.get('conclusion')}")
successful_jobs(run, RELEASE_JOBS)
command = [sys.executable, str(Path(__file__).with_name("verify-frame-smoke-result.py")),
smoke_result, sha]
checked = subprocess.run(command, text=True, capture_output=True, check=False)
if checked.returncode:
raise GateError(checked.stderr.strip() or "Frame smoke result verification failed")
print(checked.stdout.strip())
print(f"exact release commit: {sha}")
if __name__ == "__main__":
try:
main(sys.argv[1])
main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else None)
except (IndexError, GateError) as error:
sys.exit(f"release gate failed: {error}")
+29 -1
View File
@@ -14,6 +14,31 @@
"shim": null,
"probe": "toggle-settings"
},
{
"id": 24,
"shim": "radiyo-verified-tls",
"probe": "radio-local"
},
{
"id": 135,
"shim": null,
"probe": "shelves-settings"
},
{
"id": 50,
"shim": "storage-cleaner-bounded-delete",
"probe": "storage-cleaner"
},
{
"id": 104,
"shim": null,
"probe": "game-sync-config"
},
{
"id": 63,
"shim": null,
"probe": "free-loader"
},
{
"id": 12,
"shim": "music-control-system-dbus-env",
@@ -42,7 +67,10 @@
{
"id": 60,
"shim": "deckyfileserver-user-native",
"probe": "fileserver"
"probe": "fileserver",
"additionalShims": [
"deckyfileserver-https-address"
]
},
{
"id": 125,
+173 -2
View File
@@ -2,6 +2,7 @@
import argparse
import asyncio
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
@@ -11,6 +12,7 @@ import socket
import ssl
import subprocess
import sys
import threading
import time
import urllib.request
import zipfile
@@ -90,7 +92,8 @@ def main():
plugin = base/'plugins'/record['folder']
receipt = json.loads((plugin/'.frame-control-store.json').read_text())
if not args.reproduce_timer_regression:
assert receipt.get('compatShims', []) == ([case['shim']] if case['shim'] else []), receipt
expected_shims = ([case['shim']] if case['shim'] else []) + case.get('additionalShims', [])
assert receipt.get('compatShims', []) == expected_shims, receipt
else:
assert record['id'] == 96
with zipfile.ZipFile(io.BytesIO(data)) as original:
@@ -240,6 +243,159 @@ def main():
report['result'] = ('original empty argument bug reproduced: thumbnail policy ignored'
if args.reproduce_fileserver_empty_flag_regression else
'discovery, API1 settings, native HTTPS listing/download, thumbnail policy and stop passed; uploads disabled')
elif case['probe'] == 'free-loader':
settings = current('settings_read')
assert settings['notify_on_free_games'] is True, settings
assert settings['larger_text'] is False, settings
assert current('read_deals') == {}
# This plugin commits defaults on startup and writes an empty
# deals cache on first read. Both paths are private to this
# disposable container; no feed, notification or store call.
settings_dir = base/'settings'/record['folder']
assert (settings_dir/'settings.json').is_file()
assert (settings_dir/'deal_db.json').is_file()
current('settings_setSetting', 'larger_text', True)
assert current('settings_read')['larger_text'] is True
os.killpg(backend.pid, signal.SIGTERM)
backend.wait(timeout=8)
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log,
start_new_session=True)
processes.append(backend)
until_ready(lambda: rpc('loader/get_plugins'), 'restarted Decky discovery')
assert until_ready(lambda: current('settings_read')['larger_text'],
'private Free Loader setting after restart') is True
assert current('read_deals') == {}
report['result'] = ('discovery, default settings, empty local deals, '
'private setting write/read and restart persistence passed; feed, notifications and VR unverified')
elif case['probe'] == 'game-sync-config':
# Exercise only private settings. This plugin also exposes
# rclone cloud sync and arbitrary process signals; neither
# belongs in a passive compatibility probe.
settings = current('get_config')
assert settings['sync_on_game_start'] is False, settings
assert settings['sync_on_game_stop'] is False, settings
assert settings['capture_upload'] is False, settings
assert settings['advanced_mode'] is False, settings
assert current('set_config', 'advanced_mode', True) is None
assert current('get_config')['advanced_mode'] is True
os.killpg(backend.pid, signal.SIGTERM)
backend.wait(timeout=8)
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log,
start_new_session=True)
processes.append(backend)
until_ready(lambda: rpc('loader/get_plugins'), 'restarted Decky discovery')
assert until_ready(lambda: current('get_config')['advanced_mode'],
'private Game Sync setting after restart') is True
report['result'] = ('discovery, default config and private setting restart persistence passed; '
'rclone, cloud authentication, sync, process signals, real Frame and VR unverified')
elif case['probe'] == 'radio-local':
# Exercise HTTP behavior and prove managed RadiYo rejects a
# self-signed HTTPS endpoint. No station or audio opens.
marker = 'Framey private radio station\n'
assert 'ctx.verify_mode = ssl.CERT_NONE' not in (plugin/'main.py').read_text()
class RadioHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/redirect':
self.send_response(302)
self.send_header('Location', '/station')
self.end_headers()
elif self.path == '/station':
data = marker.encode()
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
else:
self.send_error(404)
def log_message(self, *_):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), RadioHandler)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
base_url = f'http://127.0.0.1:{server.server_port}'
station = legacy('fetch', url=base_url+'/station', customStation=False)
assert station['status'] == 200 and station['body'] == marker, station
assert station['headers']['Content-Type'] == 'text/plain', station
custom = legacy('fetch', url=base_url+'/station', customStation=True)
assert custom['status'] == 200 and custom['body'] == '', custom
redirect = legacy('getRedirectUrl', url=base_url+'/redirect')
assert redirect == {'response': base_url+'/station'}, redirect
finally:
server.shutdown()
server.server_close()
worker.join(timeout=3)
secure = ThreadingHTTPServer(('127.0.0.1', 0), RadioHandler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
fixtures = ROOT/'tests/decky-runtime/fixtures'
context.load_cert_chain(fixtures/'localhost-selfsigned.crt',
fixtures/'localhost-selfsigned.key')
secure.socket = context.wrap_socket(secure.socket, server_side=True)
worker = threading.Thread(target=secure.serve_forever, daemon=True)
worker.start()
try:
base_url = f'https://localhost:{secure.server_port}'
for method, kwargs in (
('fetch', {'url': base_url+'/station', 'customStation': False}),
('getRedirectUrl', {'url': base_url+'/redirect'})):
result = legacy(method, **kwargs)
assert 'error' in result and 'CERTIFICATE_VERIFY_FAILED' in result['error'], result
finally:
secure.shutdown()
secure.server_close()
worker.join(timeout=3)
report['result'] = ('discovery, loopback HTTP fetch/redirect and self-signed HTTPS rejection passed; '
'external stations, trusted HTTPS, playback, real Frame and VR unverified')
elif case['probe'] == 'shelves-settings':
# Private configuration only. Never register a Steam Home
# patch, import a user's settings, or touch their library.
defaults = current('get_settings')
assert defaults['enabled'] is False and defaults['shelves'] == [], defaults
shelf = {'id': 'framey-private-shelf', 'title': 'Private Test',
'source': {'type': 'tab', 'tab': 'all'}, 'limit': 4}
assert current('set_settings', {'enabled': False, 'shelves': [shelf]}) is True
saved = current('get_settings')
assert len(saved['shelves']) == 1 and saved['shelves'][0]['title'] == 'Private Test', saved
state = base/'settings'/record['folder']/'settings.json'
assert json.loads(state.read_text())['state']['shelves'][0]['id'] == shelf['id']
os.killpg(backend.pid, signal.SIGTERM)
backend.wait(timeout=8)
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log,
start_new_session=True)
processes.append(backend)
until_ready(lambda: rpc('loader/get_plugins'), 'restarted Decky discovery')
restored = until_ready(lambda: current('get_settings')['shelves'],
'private shelf after restart')
assert restored[0]['id'] == shelf['id'] and restored[0]['title'] == shelf['title'], restored
report['result'] = ('published Decky discovery, private shelf settings and restart persistence passed; '
'Steam Home injection, real Frame and VR unverified')
elif case['probe'] == 'storage-cleaner':
# The only deletion is an owned marker under this disposable
# container's private Steam fixture, never a real game cache.
apps = home/'.steam/steam/steamapps'
selected = apps/'shadercache/424242'
selected.mkdir(parents=True)
marker = b'Framey private cache fixture\n'
(selected/'marker').write_bytes(marker)
assert current('get_size', 'shadercache/424242') == len(marker)
rows = json.loads(current('list_games_with_temp_data', 'shadercache'))
assert rows == [{'appid': '424242', 'size': len(marker),
'size_readable': f'{len(marker)}.0 B'}], rows
assert current('delete_cache', 'shadercache/424242') is None
assert not selected.exists()
assert apps.joinpath('shadercache').is_dir()
whole = apps/'compatdata'
whole.mkdir()
(whole/'marker').write_bytes(marker)
assert current('get_size', 'compatdata') == len(marker)
assert current('delete_cache', 'compatdata') is None
assert not whole.exists()
report['result'] = ('published Decky discovery, private size/list and selected/whole fixture deletion passed; '
'real Steam caches, Frame and VR unverified')
elif case['probe'] == 'toggle-settings':
def call(method, *values):
result = rpc('loader/call_plugin_method', record['name'], method, *values)
@@ -333,7 +489,22 @@ def main():
assert case['probe'] == 'screenshots'
assert legacy('was_rescued') is False
assert legacy('aggregate_all', allapps=[]) == 0
report['result'] = 'discovery, backend import and empty screenshot aggregation passed'
screenshots = home/'.local/share/Steam/userdata/9001/760/remote/4242/screenshots'
screenshots.mkdir(parents=True)
marker = b'Framey private screenshot fixture\n'
(screenshots/'selected.jpg').write_bytes(marker)
legacy('set_id_map_fronend', allapps=[[4242, 'Private Game']])
assert legacy('copy_screenshot', app_id=4242,
url='https://localhost/selected.jpg') is True
copied = home/'Pictures/Screenshots/Private Game/selected.jpg'
assert copied.read_bytes() == marker
assert (home/'Pictures/Screenshots/most_recent.jpg').read_bytes() == marker
(screenshots/'historical.jpg').write_bytes(b'Framey historical fixture\n')
assert legacy('aggregate_all', allapps=[[4242, 'Private Game']]) == 1
assert (copied.parent/'historical.jpg').read_bytes() == b'Framey historical fixture\n'
assert legacy('was_rescued') is False
report['result'] = ('discovery, backend import, empty aggregation, private screenshot '
'copy and historical aggregation passed')
log.flush()
log.seek(0)
assert 'Starting Decky version v3.3.0' in log.read()
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDCzCCAfOgAwIBAgIUei7D5XAp4cxefO1O9TUr1wm52PEwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAyOTAyMDUzOVoYDzIxMjYx
MDA1MDIwNTM5WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDaSOVSxyLrzuOleRA/6RJL+3zXhZPdCjQGC6O2vx7B
EU+8hmvWMpJrUBjE8DncduB9gu8j03vtxX/ZC2lx6GQOFJGPsFEbYwB5InZaCl8E
hV42aYrhukxSJYETy+XrzEa6WT3CBZG8X4vHD+31tO5vBIeUGsWp5Mv2tQY+LAIm
4vgkBfy6dvADyttM7SjN0n/hUVRH+2cO9QiA5GWxmQOinZMmk8er9LpVkjTEdKAx
KxsQP/qZLrY2LOUM0qXpRGaG/1do+wmaid8B1tKrHm5AIMaitDznVSTf/ckcYDPW
BjYVrWdqcwt4v/lrkPEJRQKDJQeLDvQrQD6mGK7PKDsHAgMBAAGjUzBRMB0GA1Ud
DgQWBBSSw7MvTccz05JmuXR7lwnWlrpPejAfBgNVHSMEGDAWgBSSw7MvTccz05Jm
uXR7lwnWlrpPejAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBt
1xwvIf3PU3GWogGHyHxBtnfT1vMSoJ9SknrMD6pjtRfErXwB/ys+G6NRq5PQVXhM
GfpIrDeYFZ6TuWSqPkmNjVc88+rffg34kEuB0h3PPgmGxkvkoar6X72cCJ5DrAC0
P+ufIHLVepoz80/RED9Tf7Xa1YAG8uoVmZXaKBneTfRvRzhtbvksDlOMAOiQfxtq
l8qVGoYctfNl6ka/5AWq7KFvcFaXSSnvsLSWrwYZ+9B21ClxdIGEhYzbKJiPSc00
PbNL5be/QaLpptM/8QSFGjgzwZH7GoZe53XuSgCygvhAgVxDsMyi5qP+6JDGiPci
d4Jd914sn4ONMRq99lZd
-----END CERTIFICATE-----
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDaSOVSxyLrzuOl
eRA/6RJL+3zXhZPdCjQGC6O2vx7BEU+8hmvWMpJrUBjE8DncduB9gu8j03vtxX/Z
C2lx6GQOFJGPsFEbYwB5InZaCl8EhV42aYrhukxSJYETy+XrzEa6WT3CBZG8X4vH
D+31tO5vBIeUGsWp5Mv2tQY+LAIm4vgkBfy6dvADyttM7SjN0n/hUVRH+2cO9QiA
5GWxmQOinZMmk8er9LpVkjTEdKAxKxsQP/qZLrY2LOUM0qXpRGaG/1do+wmaid8B
1tKrHm5AIMaitDznVSTf/ckcYDPWBjYVrWdqcwt4v/lrkPEJRQKDJQeLDvQrQD6m
GK7PKDsHAgMBAAECggEAU3PeFLUkYTHiL4+mykBRqfH2azFa8/1kIVGO4B2WZOq4
6NMdZDFGcDeZmKVdAbADKIkH41MFxMQmmjS2NgHKSuXLNruKbqV9d1JZHBht9j0E
5VmZaq4+YoOLIyXne6xH+BOJPGXDr1h6CHtLFw56v7/kE5ferRLXdbUEs31Av1t+
1Vwj5Ne7QvNTEUTyBFn7fuXmeoUThM8ZdkW5b2fzQ8UZY/UjcOzo2jdg24MYwmBz
Jc1Ysdy6Qiz5SWl3SWTkpdMO6kaHG2cWXOECHtYEXj7HAIiZQJAjsZUHR5rsGrVh
3ecZHA5PIrrUojI2uls1PtFgrAnDN7ZYVJ3Ab2KFkQKBgQD4tl5p3tW1PRAUauWl
e4Brhn3yc9BvIH5aknKX0EBxp9CK1pcRb4c5RvGqmHENG90jeX95vRfCr6okKicX
wpIVqzp9aznA7uzJfAZ2SGsD+rnNfAHCr2lxjhhVCs/WS6Ck5a6i6DolXutunhD3
uad42YGNwYOV7cnZlyauf7GW1wKBgQDgrkjUu/hcn9jCaXHGd3xyelow0S8PZEIB
ogw6BN4eecxnCBExibkpGE+OeCyf/Os2HxObHwd51UNyhQ/FEhdrv4vu+rNb3pIL
8b7wu3zopC01BX62rDMy2abKwD9RyhOJYOflYZ6gJxpWjRTOIcQNBsSmxmXslTk2
iMKskBFnUQKBgHFeJ4EYqs83K0ozQ8R/nBTnNCe89MiCxjWgxY0/xvBHrz5p/Ghd
LwVDukqpu/Zeuhk+e4XRVLBQhDyB3lntPcuUxk9+/c9c2SAqYYOvFOC2e9UXbnIA
pQ62146EJCKpTGt9qnHlawO31ZcRg+h3fsgOVQh5qfJAdp6xUFeAK0H3AoGAeXff
6hsBZ9oHTvvAxgJ9JkrdjbzZy2Fxif54y7kr+J/ykNU8rmJ91yjMggKYZZpP83+T
ku6QlR5mC3jCkNF6FgeFeJrS8tz+ajS0/OLv4bo4soyY99tbBqfC06Rh1y7/purc
FBfVsVHSnG5DjDmXC0/Yay2hlukJ+GCxlqeAsTECgYAXgHIRET8nBkDwF7mCZnm9
IjTVYF/p4OzY9kKHRPUWjdaxEzoZfKWEDohz1x+2ruilzVYEU4b3ISlYzjIqOjaM
w4Kmwd97UlZnOe+13R64951xlN+6MxVUQ0J1Aty1e5QT8FNUD4c2277xvFvDOoep
RmBmgFaX/lBlVfiMlW/Bpg==
-----END PRIVATE KEY-----
+14 -3
View File
@@ -9,7 +9,8 @@ import subprocess
import threading
import zipfile
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, ssh
from harness import FrameTestCase, ROOT, Server, api, finished, ok, require, reset, ssh
import frame_decky_store
from framey_official import LEGACY_PLUGINS, PLUGINS as PANEL_PLUGINS, archive as official_archive
OFFICIAL_PLUGINS = PANEL_PLUGINS + LEGACY_PLUGINS
@@ -99,6 +100,13 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
input=source, capture_output=True, timeout=20)
self.assertEqual(copy.returncode, 0, copy.stderr)
def test_fake_frame_reset_clears_managed_decky_state(self):
ssh('mkdir -p ~/.local/share/frame-control/decky/plugins/test-leftover ~/.config/systemd/user')
ssh('touch ~/.config/systemd/user/frame-control-decky.service')
reset()
self.assertEqual(ssh('test -e ~/.local/share/frame-control/decky && echo yes || echo no').strip(), 'no')
self.assertEqual(ssh('test -e ~/.config/systemd/user/frame-control-decky.service && echo yes || echo no').strip(), 'no')
def test_store_install_update_remove_and_guards(self):
self.backend()
status, _, _ = api('GET', '/api/decky/store', headers={'X-Frame-UI':''})
@@ -190,6 +198,9 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
installed = f"~/.local/share/frame-control/decky/plugins/{plugin['folder']}"
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
if plugin['id'] == 60:
# The pinned managed bundle rewrites the two incorrect addresses.
expected = '05819ddd0ee9c3ce7eeb184977a2f7905d7c8f5cfaec8bdff837d7935985a0e7'
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'] == 96:
@@ -205,8 +216,8 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
if plugin['id'] == 60:
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], [])
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['deckyfileserver-user-native'])
import frame_decky_store
self.assertEqual(receipt['compatShims'],
['deckyfileserver-user-native', 'deckyfileserver-https-address'])
arch = ssh('uname -m').strip()
self.assertEqual(ssh(f'sha256sum {installed}/bin/backend').split()[0],
frame_decky_store.FILESERVER_BINARIES[arch])
@@ -304,6 +304,8 @@ def clean_home():
pass
for pattern in (fs.DEVKIT_GAMES + '/*', fs.DEVKIT_GAMES + '/.[!.]*', HOME + '/devkit-utils',
HOME + '/.devkit-utils.frame-control', HOME + '/Applications', HOME + '/Downloads/*',
HOME + '/.local/share/frame-control',
HOME + '/.config/systemd/user/frame-control-decky.service',
fs.STEAM_ROOT + '/steamapps/compatdata', fs.STEAM_ROOT + '/steamapps/shadercache',
fs.STEAM_ROOT + '/logs', '/var/log/fakeframe/devkit-*', '/var/log/fakeframe/shortcut-*'):
for p in glob.glob(pattern):
+25 -6
View File
@@ -9,6 +9,7 @@ $FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's li
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
(raw strings, compact JSON)."""
import json
import hashlib
import os
import shutil
import subprocess
@@ -95,15 +96,26 @@ elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"):
"status": gate.get("decky_status" if run_id == 503 else ("main_status" if is_main else "release_status"), "completed"),
"conclusion": gate.get("decky_conclusion" if run_id == 503 else ("main_conclusion" if is_main else "release_conclusion"), "success"),
"html_url": "https://github.com/saphid/frame-control/actions/runs/%s" % run_id}
print(json.dumps({"workflow_runs": [run]}))
runs = [run]
if run_id == 502 and gate.get("prior_release_run_bad_job"):
runs.append(dict(run, id=500, run_attempt=1, status="completed",
conclusion=gate.get("prior_release_run_conclusion", "failure"),
html_url="https://github.com/saphid/frame-control/actions/runs/500"))
if run_id == 502 and gate.get("release_run_list_incomplete"):
print(json.dumps({"total_count": len(runs) + 1, "workflow_runs": runs}))
else:
print(json.dumps({"total_count": len(runs), "workflow_runs": runs}))
elif method == "GET" and endpoint.startswith(repo + "actions/runs/"):
parts = endpoint[len(repo + "actions/runs/"):].split("/")
run_id = parts[0]
attempt = int(parts[2])
names = {"501": main_jobs, "502": release_jobs, "503": decky_jobs}[run_id]
names = {"500": release_jobs, "501": main_jobs, "502": release_jobs,
"503": decky_jobs}[run_id]
names = [name for name in names if name != gate.get("omit_job")]
jobs = [{"name": name, "status": "completed",
"conclusion": (gate.get("prior_bad_job_conclusion", "failure")
"conclusion": (gate.get("prior_release_run_bad_conclusion", "cancelled")
if run_id == "500" and name == gate.get("prior_release_run_bad_job")
else gate.get("prior_bad_job_conclusion", "failure")
if run_id == "502" and attempt == 1 and name == gate.get("prior_bad_job")
else gate.get("bad_job_conclusion", "success") if name == gate.get("bad_job")
else "success")}
@@ -120,10 +132,17 @@ elif method == "POST" and endpoint == repo + "releases":
answer(new, json.dumps(new))
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
pass
elif method == "GET" and endpoint == repo + "releases/assets/901":
data = open(os.environ["FAKEGH_UPLOAD"], "rb").read()
if os.environ.get("FAKEGH_CORRUPT_MANIFEST_READBACK") == "1":
data += b" "
sys.stdout.buffer.write(data)
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
dst.write(src.read())
print("{}")
data = open(inp, "rb").read()
with open(os.environ["FAKEGH_UPLOAD"], "wb") as dst:
dst.write(data)
print(json.dumps({"id": 901, "name": "update.json", "size": len(data),
"digest": "sha256:" + hashlib.sha256(data).hexdigest()}))
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
rid = endpoint[len(repo + "releases/"):]
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
Binary file not shown.
Binary file not shown.
+17 -1
View File
@@ -3,7 +3,7 @@
These are unmodified ZIPs downloaded from the official Decky store CDN on
2026-10-25, except `96.zip` and `111.zip` downloaded on 2026-10-26 and `60.zip` on
2026-10-27; `13.zip` and `125.zip` were added from the digest-verified audit cache on 2026-10-27,
and `67.zip` and `114.zip` on 2026-10-28.
and `67.zip` and `114.zip` on 2026-10-28. `24.zip` and `104.zip` were added on 2026-10-29.
Each ZIP includes its upstream `LICENSE`. They are test data, not plugins shipped or installed by
Frame Control. The fake Frame tests serve them
from a local fixture so CI does not depend on the store being online.
@@ -11,6 +11,8 @@ from a local fixture so CI does not depend on the store being online.
| File | Official plugin | Version | Store SHA-256 |
| --- | --- | --- | --- |
| `13.zip` | Pause Games | 1.0.3 | `4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62` |
| `24.zip` | RadiYo! | 1.3 | `647111578aa7a5ecd16af8d7fe6c068a6d7ea8737c6dce5ac2d52e84a549ec43` |
| `104.zip` | Game Sync | 1.0.4 | `65e021ebb37d2ebfbfbe3dbc8af95aef9556cf484730362c6b26c4ecc3e62ccd` |
| `60.zip` | DeckyFileServer | 1.2.0 | `7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0` |
| `125.zip` | Decky Localsend | 0.4.5 | `e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7` |
| `114.zip` | Network Sentinel | 1.0.4 | `d3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5` |
@@ -60,6 +62,20 @@ Pause Games runs through actual published Decky 3.3.0 discovery and instance RPC
for private settings and a synthetic exclusion list. No process-control method
is called. The official frontend's standalone Toggle component separately
checks saved true/false state and matching settings callbacks; effects are inert.
RadiYo! runs through actual published Decky 3.3.0 legacy RPC in a network-isolated
container. The managed copy passes loopback station fetch, custom-station fetch
and redirect, and rejects a self-signed loopback HTTPS endpoint from both
methods. The certificate and private key under `tests/decky-runtime/fixtures`
are public test fixtures; they grant no trusted access. External radio, trusted
HTTPS and audio playback remain unverified.
Storage Cleaner runs through the published Decky loader with only disposable
cache markers: size/list, selected-app delete and whole-directory delete pass.
The managed copy rejects traversal and symlink delete paths. No real game or
Steam cache is deleted.
Game Sync runs through actual published Decky 3.3.0 instance RPC in the same
isolation. Only default configuration and a private setting surviving restart
are checked. No rclone, cloud sync, process signal, Frame backend or VR action
is tested.
Network Sentinel runs through the same isolated native Decky gate. Its exact
archive gets plugin-local psutil and a user-service manifest; the probe checks
discovery, private settings and one monitoring-loop sample without external
+4
View File
@@ -10,6 +10,10 @@ PLUGINS = (
'folder': 'network-sentinel', 'sha256': 'd3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a0260520e735b971a5'},
{'id': 13, 'name': 'Pause Games', 'version': '1.0.3', 'api_version': 1,
'folder': 'SDH-PauseGames', 'sha256': '4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62'},
{'id': 24, 'name': 'RadiYo!', 'version': '1.3', 'api_version': 'legacy',
'folder': 'radiyo-steam-deck', 'sha256': '647111578aa7a5ecd16af8d7fe6c068a6d7ea8737c6dce5ac2d52e84a549ec43'},
{'id': 104, 'name': 'Game Sync', 'version': '1.0.4', 'api_version': 1,
'folder': 'SDH-GameSync', 'sha256': '65e021ebb37d2ebfbfbe3dbc8af95aef9556cf484730362c6b26c4ecc3e62ccd'},
{'id': 60, 'name': 'DeckyFileServer', 'version': '1.2.0', 'api_version': 1,
'folder': 'DeckyFileServer', 'sha256': '7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0'},
{'id': 125, 'name': 'Decky Localsend', 'version': '0.4.5', 'api_version': 1,
+42
View File
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(process.argv[2], 'utf8');
const testSource = source.replace(/export \{ index as default \};/, 'globalThis.__Content = Content;');
assert.notEqual(testSource, source, 'official module export must be present');
const DFL = new Proxy({
definePlugin: factory => factory(),
staticClasses: {Title: 'title'},
}, {get(target, name) { return target[name] ?? name; }});
const createElement = (type, props, ...children) => ({type, props: props ?? {}, children});
for (const allow_uploads of [false, true]) {
const SP_REACT = {
createElement,
createContext: value => ({value}),
useState: initial => [{...initial, server_running: true,
ip_address: '192.0.2.5', port: 8421, allow_uploads}, () => {}],
useEffect() {},
};
const window = {SP_REACT,
__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit: {
connect: () => ({_version: 2, callable: () => async () => ({}), openFilePicker: async () => ({})}),
},
};
const context = vm.createContext({window, SP_REACT, DFL, setInterval() {}, clearInterval() {}});
vm.runInContext(testSource, context);
const tree = context.__Content();
const fields = [];
function visit(value) {
if (!value || typeof value !== 'object') return;
if (value.type === DFL.Field) fields.push(value);
for (const child of value.children ?? []) visit(child);
}
visit(tree);
const addresses = fields.filter(field => field.props.label === 'Server Address');
assert.equal(addresses.length, 1);
assert.equal(addresses[0].children.join(''), 'https://192.0.2.5:8421');
assert.equal(fields.filter(field => field.children.join('').includes('steamdeck')).length, 0);
}
+33
View File
@@ -0,0 +1,33 @@
// Exercise the real page functions for desktop direct copy and browser size guard.
import { readFileSync } from "fs";
import assert from "node:assert/strict";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const lines = src.split("\n");
function grab(name) {
const start = lines.findIndex(line => line.startsWith(`function ${name}(`));
assert.notEqual(start, -1, `${name} exists`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
if (depth === 0) return lines.slice(start, i + 1).join("\n");
}
throw new Error(`${name} is unbalanced`);
}
const calls = [];
const copy = new Function("api", "upload", grab("copyToDownloads") + "\nreturn copyToDownloads;")(
async (...args) => { calls.push(["api", ...args]); return { message: "sent" }; },
async (...args) => { calls.push(["upload", ...args]); return { message: "uploaded" }; });
const large = { name: "usb-file.bin", size: 27 * 1024 ** 3 };
assert.deepEqual(await copy(large, "/media/usb/usb-file.bin", "frame-a"), { message: "sent" });
assert.deepEqual(calls.pop(), ["api", "/api/transfer", { action: "copy-local", path: "/media/usb/usb-file.bin" }, "frame-a"]);
assert.deepEqual(await copy({ name: "small.bin", size: 100 }, "", "frame-a"), { message: "uploaded" });
assert.deepEqual(calls.pop(), ["upload", { name: "small.bin", size: 100 }, "push", "frame-a"]);
let opened = false;
const browserUpload = new Function("XMLHttpRequest", grab("upload") + "\nreturn upload;")(
class { constructor() { opened = true; } });
await assert.rejects(browserUpload(large, "push", "frame-a"), /limited to 8 GiB/);
assert.equal(opened, false, "oversized browser file is refused before creating a request");
console.log("ok");
+37 -8
View File
@@ -28,13 +28,15 @@ 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 no step failed (optional runtime may skip a launch), 1 a step
failed, 2 the headset isn't reachable.
Exit status: 0 every step passed, 1 a step failed or was skipped, 2 the
headset isn't reachable. A skipped launch is not a release smoke pass.
"""
import argparse
import json
import math
import os
import platform
import re
import secrets
import shlex
import subprocess
@@ -61,6 +63,20 @@ PREFIX = 'fc_smoke_'
LOCK = '/tmp/frame-test.lock'
def source_revision():
"""Record which checkout produced the smoke result for release review."""
try:
head = subprocess.run(['git', '-C', str(ROOT), 'rev-parse', 'HEAD'],
capture_output=True, text=True, timeout=10, check=True).stdout.strip()
status = subprocess.run(['git', '-C', str(ROOT), 'status', '--porcelain'],
capture_output=True, text=True, timeout=10, check=True).stdout
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
return {'commit': None, 'clean': False}
if not isinstance(head, str) or not re.fullmatch(r'[0-9a-f]{40}', head):
return {'commit': None, 'clean': False}
return {'commit': head, 'clean': not bool(status.strip())}
def remote(command, *, input=None):
"""Use a bounded SSH call for shared-lock operations."""
return subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', ALIAS, command],
@@ -111,10 +127,13 @@ def preflight():
proximity = state.get('proximity')
if (not isinstance(proximity, list) or not proximity or
any(not isinstance(value, (int, float)) or isinstance(value, bool) or
value < 0 or value > 10 for value in proximity)):
not math.isfinite(value) or value < 0 or value > 10
for value in proximity)):
raise RuntimeError('Frame proximity is active or unknown; active session untouched')
battery = state.get('battery')
if not battery or min(battery) < 20:
if (not isinstance(battery, list) or not battery or
any(type(value) is not int or value < 20 or value > 100
for value in battery)):
raise RuntimeError('Frame battery is unavailable or below the smoke threshold')
return state
@@ -420,12 +439,14 @@ def run_smoke(args, before):
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,
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'source': source_revision(),
'preflight': before,
'os_release': release, 'paired': args.pair,
'passed': passed, 'skipped': skipped, 'failed': failed,
'lock_released': False,
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
print(f'==> {passed} passed, {skipped} skipped, {failed} failed on build {build}; results in {out}')
return 0 if failed == 0 else 1
return (0 if failed == 0 and skipped == 0 else 1), out
def main():
@@ -440,12 +461,12 @@ def main():
if not locked:
print('Shared Frame lock busy or unreachable; no headset status read or smoke run.', file=sys.stderr)
return 2
result = 1
result, output = 1, None
try:
try:
before = preflight()
print('Locked read-only preflight: ' + json.dumps(before), flush=True)
result = run_smoke(args, before)
result, output = run_smoke(args, before)
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as failure:
print(str(failure), file=sys.stderr)
result = 2
@@ -457,6 +478,14 @@ def main():
if not released:
print('ERROR: could not release owned Frame lock.', file=sys.stderr)
result = 1
elif output is not None:
try:
saved = json.loads(output.read_text())
saved['lock_released'] = True
output.write_text(json.dumps(saved, indent=1))
except (OSError, ValueError) as failure:
print(f'ERROR: could not record Frame lock release: {failure}', file=sys.stderr)
result = 1
return result
+204
View File
@@ -0,0 +1,204 @@
"""Independent release audit rejects incomplete metadata and wrong package versions."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import hashlib
import importlib.util
import json
import plistlib
import struct
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "audit-release-assets.py"
spec = importlib.util.spec_from_file_location("audit_release_assets", SCRIPT)
audit = importlib.util.module_from_spec(spec)
spec.loader.exec_module(audit)
def release_assets():
assets = []
for number, name in enumerate(audit.INSTALLERS, 1):
assets.append({
"id": number, "name": name, "size": number,
"digest": "sha256:" + hashlib.sha256(str(number).encode()).hexdigest(),
})
sums = audit.CHECKSUMS["checksums"]({"assets": assets}).encode("ascii")
assets.append({
"id": 99, "name": "SHA256SUMS", "size": len(sums),
"digest": "sha256:" + hashlib.sha256(sums).hexdigest(),
})
return assets, sums
class AssetAudit(unittest.TestCase):
def test_release_lookup_rejects_matching_draft_on_later_page(self):
first = {"id": 42, "tag_name": "untagged-first", "draft": True,
"name": "Frame Control 9.8.7"}
second = {"id": 43, "tag_name": "untagged-second", "draft": True,
"name": "Frame Control 9.8.7"}
first_page = [first] + [
{"id": number, "tag_name": f"v0.0.{number}", "draft": False}
for number in range(1, 100)
]
with mock.patch.object(audit, "github_json", side_effect=[first_page, [second]]) as github:
with self.assertRaisesRegex(audit.AuditError, "expected one release"):
audit.find_release("v9.8.7")
self.assertEqual(github.call_count, 2)
def test_release_lookup_finds_match_after_full_first_page(self):
first_page = [
{"id": number, "tag_name": f"v0.0.{number}", "draft": False}
for number in range(1, 101)
]
matching = {"id": 42, "tag_name": "v9.8.7", "draft": True}
with mock.patch.object(audit, "github_json", side_effect=[first_page, [matching]]) as github:
self.assertEqual(audit.find_release("v9.8.7"), matching)
self.assertEqual(github.call_count, 2)
def test_release_lookup_refuses_unfinished_pagination(self):
page = [{"id": number, "tag_name": "v9.8.7" if number == 1 else f"v0.0.{number}"}
for number in range(1, 101)]
with mock.patch.object(audit, "github_json", return_value=page) as github:
with self.assertRaisesRegex(audit.AuditError, "cannot verify uniqueness"):
audit.find_release("v9.8.7")
self.assertEqual(github.call_count, 20)
def test_published_manifest_matches_release_bytes(self):
assets, _ = release_assets()
release = {"tag_name": "v9.8.7", "body": "Release notes", "assets": assets}
manifest = {
"version": "9.8.7",
"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7",
"notes": "Release notes",
"assets": [{key: asset[key] for key in ("name", "size", "digest")}
for asset in assets if asset["name"] in audit.INSTALLERS],
}
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "update.json"
path.write_text(json.dumps(manifest), encoding="utf-8")
self.assertEqual(audit.validate_update_manifest(path, release),
{"version": "9.8.7", "installers": 10})
for change in (
{"version": "9.8.6"},
{"page": "https://github.com/elsewhere/releases/tag/v9.8.7"},
{"notes": "stale notes"},
{"assets": manifest["assets"][:-1]},
{"assets": [dict(manifest["assets"][0], digest="sha256:" + "0" * 64),
*manifest["assets"][1:]]},
):
with self.subTest(change=change):
path.write_text(json.dumps({**manifest, **change}), encoding="utf-8")
with self.assertRaises(audit.AuditError):
audit.validate_update_manifest(path, release)
def test_complete_metadata_uses_release_checksum_order(self):
assets, sums = release_assets()
by_name, expected = audit.validate_assets({"assets": assets})
self.assertEqual(len(by_name), 11)
self.assertEqual(expected, sums)
def test_published_release_requires_update_manifest(self):
assets, sums = release_assets()
manifest = {"id": 100, "name": "update.json", "size": 20,
"digest": "sha256:" + "a" * 64}
with self.assertRaises(audit.AuditError):
audit.validate_assets({"draft": False, "assets": assets})
by_name, expected = audit.validate_assets({"draft": False, "assets": [*assets, manifest]})
self.assertEqual(len(by_name), 12)
self.assertEqual(expected, sums)
def test_missing_duplicate_and_unhashed_assets_fail(self):
assets, _ = release_assets()
variants = (
assets[:-1],
[*assets, assets[0]],
[dict(assets[0], digest="sha256:bad"), *assets[1:]],
[dict(assets[0], size=0), *assets[1:]],
)
for changed in variants:
with self.subTest(changed=changed[0]), self.assertRaises(audit.AuditError):
audit.validate_assets({"assets": changed})
def test_mac_zip_bundle_version_must_match_tag(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "mac.zip"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(
"Frame Control.app/Contents/Info.plist",
plistlib.dumps({"CFBundleShortVersionString": "9.8.7"}),
)
self.assertEqual(
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.7")["bundleVersion"],
"9.8.7",
)
with self.assertRaises(audit.AuditError):
audit.inspect_package(path, "Frame-Control-mac-arm64.zip", "9.8.8")
def test_appimage_machine_must_match_name(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "image"
path.write_bytes(b"\x7fELF" + bytes(14) + struct.pack("<H", 183))
self.assertEqual(
audit.inspect_package(path, "Frame-Control-linux-arm64.AppImage", "9.8.7"),
{"elfMachine": 183},
)
with self.assertRaises(audit.AuditError):
audit.inspect_package(path, "Frame-Control-linux-x86_64.AppImage", "9.8.7")
def test_existing_report_is_never_overwritten(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "report.json"
path.write_text("keep")
with self.assertRaisesRegex(audit.AuditError, "already exists"):
audit.audit("v9.8.7", path)
self.assertEqual(path.read_text(), "keep")
def test_missing_report_directory_fails_before_download(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "missing" / "report.json"
with self.assertRaisesRegex(audit.AuditError, "directory does not exist"):
audit.audit("v9.8.7", path)
def test_bad_downloaded_checksum_file_creates_no_report(self):
assets, _ = release_assets()
release = {"id": 42, "tag_name": "v9.8.7", "draft": True, "assets": assets}
def bad_download(asset, path):
path.write_bytes(b"wrong checksum file\n")
return asset["digest"][7:]
with tempfile.TemporaryDirectory() as directory:
report = Path(directory) / "report.json"
with mock.patch.object(audit, "find_release", return_value=release), \
mock.patch.object(audit, "download_asset", side_effect=bad_download), \
mock.patch.object(audit.shutil, "which", return_value="/fake/tool"):
with self.assertRaisesRegex(audit.AuditError, "SHA256SUMS bytes differ"):
audit.audit("v9.8.7", report)
self.assertFalse(report.exists())
def test_bad_published_manifest_creates_no_report(self):
assets, sums = release_assets()
assets.append({"id": 100, "name": "update.json", "size": 2,
"digest": "sha256:" + hashlib.sha256(b"{}").hexdigest()})
release = {"id": 42, "tag_name": "v9.8.7", "draft": False,
"body": "Release notes", "assets": assets}
def download(asset, path):
path.write_bytes(sums if asset["name"] == "SHA256SUMS" else b"{}")
return asset["digest"][7:]
with tempfile.TemporaryDirectory() as directory:
report = Path(directory) / "report.json"
with mock.patch.object(audit, "find_release", return_value=release), \
mock.patch.object(audit, "download_asset", side_effect=download), \
mock.patch.object(audit.shutil, "which", return_value="/fake/tool"):
with self.assertRaisesRegex(audit.AuditError, "version differs"):
audit.audit("v9.8.7", report)
self.assertFalse(report.exists())
if __name__ == "__main__":
unittest.main()
+65 -1
View File
@@ -192,6 +192,66 @@ class StoreTransaction(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertTrue(external.exists())
def test_exact_radiyo_managed_copy_restores_tls_verification(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 24)
data = official_archive(plugin)
self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']), plugin['folder'])
result = self.apply('install', data, plugin_id=24,
folder=plugin['folder'], version=plugin['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent/plugin['folder']
source = (folder/'main.py').read_text(encoding='utf-8')
receipt = json.loads((folder/'.frame-control-store.json').read_text())
self.assertEqual(receipt['compatShims'], ['radiyo-verified-tls'])
self.assertEqual(source.count('ctx = ssl.create_default_context()'), 2)
self.assertNotIn('ssl.CERT_NONE', source)
self.assertNotIn('ctx.check_hostname = False', source)
self.assertEqual(source.count("'URL Error: ' + str(error.reason)"), 2)
result = self.apply('remove', plugin_id=24, folder=plugin['folder'])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(folder.exists())
def test_exact_storage_cleaner_deletes_only_private_cache_fixture(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 50)
data = official_archive(plugin)
self.assertEqual(frame_decky_store.archive_info(data, plugin['sha256'], plugin['name']), plugin['folder'])
result = self.apply('install', data, plugin_id=50,
folder=plugin['folder'], version=plugin['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent/plugin['folder']
receipt = json.loads((folder/'.frame-control-store.json').read_text())
self.assertEqual(receipt['compatShims'], ['storage-cleaner-bounded-delete'])
source = (folder/'main.py').read_text(encoding='utf-8')
self.assertNotIn('await shutil.rmtree', source)
steamapps = Path(self.tmp.name)/'.steam/steam/steamapps'
selected = steamapps/'shadercache/424242'
selected.mkdir(parents=True)
(selected/'marker').write_text('private fixture')
outside = Path(self.tmp.name)/'outside'
outside.mkdir()
(outside/'marker').write_text('must survive')
namespace = {'__name__': 'storage_cleaner_fixture'}
fake_decky = types.SimpleNamespace(DECKY_USER_HOME=self.tmp.name,
DECKY_PLUGIN_DIR=str(folder))
with patch.dict(sys.modules, {'decky': fake_decky}):
exec(compile(source, str(folder/'main.py'), 'exec'), namespace)
plugin_class = namespace['Plugin']
for name in ('../outside', 'shadercache/../outside', 'compatdata/../../outside',
'shadercache/not-an-app', '/tmp/outside'):
with self.subTest(name=name), self.assertRaisesRegex(ValueError, 'Invalid cache path'):
asyncio.run(plugin_class.delete_cache(name))
(steamapps/'compatdata').symlink_to(outside, target_is_directory=True)
with self.assertRaisesRegex(ValueError, 'symbolic link'):
asyncio.run(plugin_class.delete_cache('compatdata'))
self.assertTrue((outside/'marker').is_file())
self.assertIsNone(asyncio.run(plugin_class.delete_cache('shadercache/424242')))
self.assertFalse(selected.exists())
self.assertTrue((outside/'marker').is_file())
result = self.apply('remove', plugin_id=50, folder=plugin['folder'])
self.assertEqual(result.returncode, 0, result.stderr)
def test_modified_fileserver_archive_does_not_inherit_exception(self):
from framey_official import PLUGINS, archive as official_archive
record = next(p for p in PLUGINS if p['id'] == 60)
@@ -228,12 +288,16 @@ class StoreTransaction(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
plugin = self.folder.parent/record['folder']
self.assertEqual(json.loads((plugin/'plugin.json').read_text())['flags'], [])
self.assertEqual(json.loads((plugin/'.frame-control-store.json').read_text())['compatShims'], ['deckyfileserver-user-native'])
self.assertEqual(json.loads((plugin/'.frame-control-store.json').read_text())['compatShims'],
['deckyfileserver-user-native', 'deckyfileserver-https-address'])
helper = plugin/'bin/backend'
self.assertEqual(hashlib.sha256(helper.read_bytes()).hexdigest(), frame_decky_store.FILESERVER_BINARIES[platform.machine()])
self.assertEqual(helper.stat().st_mode & 0o777, 0o700)
compile((plugin/'main.py').read_text(), str(plugin/'main.py'), 'exec')
self.assertIn('if arg]', (plugin/'main.py').read_text())
rendered = subprocess.run(['node', str(Path(__file__).parent/'page/framey_fileserver_url.mjs'),
str(plugin/'dist/index.js')], capture_output=True, text=True, timeout=20)
self.assertEqual(rendered.returncode, 0, rendered.stderr)
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux helper architecture')
def test_exact_localsend_gets_verified_native_helper_and_preserves_source(self):
+10
View File
@@ -80,6 +80,16 @@ class Compatibility(unittest.TestCase):
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
self.assertEqual(result[1]['compatibility']['status'], 'works')
def test_fileserver_old_copy_requires_https_address_update(self):
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 60)
old = dict(storeId=60, version=plugin['version'], sha256=plugin['sha256'],
compatShims=['deckyfileserver-user-native'])
updated = dict(old, compatShims=['deckyfileserver-user-native', 'deckyfileserver-https-address'])
result = frame_decky.status(lambda *a, **k: json.dumps({'plugins': [old, updated]}))['plugins']
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
self.assertNotIn('Update or reinstall', result[1]['compatibility']['reason'])
self.assertEqual([p['compatibility']['status'] for p in result], ['partial', 'partial'])
def test_missing_audit_data_is_unverified(self):
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
self.assertEqual(compatibility.lookup(12, 'a'*64, '1.0')['status'], 'unverified')
+2 -1
View File
@@ -21,7 +21,8 @@ class RuntimeCoverage(unittest.TestCase):
self.assertIn(case['id'], official)
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
'toggle-settings', 'fileserver', 'localsend', 'network-sentinel',
'battery-tracker'))
'battery-tracker', 'free-loader', 'radio-local', 'game-sync-config',
'shelves-settings', 'storage-cleaner'))
def test_new_shim_without_runtime_case_fails_coverage(self):
import frame_decky_store
+86
View File
@@ -0,0 +1,86 @@
"""Large desktop file copies use the selected local path, not an 8 GiB HTTP upload."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
import os
import stat
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import server
class LargeTransfer(unittest.TestCase):
def test_desktop_http_copy_stays_on_selected_headset(self):
with tempfile.TemporaryDirectory() as tmp, server.LoopbackServer(("127.0.0.1", 0), server.Handler) as httpd:
source = Path(tmp) / "usb-file.bin"
source.write_bytes(b"a small stand-in for a large file")
worker = threading.Thread(target=httpd.serve_forever, daemon=True)
worker.start()
link = mock.Mock()
link.active_device.return_value = {"id": "frame-a"}
body = json.dumps({"action": "copy-local", "path": str(source)})
def copy(device):
conn = http.client.HTTPConnection("127.0.0.1", httpd.server_port, timeout=5)
try:
conn.request("POST", "/api/transfer", body, {"X-Frame-UI": server.UI_KEY,
"X-Frame-Device": device, "Content-Type": "application/json"})
response = conn.getresponse()
return response.status, json.loads(response.read())
finally:
conn.close()
try:
with mock.patch.dict(os.environ, {"FRAME_CONTROL_APP": "1"}), \
mock.patch.object(server, "LINK", link), \
mock.patch.object(server, "push_file", return_value="Sent usb-file.bin to ~/Downloads/") as push:
self.assertEqual(copy("frame-b")[0], 409)
push.assert_not_called()
self.assertEqual(copy("frame-a"), (200, {"message": "Sent usb-file.bin to ~/Downloads/"}))
push.assert_called_once_with(source)
finally:
httpd.shutdown()
worker.join(timeout=5)
def test_27_gib_local_file_reaches_direct_copy(self):
source = Path(os.path.abspath("large-file.bin"))
size = 27 * 1024 ** 3
info = SimpleNamespace(st_mode=stat.S_IFREG | 0o644, st_size=size)
with mock.patch.dict(os.environ, {"FRAME_CONTROL_APP": "1"}), \
mock.patch.object(Path, "stat", return_value=info), \
mock.patch.object(server, "push_file", return_value="Sent large-file.bin to ~/Downloads/") as push:
self.assertEqual(server.transfer_local({"action": "copy-local", "path": str(source)}),
{"message": "Sent large-file.bin to ~/Downloads/"})
push.assert_called_once_with(source)
def test_direct_copy_validates_desktop_file_before_ssh(self):
with tempfile.TemporaryDirectory() as tmp, \
mock.patch.dict(os.environ, {"FRAME_CONTROL_APP": "1"}), \
mock.patch.object(server, "push_file") as push:
empty = Path(tmp) / "empty.bin"
empty.touch()
hidden = Path(tmp) / ".hidden"
hidden.write_bytes(b"x")
for path in ("relative.bin", str(Path(tmp) / "missing.bin"), tmp,
str(empty), str(hidden)):
with self.subTest(path=path), self.assertRaises(server.Failure):
server.transfer_local({"action": "copy-local", "path": path})
push.assert_not_called()
def test_large_copy_gets_more_than_one_hour_without_loading_file(self):
info = SimpleNamespace(st_size=27 * 1024 ** 3)
result = subprocess.CompletedProcess([], 0, "", "")
with mock.patch.object(Path, "stat", return_value=info), \
mock.patch.object(server.shutil, "which", return_value=None), \
mock.patch.object(server.frame_host, "run_ssh", return_value=result) as run:
self.assertEqual(server.push_file("/tmp/large-file.bin"),
"Sent large-file.bin to ~/Downloads/")
self.assertGreater(run.call_args.kwargs["timeout"], 3600)
+70 -2
View File
@@ -37,14 +37,36 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
def run_script(self, releases, *args, tags="v9.8.7", gates=None,
corrupt_manifest_readback=False, smoke=None):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
(d / "gates.json").write_text(json.dumps(gates or {}))
steps = ["cleanup: leftovers from earlier runs", "devkit service: properties.json",
"status", "cleanup: everything this run installed"] + [
f"{kind}: {action}" for kind in ("arm64", "x86_64", "exe")
for action in ("install", "launch", "remove")]
clean_smoke = {"source": {"commit": "a" * 40, "clean": True},
"lock_released": True,
"steps": [{"step": name, "ok": True} for name in steps],
"passed": len(steps), "skipped": 0, "failed": 0,
"preflight": {"activity": 3, "proximity": [2.3125], "battery": [100]}}
if smoke == "skipped":
clean_smoke["steps"][0].update(ok=False, skipped=True)
clean_smoke.update(passed=len(steps) - 1, skipped=1)
elif smoke == "unreleased":
clean_smoke["lock_released"] = False
if smoke is not False:
(d / "smoke.json").write_text(json.dumps(
clean_smoke if smoke is None or isinstance(smoke, str) else smoke))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
FAKEGH_GATES=str(d / "gates.json"))
FAKEGH_GATES=str(d / "gates.json"),
FAKEGH_CORRUPT_MANIFEST_READBACK="1" if corrupt_manifest_readback else "0")
env.pop("FRAME_SMOKE_RESULT", None)
if smoke is not False:
env["FRAME_SMOKE_RESULT"] = str(d / "smoke.json")
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
@@ -60,11 +82,21 @@ class PublishRelease(unittest.TestCase):
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
patch = next(c for c in log if "PATCH" in c)
readback = next(c for c in log if any("releases/assets/901" in arg for arg in c))
self.assertLess(log.index(readback), log.index(patch))
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
self.assertIn(f, patch)
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
def test_refuses_mismatched_manifest_readback_before_publication(self):
p, log, upload = self.run_script([draft()], "v9.8.7",
corrupt_manifest_readback=True)
self.assertNotEqual(p.returncode, 0)
self.assertIn("downloaded update.json differs", p.stderr)
self.assertIsNotNone(upload)
self.assertFalse(any("PATCH" in call for call in log))
def test_an_untagged_draft_may_carry_a_subtitle(self):
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
@@ -144,6 +176,18 @@ class PublishRelease(unittest.TestCase):
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_missing_or_skipped_frame_smoke_before_mutation(self):
for smoke in (False,
{"source": {"commit": "b" * 40, "clean": True}},
{"source": {"commit": "a" * 40, "clean": False}},
"skipped", "unreleased"):
with self.subTest(smoke=smoke):
p, log, upload = self.run_script([draft()], "v9.8.7", smoke=smoke)
self.assertNotEqual(p.returncode, 0)
self.assertIn("release gate failed", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_a_prior_cancelled_installer_after_successful_rerun(self):
gates = {"release_attempts": 2,
"prior_bad_job": "build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
@@ -155,6 +199,30 @@ class PublishRelease(unittest.TestCase):
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_bad_installer_in_separate_prior_tag_run(self):
for run_conclusion, job_conclusion, expected in (
("failure", "cancelled", "failure"),
("success", "skipped", "skipped")):
with self.subTest(run_conclusion=run_conclusion, job_conclusion=job_conclusion):
gates = {"prior_release_run_bad_job":
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
"prior_release_run_conclusion": run_conclusion,
"prior_release_run_bad_conclusion": job_conclusion}
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
self.assertNotEqual(p.returncode, 0)
self.assertIn("run 500", p.stderr)
self.assertIn(expected, p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_incomplete_tag_run_list(self):
p, log, upload = self.run_script([draft()], "v9.8.7",
gates={"release_run_list_incomplete": True})
self.assertNotEqual(p.returncode, 0)
self.assertIn("incomplete", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_missing_or_nonexact_gate_results(self):
for gates in ({"omit_job": "e2e"}, {"main_sha": "c" * 40},
{"decky_conclusion": "failure"}, {"release_conclusion": "failure"},
+19 -1
View File
@@ -267,6 +267,17 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.request("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404)
self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403)
def test_direct_local_transfer_is_desktop_only(self):
status, payload = self.post("/api/transfer", {"action": "copy-local", "path": "/tmp/a.bin"})
self.assertEqual(status, 403, payload)
def test_oversized_browser_upload_rejected_without_reading_body(self):
status, _, payload = self.request("POST", "/api/upload", b"",
{"X-Frame-UI": "1", "X-Filename": "large.bin",
"Content-Length": str(27 * 1024 ** 3)})
self.assertEqual(status, 413, payload)
self.assertIn(b"limited to 8 GiB", payload)
def test_web_install_needs_the_app_page(self):
# A website can only open frame-control:// links; it can't call these itself.
link = {"url": "https://cdn.example.com/game.apk"}
@@ -497,7 +508,10 @@ class ArtworkSettings(unittest.TestCase):
time.sleep(0.05)
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403)
# The missing-key guard runs before reading a body. Leaving an
# unread body on Windows can reset this test connection before
# http.client receives the 403 response.
self.assertEqual(request("POST", "/api/settings/artwork")[0], 403)
status, payload = request("GET", "/api/settings/artwork", headers=key)
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
@@ -575,6 +589,10 @@ class PageScripts(unittest.TestCase):
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "meta_rows.mjs")], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
def test_large_local_file_uses_direct_copy(self):
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "large_transfer.mjs")], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class LocalMode(unittest.TestCase):
+43 -7
View File
@@ -95,8 +95,21 @@ class RealProcesses(unittest.TestCase):
class Safety(unittest.TestCase):
def test_source_revision_records_clean_checkout_and_rejects_invalid_head(self):
head = 'a' * 40
with mock.patch.object(frame_smoke.subprocess, 'run', side_effect=[
mock.Mock(stdout=head + '\n'), mock.Mock(stdout='')]):
self.assertEqual(frame_smoke.source_revision(), {'commit': head, 'clean': True})
with mock.patch.object(frame_smoke.subprocess, 'run', side_effect=[
mock.Mock(stdout=head + '\n'), mock.Mock(stdout=' M tests/smoke/frame_smoke.py\n')]):
self.assertEqual(frame_smoke.source_revision(), {'commit': head, 'clean': False})
with mock.patch.object(frame_smoke.subprocess, 'run', side_effect=[
mock.Mock(stdout='not-a-commit\n'), mock.Mock(stdout='')]):
self.assertEqual(frame_smoke.source_revision(), {'commit': None, 'clean': False})
def test_locked_preflight_rejects_active_or_unknown_proximity_before_titles(self):
for proximity in ([], [88.0625], [4.6875, 88.0625], [None]):
for proximity in ([], [88.0625], [4.6875, 88.0625], [None],
[float('nan')], [float('inf')], [float('-inf')]):
with self.subTest(proximity=proximity), mock.patch.object(
frame_smoke, 'remote', return_value=mock.Mock(
returncode=0, stdout=json.dumps({
@@ -134,7 +147,7 @@ class Safety(unittest.TestCase):
launch.assert_called_once_with(gid)
remove.assert_called_once_with(gid)
def test_optional_runtime_skip_is_counted_separately_in_saved_result(self):
def test_optional_runtime_skip_fails_gate_and_is_counted_in_saved_result(self):
with tempfile.TemporaryDirectory() as root:
output = Path(root) / 'results'
folder = Path(root) / 'titles'
@@ -147,6 +160,8 @@ class Safety(unittest.TestCase):
else:
smoke.step(f'{kind}: launch', lambda: {'outcome': 'running'})
with mock.patch.object(frame_smoke, 'RESULTS', output), \
mock.patch.object(frame_smoke, 'source_revision', return_value={
'commit': 'a' * 40, 'clean': True}), \
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'), \
@@ -155,10 +170,14 @@ class Safety(unittest.TestCase):
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)
code, saved_path = frame_smoke.run_smoke(mock.Mock(pair=False),
{'activity': 3, 'proximity': [], 'battery': [100]})
self.assertEqual(code, 1)
result = json.loads(next(output.glob('*-fake.json')).read_text())
self.assertEqual(saved_path.parent, output)
self.assertEqual((result['passed'], result['skipped'], result['failed']), (6, 1, 0))
self.assertEqual(result['source'], {'commit': 'a' * 40, 'clean': True})
self.assertFalse(result['lock_released'])
self.assertEqual(next(s for s in result['steps'] if s['step'] == 'x86_64: launch')
['detail']['outcome'], 'runtime missing')
@@ -207,13 +226,27 @@ class Safety(unittest.TestCase):
mock.patch.object(frame_smoke, 'cleanup', side_effect=RuntimeError('cannot stop')), \
mock.patch.object(frame_smoke, 'title_cycle') as cycle, \
mock.patch('builtins.print'):
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
{'activity':3,'proximity':[],'battery':[100]}), 1)
code, _ = frame_smoke.run_smoke(mock.Mock(pair=False),
{'activity':3,'proximity':[],'battery':[100]})
self.assertEqual(code, 1)
cycle.assert_not_called()
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
class SharedSmokeLock(unittest.TestCase):
def test_result_marks_release_only_after_owned_lock_is_removed(self):
with tempfile.TemporaryDirectory() as folder:
result_path = Path(folder) / 'smoke.json'
for released in (False, True):
result_path.write_text('{"lock_released": false}')
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
mock.patch.object(frame_smoke, 'acquire_lock', return_value=True), \
mock.patch.object(frame_smoke, 'preflight', return_value={}), \
mock.patch.object(frame_smoke, 'run_smoke', return_value=(0, result_path)), \
mock.patch.object(frame_smoke, 'release_lock', return_value=released):
self.assertEqual(frame_smoke.main(), 0 if released else 1)
self.assertIs(json.loads(result_path.read_text())['lock_released'], released)
def test_busy_lock_prevents_any_status_read_or_title_change(self):
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
mock.patch.object(frame_smoke, 'acquire_lock', return_value=False), \
@@ -229,7 +262,10 @@ class SharedSmokeLock(unittest.TestCase):
for state in ({'activity':1,'proximity':[1],'battery':[100]},
{'activity':None,'proximity':[],'battery':[100]},
{'activity':3,'proximity':[],'battery':[19]},
{'activity':3,'proximity':[],'battery':[]}):
{'activity':3,'proximity':[],'battery':[]},
{'activity':3,'proximity':[0],'battery':[True]},
{'activity':3,'proximity':[0],'battery':[float('nan')]},
{'activity':3,'proximity':[0],'battery':[101]}):
with self.subTest(state=state), mock.patch.object(frame_smoke, 'remote',
return_value=mock.Mock(returncode=0, stdout=json.dumps(state))):
with self.assertRaises(RuntimeError):
+77
View File
@@ -0,0 +1,77 @@
"""The release smoke gate requires a complete result from its exact checkout."""
import importlib.util
import math
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SPEC = importlib.util.spec_from_file_location(
'verify_frame_smoke_result', ROOT / 'scripts' / 'verify-frame-smoke-result.py')
gate = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(gate)
SHA = 'a' * 40
def result():
steps = [{'step': name, 'ok': True} for name in sorted(gate.REQUIRED_STEPS)]
return {'source': {'commit': SHA, 'clean': True}, 'lock_released': True, 'steps': steps,
'passed': len(steps), 'skipped': 0, 'failed': 0,
'preflight': {'activity': 3, 'proximity': [2.3125], 'battery': [100]}}
class VerifyFrameSmokeResult(unittest.TestCase):
def test_accepts_complete_clean_result_from_exact_commit(self):
gate.verify(result(), SHA)
def test_rejects_wrong_commit_dirty_checkout_or_missing_step(self):
for changed in ('commit', 'clean', 'step'):
with self.subTest(changed=changed):
data = result()
if changed == 'commit':
data['source']['commit'] = 'b' * 40
elif changed == 'clean':
data['source']['clean'] = False
else:
data['steps'].pop()
data['passed'] -= 1
with self.assertRaises(ValueError):
gate.verify(data, SHA)
def test_rejects_skipped_launch_even_when_counts_claim_success(self):
data = result()
launch = next(step for step in data['steps'] if step['step'] == 'x86_64: launch')
launch.update(ok=False, skipped=True)
data.update(passed=len(data['steps']) - 1, skipped=1)
with self.assertRaisesRegex(ValueError, 'failed, skipped'):
gate.verify(data, SHA)
def test_rejects_missing_locked_preflight(self):
data = result()
data['preflight']['activity'] = 1
with self.assertRaisesRegex(ValueError, 'preflight'):
gate.verify(data, SHA)
def test_rejects_nonfinite_proximity_and_invalid_battery(self):
for proximity in (math.nan, math.inf, -math.inf):
with self.subTest(proximity=proximity):
data = result()
data['preflight']['proximity'] = [proximity]
with self.assertRaisesRegex(ValueError, 'preflight'):
gate.verify(data, SHA)
for battery in ([True], [math.nan], [101], [19], ['99']):
with self.subTest(battery=battery):
data = result()
data['preflight']['battery'] = battery
with self.assertRaisesRegex(ValueError, 'preflight'):
gate.verify(data, SHA)
def test_rejects_missing_lock_release_record(self):
data = result()
data['lock_released'] = False
with self.assertRaisesRegex(ValueError, 'lock release'):
gate.verify(data, SHA)
if __name__ == '__main__':
unittest.main()
+3
View File
@@ -7,6 +7,7 @@ import base64
import copy
import http.client
import json
import os
import shutil
import subprocess
import sys
@@ -189,6 +190,8 @@ class Probe(unittest.TestCase):
finally:
wireless._lock.release()
@unittest.skipIf(os.environ.get('FRAME_SKIP_WINDOWS_WIRELESS_LIVE') == '1',
'dedicated wireless-windows CI job owns the live inventory')
@unittest.skipUnless(wireless.frame_host.WINDOWS, 'Live read-only Windows inventory runs on Windows CI')
def test_live_windows_inventory(self):
r = wireless.diagnose()
+87
View File
@@ -41,6 +41,10 @@ NETWORK_SENTINEL_DIGEST = 'd3e07f40a0c800e72d15594e03c8c61e9bf3092524a578a026052
NETWORK_SENTINEL_MAIN = '2baa390cb66dc267e8f04e61ba1edc43af75115ad5e453d308f6bf26ed70b6b9'
BATTERY_TRACKER_DIGEST = '91c388d3c1b52079314045b79671ad55c767510658b6193f6936ed6cad383b94'
BATTERY_TRACKER_MAIN = '2303ec70c262f1cbfe1c8c138770c6e7c3496827e748e679c94ca43ff9f28725'
RADIYO_DIGEST = '647111578aa7a5ecd16af8d7fe6c068a6d7ea8737c6dce5ac2d52e84a549ec43'
RADIYO_MAIN = '868332dbe3feb1e7a81e35a0c01fdc0a6b68421ec1f84e238564bfad4946e87e'
STORAGE_CLEANER_DIGEST = '1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591'
STORAGE_CLEANER_MAIN = '6fd4d012568edae22ba421495455b3c02899155e4c7484f5aa5be6ce9b14dd5f'
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
@@ -171,6 +175,13 @@ def archive_info(data, expected_hash, expected_name):
if battery_tracker and (flags != ['_root'] or 'api_version' in manifest
or hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != BATTERY_TRACKER_MAIN):
raise ValueError('Battery Tracker manifest or source differs from the pinned archive')
if (expected_hash == RADIYO_DIGEST and expected_name == 'RadiYo!' and folder == 'radiyo-steam-deck'
and hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != RADIYO_MAIN):
raise ValueError('RadiYo source differs from the pinned archive')
if (expected_hash == STORAGE_CLEANER_DIGEST and expected_name == 'Storage Cleaner'
and folder == 'decky-storage-cleaner'
and hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != STORAGE_CLEANER_MAIN):
raise ValueError('Storage Cleaner source differs from the pinned archive')
return folder
@@ -385,7 +396,38 @@ try:
new=' if "disable_thumbnails" in status:\n await Plugin.set_disable_thumbnails(self, status["disable_thumbnails"])\n if "server_running" in status:\n await Plugin.set_server_running(self, status["server_running"])'
if old not in text: raise RuntimeError('DeckyFileServer start configuration changed')
source.write_text(text.replace(old,new))
# The helper always calls ListenAndServeTLS. The published panel
# prints HTTP when uploads are disabled and hard-codes a Deck
# hostname that the Frame does not provide. Keep only the
# actual address returned by this backend.
frontend=unpacked/'dist/index.js'
original=frontend.read_bytes()
if hashlib.sha256(original).hexdigest()!='791015d8b3f234fad89ce422b09fb32bbad05e7c867d501706cfbcf9f7c6187f':
raise RuntimeError('DeckyFileServer frontend source changed')
bundle=original.decode('utf-8')
old_url=""" window.SP_REACT.createElement(DFL.Field, { inlineWrap: "shift-children-below", label: "Server Address", bottomSeparator: "none" },
"http",
state.allow_uploads ? "s" : "",
"://steamdeck:",
state.port),
window.SP_REACT.createElement(DFL.Field, { inlineWrap: "shift-children-below" },
"http",
state.allow_uploads ? "s" : "",
"://",
state.ip_address,
":",
state.port),
"""
new_url=""" window.SP_REACT.createElement(DFL.Field, { inlineWrap: "shift-children-below", label: "Server Address", bottomSeparator: "none" },
"https://",
state.ip_address,
":",
state.port),
"""
if bundle.count(old_url)!=1: raise RuntimeError('DeckyFileServer address UI changed')
frontend.write_text(bundle.replace(old_url,new_url),encoding='utf-8')
compat_shims.append('deckyfileserver-user-native')
compat_shims.append('deckyfileserver-https-address')
# Simple Timer 1.0.0 has annotation-only task state, orphaned
# replacement tasks and canceled deadlines that resume on restart.
# Preserve its API1 instance convention and upstream license.
@@ -679,6 +721,51 @@ def split_by_app(data):
source=source.replace(old,old+' env["LD_LIBRARY_PATH"] = env.get("LD_LIBRARY_PATH_ORIG", "")\n')
main.write_text(source,encoding='utf-8')
compat_shims.append('music-control-system-dbus-env')
# RadiYo's exact 1.3 backend turns off certificate and hostname
# checks for both outbound requests. Restore Python's default TLS
# policy only for the pinned official source in this managed copy.
if int(plugin_id)==24 and digest=='647111578aa7a5ecd16af8d7fe6c068a6d7ea8737c6dce5ac2d52e84a549ec43' and folder=='radiyo-steam-deck':
main=unpacked/'main.py'
data=main.read_bytes()
if hashlib.sha256(data).hexdigest()!='868332dbe3feb1e7a81e35a0c01fdc0a6b68421ec1f84e238564bfad4946e87e':
raise RuntimeError('RadiYo source differs from pinned archive')
unsafe=b' ctx.check_hostname = False\n ctx.verify_mode = ssl.CERT_NONE\n'
broken=b"'URL Error: ' + error.reason"
if data.count(unsafe)!=2 or data.count(broken)!=2:
raise RuntimeError('RadiYo TLS source changed')
data=data.replace(unsafe,b'').replace(broken,b"'URL Error: ' + str(error.reason)")
main.write_bytes(data)
compat_shims.append('radiyo-verified-tls')
# The exact published backend awaits synchronous shutil.rmtree:
# it deletes the cache then reports TypeError. Restrict deletion
# to the two directories selected by its UI and their numeric app IDs.
if int(plugin_id)==50 and digest=='1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591' and folder=='decky-storage-cleaner':
main=unpacked/'main.py'
data=main.read_bytes()
if hashlib.sha256(data).hexdigest()!='6fd4d012568edae22ba421495455b3c02899155e4c7484f5aa5be6ce9b14dd5f':
raise RuntimeError('Storage Cleaner source differs from pinned archive')
old=b" @classmethod\n async def delete_cache(cls, dirName):\n await shutil.rmtree(homeDir + '/.steam/steam/steamapps/' + dirName)"
new=b""" @classmethod
async def delete_cache(cls, dirName):
if not isinstance(dirName, str):
raise ValueError('Invalid cache path')
parts = dirName.split('/')
if (len(parts) not in (1, 2) or parts[0] not in ('shadercache', 'compatdata')
or (len(parts) == 2 and (not parts[1].isascii() or not parts[1].isdecimal()))):
raise ValueError('Invalid cache path')
root = os.path.realpath(os.path.join(homeDir, '.steam', 'steam', 'steamapps'))
target = os.path.join(root, *parts)
cursor = root
for part in parts:
cursor = os.path.join(cursor, part)
if os.path.islink(cursor):
raise ValueError('Cache path is a symbolic link')
if os.path.commonpath((root, os.path.realpath(target))) != root:
raise ValueError('Cache path escapes Steam apps')
shutil.rmtree(target)"""
if data.count(old)!=1: raise RuntimeError('Storage Cleaner delete source changed')
main.write_bytes(data.replace(old,new))
compat_shims.append('storage-cleaner-bounded-delete')
# Recheck under the transaction lock. Another store operation may
# have installed a colliding name while this archive downloaded.
proposed_name=json.loads((unpacked/'plugin.json').read_text()).get('name')
+30 -23
View File
@@ -1479,12 +1479,13 @@
"https://github.com/isiah-lloyd/radiyo-steam-deck"
],
"status": "partial",
"reason": "Initializer succeeds; radio discovery and playback remain unverified.",
"reason": "Initializer succeeds. The exact official legacy backend passes isolated Decky 3.3.0 discovery and loopback HTTP station fetch, custom fetch and redirect. A digest-scoped managed install restores TLS certificate and hostname checks; its fetch and redirect methods reject a loopback self-signed HTTPS endpoint. External radio services, trusted HTTPS, audio playback, real Frame backend and worn VR remain unverified. Older installed copies need an update for the TLS fix.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"isolated Decky 3.3.0 x86_64 backend"
],
"browser": {
"id": 24,
@@ -3713,12 +3714,13 @@
"https://github.com/mcarlucci/decky-storage-cleaner.git"
],
"status": "partial",
"reason": "Empty-data panel rendered; cleanup was deliberately not run because it can remove local data.",
"reason": "The empty-data panel renders locally. Exact managed 1.4.0 installs repair the backend delete-then-TypeError bug and restrict deletion to shadercache/compatdata or numeric app-ID children. Published Decky 3.3.0 x86_64 RPC passes private size/list and selected/whole fixture deletion in a network-isolated container. Real Steam cache deletion, Frame ARM64 backend, frontend confirmation flow and worn VR remain unverified; older copies need Update.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"isolated Decky 3.3.0 x86_64 backend"
],
"browser": {
"id": 50,
@@ -3903,14 +3905,15 @@
"https://github.com/safijari/Shotty"
],
"status": "partial",
"reason": "The frontend starts with the read-only Frame library snapshot. Frame Control adds a digest-scoped click.get_app_dir compatibility module to the managed install; the exact backend then imports and copies a synthetic Steam screenshot into a temporary Pictures folder in isolated Python. Actual Decky 3.3.0 ARM64 loading, live screenshots/rescue, in-VR panel and physical controller use remain unverified.",
"reason": "The frontend starts with the read-only Frame library snapshot. The exact-digest click.get_app_dir shim lets actual Decky 3.3.0 in isolated amd64 and ARM64 containers discover Shotty and complete empty screenshot RPCs; isolated Python also copied a synthetic screenshot into a temporary Pictures folder. The managed backend on the real Frame, live screenshots/rescue, in-VR panel and physical controller use remain unverified.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame",
"real Frame SSH",
"isolated Python backend"
"isolated Python backend",
"isolated Decky 3.3.0 amd64/ARM64 empty backend RPCs"
],
"browser": {
"id": 53,
@@ -3943,11 +3946,12 @@
"frontendReady": true
},
"backendFollowUp": {
"evidence": "docs/evidence/framey-shotty-python-2026-10-26.md",
"mode": "exact archive managed transaction and isolated Python import/copy with temporary HOME",
"evidence": "docs/evidence/framey-shotty-native-2026-10-28.md",
"mode": "exact archive managed transaction, isolated Python copy, and actual Decky 3.3.0 amd64/ARM64 discovery with empty backend RPCs",
"compatShim": "shotty-click-app-dir",
"syntheticScreenshotCopied": true,
"actualDeckyArm64": false
"actualDeckyArm64": true,
"actualFrameBackend": false
}
},
{
@@ -4349,7 +4353,7 @@
"https://github.com/grimkor/DeckyFileServer"
],
"status": "partial",
"reason": "Managed install supplies a native ARM64 helper and removes the exact archive's unnecessary root request. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. Frontend address/picker limitations remain; use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Shared Toggle value rendering is repaired; other listed blockers remain.",
"reason": "Managed install supplies a native ARM64 helper, removes the exact archive's unnecessary root request, and displays the backend-returned IP with HTTPS. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. The Steam folder picker remains unavailable; use a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Existing managed copies need update or reinstall for the address fix.",
"blockInstall": false,
"verification": [
"static archive",
@@ -4385,7 +4389,8 @@
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"requiredShims": [
"deckyfileserver-user-native"
"deckyfileserver-user-native",
"deckyfileserver-https-address"
],
"managedNative": {
"shim": "deckyfileserver-user-native",
@@ -4703,14 +4708,15 @@
"https://github.com/jwhitlow45/free-loader.git"
],
"status": "partial",
"reason": "The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified.",
"reason": "The exact archive's byte-identical duplicate ZIP members pass guarded install/remove. Actual Decky 3.3.0 in private network-isolated amd64 and ARM64 containers discovers the plugin, reads default settings and empty local deals, and retains a settings change after restart. Live deal-feed refresh, notifications, Steam store integration, real Frame ARM64 service and worn VR remain unverified; no feed request was made.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame pinned install/remove",
"local exact-archive transaction",
"isolated Python backend"
"isolated Python backend",
"isolated Decky 3.3.0 amd64/ARM64 backend"
],
"browser": {
"id": 63,
@@ -5177,7 +5183,7 @@
"https://github.com/safijari/steam-deck-battery-tracker.git"
],
"status": "partial",
"reason": "Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 amd64 recorded Frame-shaped private battery samples and returned history/power data. A locked real Frame read verified the required sysfs fields, not the managed plugin. Live ARM64 recording, long-term battery history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall.",
"reason": "Exact managed install removes the unnecessary root flag, selects a readable Battery-type power supply, and repairs empty history and per-app sample grouping. Actual Decky 3.3.0 in isolated amd64 and ARM64 containers recorded Frame-shaped private battery samples and returned history/per-app power. A locked real Frame read verified the required sysfs fields, not the managed plugin. Real Frame ARM64 recording, long-term history, foreground-app labeling, panel controls and worn VR remain unverified; existing copies need update or reinstall.",
"blockInstall": false,
"requiredShims": [
"battery-tracker-user-supply"
@@ -5186,7 +5192,7 @@
"static archive",
"local browser",
"fake-Frame",
"isolated Decky amd64 backend",
"isolated Decky 3.3.0 amd64/ARM64 backend",
"real Frame read-only sysfs"
],
"browser": {
@@ -6318,7 +6324,7 @@
"https://github.com/JtdeGraaf/IsThereAnyDeal-DeckyPlugin.git"
],
"status": "partial",
"reason": "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.",
"reason": "Country settings render and select the real Frame Steam country through the read-only bridge. The published frontend attempts deal lookups with an embedded upstream API key and a Steam store CDP tab; neither was exercised in Framey. Settings writes and Steam page integration remain unverified.",
"blockInstall": false,
"verification": [
"static archive",
@@ -8875,12 +8881,13 @@
"https://github.com/GedasFX/decky-cloud-save"
],
"status": "partial",
"reason": "Backend get_config is intentionally rejected by the audit before initialization completes; actual backend compatibility is unknown.",
"reason": "Exact official backend discovers under Decky 3.3.0 amd64; default get_config and a private setting read/write survive a loader restart. The isolated browser baseline still stops at its denied get_config call. Cloud login, rclone download/sync (including ARM64), process signals, real Frame and VR are unverified. The published updater names an amd64 rclone download, so no ARM64 sync claim follows.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"native Decky amd64 configuration"
],
"browser": {
"id": 104,
@@ -9974,7 +9981,7 @@
"https://github.com/SteamDeckHomebrew/decky-loader"
],
"status": "partial",
"reason": "Exact managed install removes the unnecessary root flag and adds pinned plugin-local psutil wheels. Actual Decky 3.3.0 amd64 in network isolation loads the backend and records a network sample; private settings and read-only RPCs pass. External ping/DNS, real Frame ARM64, Wi-Fi scan behavior, panel controls and worn VR remain unverified. Existing copies need update or reinstall.",
"reason": "Exact managed install removes the unnecessary root flag and adds pinned plugin-local psutil wheels. Actual Decky 3.3.0 in isolated amd64 and ARM64 containers loads the backend and records a private network sample; settings and read-only RPCs pass. External ping/DNS, real Frame ARM64 backend, Wi-Fi scan behavior, panel controls and worn VR remain unverified. Existing copies need update or reinstall.",
"blockInstall": false,
"requiredShims": [
"network-sentinel-user-psutil"
@@ -9983,7 +9990,7 @@
"static archive",
"local browser",
"fake-Frame",
"isolated Decky amd64 backend"
"isolated Decky 3.3.0 amd64/ARM64 backend"
],
"browser": {
"id": 114,
@@ -10542,7 +10549,7 @@
"https://github.com/astrand/xclip"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-clipboard/bin/xclip.",
"reason": "The exact archive is blocked by its x86-64 xclip binary and root request. Its published backend defaults to auto-starting an unauthenticated clipboard GET/POST API on 0.0.0.0:8765; this was inspected statically and never run on Frame.",
"blockInstall": true,
"verification": [
"static archive",
@@ -12190,13 +12197,13 @@
"https://github.com/sponsors/santojon"
],
"status": "partial",
"reason": "The exact chunked frontend renders settings in opaque local Chromium. The official Python backend saved a shelf and returned it after reopen with a stub Decky module and temporary settings directory. Actual Decky RPC, real Frame settings, Steam Home shelf injection and worn VR remain unverified.",
"reason": "The exact chunked frontend renders settings in opaque local Chromium. The official Python backend now passes published Decky 3.3.0 x86_64 discovery, private shelf settings save/readback and persistence after a loader restart in a network-isolated container. Steam Home shelf injection has no verified target in the isolated panel; real Frame ARM64 settings, worn VR and physical controller use remain unverified.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame",
"isolated Python backend"
"isolated Decky 3.3.0 x86_64 backend"
],
"browser": {
"id": 135,
+8 -3
View File
@@ -1686,6 +1686,7 @@
<option value="vr">VR app</option>
</select>
<div class="progress" id="prog"><i></i></div>
<div class="hint">The desktop app copies files from this computer directly, including files larger than 8 GiB. Browser uploads are limited to 8 GiB.</div>
<div class="hint">Clipboard text, both ways, is in <a href="#pad">Type on the headset</a>.</div>
</section>
@@ -3843,6 +3844,7 @@ window.addEventListener("drop", e => {
function upload(file, mode, dev = window.connDevice, backupToken = null, progress = () => {}, signal = null) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new Error("Import cancelled"));
if (file.size > 8 * 1024 ** 3) return reject(new Error("Browser uploads are limited to 8 GiB; use the desktop app to copy larger files"));
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
const cleanup = () => signal?.removeEventListener("abort", abort);
@@ -3875,6 +3877,9 @@ function upload(file, mode, dev = window.connDevice, backupToken = null, progres
xhr.send(file);
});
}
function copyToDownloads(file, path, dev) {
return path ? api("/api/transfer", { action: "copy-local", path }, dev) : upload(file, "push", dev);
}
async function refreshMedia(selectId) {
const data = await api("/api/media", { action: "list" });
const selected = selectId || $("mediaFile").value;
@@ -4001,7 +4006,7 @@ async function sendFilesOne(files, dirs, dev) {
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
if (apk) await importAndroidFiles([f], dev);
else await act(`Copy ${f.name} to ~/Downloads`, () => upload(f, "push", dev));
else await act(`Copy ${f.name} to ~/Downloads`, () => copyToDownloads(f, path, dev));
}
$("fileInput").value = "";
refresh();
@@ -4137,7 +4142,7 @@ async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
catch (e) {
log(`${file.name}: ${e.message}`, "e");
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
await act(`Copy ${file.name} to ~/Downloads`, () => copyToDownloads(file, path, dev));
return;
}
// Its own fresh list, so the dialog can say whether this replaces an installed title.
@@ -4146,7 +4151,7 @@ async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
const choice = await confirmTitle(r.plan, canPush, installed);
if (choice === "push") {
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
await act(`Copy ${file.name} to ~/Downloads`, () => copyToDownloads(file, path, dev));
return;
}
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
+31 -3
View File
@@ -28,6 +28,7 @@ import shutil
import signal
import socket
import socketserver
import stat
import subprocess
import sys
import tempfile
@@ -3494,8 +3495,27 @@ def power_action(body):
raise Failure(str(exc), 409) from exc
def transfer_local(body):
"""Copy a desktop-selected file without a second, size-limited HTTP upload to /tmp."""
if os.environ.get("FRAME_CONTROL_APP") != "1":
raise Failure("Direct file copy needs the Frame Control desktop app", 403)
if body.get("action") != "copy-local" or not isinstance(body.get("path"), str):
raise Failure("Choose a file on this computer", 400)
path = Path(body["path"])
if not path.is_absolute() or not path.name or path.name.startswith("."):
raise Failure("Choose a regular file with a visible name", 400)
try:
info = path.stat()
except OSError:
raise Failure("That file is no longer available on this computer", 400) from None
if not stat.S_ISREG(info.st_mode) or info.st_size <= 0:
raise Failure("Choose a nonempty regular file", 400)
return {"message": push_file(path)}
POST = {
"/api/steam-pipewire": steam_pipewire_action,
"/api/transfer": transfer_local,
"/api/power": power_action,
"/api/android/backup": android_backup, "/api/backup": backup_action,
"/api/decky": decky,
@@ -3611,6 +3631,12 @@ def _next_chunk(chunks, timeout):
def push_file(path, dest="Downloads/"):
"""Copy a file to the Frame (as scripts/push.sh): rsync where both ends have it, else scp."""
name = Path(path).name
# A large file can legitimately take more than an hour over Wi-Fi. Keep a
# finite deadline, but allow roughly one MiB/s plus startup time.
try:
timeout = max(3600, min(24 * 3600, int(Path(path).stat().st_size / (1 << 20)) + 600))
except OSError:
raise Failure(f"{name} is no longer available on this computer", 400) from None
try:
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take our POSIX -e quoting.
if not frame_host.WINDOWS and shutil.which("rsync") and ssh("command -v rsync >/dev/null && echo yes || true").strip() == "yes":
@@ -3618,7 +3644,7 @@ def push_file(path, dest="Downloads/"):
else:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"Copying {name} timed out")
if r.returncode != 0:
@@ -4070,8 +4096,10 @@ class Handler(BaseHTTPRequestHandler):
length = int(self.headers.get("Content-Length") or 0)
if not name or name.startswith("."):
raise Failure("missing filename", 400)
if length <= 0 or length > MAX_UPLOAD:
raise Failure("empty or too-large upload", 400)
if length <= 0:
raise Failure("empty upload", 400)
if length > MAX_UPLOAD:
raise Failure("Browser uploads are limited to 8 GiB; use the desktop app to copy larger files", 413)
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
raise Failure("APK install needs a .apk file", 400)
backup = None