Compare commits

..
Author SHA1 Message Date
SAPHID 25d0cb41ab Keep stability handoff links and snapshot scope current 2026-10-27 05:20:24 +00:00
SAPHID 9e77a35e40 Merge remote-tracking branch 'origin/main' into coord/stability-goal-reconcile 2026-10-27 05:20:07 +00:00
Alex Southwell bc02974b49 Merge pull request #365 from saphid/docs/v0451-release-verification
Record v0.4.51 release verification
2026-10-27 05:19:50 +00:00
SAPHID 5b448bc922 Update stability handoff for current release authority and v0.4.51 2026-10-27 05:17:24 +00:00
SAPHID b345921385 Merge remote-tracking branch 'origin/main' into coord/stability-goal-reconcile 2026-10-27 05:16:40 +00:00
SAPHID 1c60985c9f Record v0.4.51 release verification 2026-10-27 05:11:26 +00:00
Alex Southwell 4da7220660 Merge pull request #364 from saphid/release/v0.4.51
Prepare Frame Control v0.4.51 candidate
2026-10-27 04:48:22 +00:00
SAPHID 728a4dbe9b Record v0.4.51 candidate smoke and remaining gates 2026-10-27 04:39:00 +00:00
SAPHID 91cf774b21 Prepare v0.4.51 with safe smoke proximity gate 2026-10-27 04:32:11 +00:00
Alex Southwell 560f3de563 Merge pull request #363 from saphid/feat/framey-localsend-managed-arm64
Adapt pinned LocalSend helper for ARM64 Framey installs
2026-10-27 04:30:20 +00:00
SAPHID 022d407ff9 Record bounded ARM64 LocalSend transfer in catalog 2026-10-27 04:21:35 +00:00
SAPHID 014f554012 Include LocalSend helper in desktop installers 2026-10-27 04:19:09 +00:00
SAPHID c847b7a3ca Align LocalSend evidence with native runtime checks 2026-10-27 04:17:33 +00:00
SAPHID ee13edabb8 Record isolated ARM64 LocalSend transfer on Frame 2026-10-27 04:15:40 +00:00
SAPHID fc030c6191 Merge remote-tracking branch 'origin/main' into feat/framey-localsend-managed-arm64 2026-10-27 04:08:43 +00:00
SAPHID 8990ddfb91 Adapt official LocalSend helper for managed ARM64 Framey 2026-10-27 04:08:34 +00:00
Alex Southwell af8fc63f3b Merge pull request #362 from saphid/feat/framey-localsend-arm64
Document official LocalSend source match for ARM64
2026-10-27 03:55:55 +00:00
SAPHID 9c39300185 Record isolated official LocalSend backend transfer 2026-10-27 03:48:09 +00:00
SAPHID 592988c212 Clarify LocalSend earlier build revision 2026-10-27 03:41:07 +00:00
SAPHID 89f5dc164e Merge remote-tracking branch 'origin/main' into feat/framey-localsend-arm64 2026-10-27 03:40:49 +00:00
SAPHID 13c689926d Document exact LocalSend source and ARM64 build provenance 2026-10-27 03:40:18 +00:00
Alex Southwell 1b143094d1 Merge pull request #361 from saphid/docs/v0450-release-verification
Record v0.4.50 release verification
2026-10-27 03:37:03 +00:00
SAPHID b2cc34576c Record v0.4.50 release verification 2026-10-27 03:21:19 +00:00
Alex Southwell 2797212d8c Merge pull request #360 from saphid/release/v0.4.50
Prepare Frame Control v0.4.50 candidate
2026-10-27 02:51:32 +00:00
SAPHID dab13fa876 Reject release jobs failed in earlier run attempts 2026-10-27 02:39:01 +00:00
SAPHID 36cef1096a Record v0.4.50 locked Frame smoke 2026-10-27 02:32:12 +00:00
SAPHID fcae5d66de Record v0.4.50 candidate gates and smoke limit 2026-10-27 02:06:18 +00:00
SAPHID a160043c63 Prepare Frame Control v0.4.50 candidate 2026-10-27 02:04:37 +00:00
Alex Southwell 64938443c8 Merge pull request #359 from saphid/feat/framey-store-request-recovery
Recover Framey store jobs after lost responses
2026-10-27 02:01:07 +00:00
Alex Southwell 1911923afa Merge pull request #358 from saphid/fix/framey-toggle-value
fix(framey): show saved values in standalone Decky toggles
2026-10-27 01:53:33 +00:00
Alex Southwell 2419279a97 Merge pull request #357 from saphid/feat/framey-progress-controls
Show indeterminate Framey plugin progress
2026-10-27 01:46:24 +00:00
Alex Southwell d958148c73 Merge pull request #354 from saphid/feat/framey-local-route-history
Return through local Framey panel routes
2026-10-27 01:39:05 +00:00
SAPHID a27e09e72c Merge remote-tracking branch 'origin/main' into feat/framey-store-request-recovery 2026-10-27 01:35:15 +00:00
Alex Southwell b3618690c2 Merge pull request #353 from saphid/feat/framey-sample-reset-modal
Show a reset confirmation in the Framey sample
2026-10-27 01:29:42 +00:00
SAPHID 030873703e Merge main and preserve Toggle and file-server runtime coverage 2026-10-27 01:28:29 +00:00
SAPHID c7979a862d Recover Framey store jobs after lost responses 2026-10-27 01:27:22 +00:00
Alex Southwell b963937227 Merge pull request #355 from saphid/feat/framey-fileserver-arm64
feat(framey): run DeckyFileServer backend on ARM64 without root
2026-10-27 01:23:04 +00:00
Alex Southwell 7053c40334 Merge pull request #356 from saphid/fix/panels-windows-oversize-response
Return reliable errors for oversized panel requests on Windows
2026-10-27 01:16:36 +00:00
SAPHID f8aff5496b fix(framey): honor saved values in standalone Decky toggles 2026-10-27 01:15:17 +00:00
SAPHID c0d8cb9dc4 Clarify Framey progress text placement 2026-10-27 00:49:03 +00:00
SAPHID 2888a45a30 Show indeterminate Framey plugin progress 2026-10-27 00:48:29 +00:00
SAPHID 92a84ea683 Return reliable errors for oversized panel requests on Windows 2026-10-27 00:35:50 +00:00
SAPHID 4bc5e3cab2 docs(framey): record combined Decky compatibility validation 2026-10-27 00:35:14 +00:00
SAPHID 86541446cf Merge remote-tracking branch 'origin/main' into feat/framey-fileserver-arm64
# Conflicts:
#	app/build/check-resources.js
#	app/package.json
#	docs/framey-compatibility.md
#	tests/framey_official.py
#	tests/test_frame_decky_store.py
#	tests/test_frame_headset.py
#	tests/test_framey_decky_runtime.py
#	ui/frame_decky_store.py
2026-10-27 00:31:11 +00:00
SAPHID af81e46c35 test(framey): verify packaged native assets and managed frontend policy 2026-10-27 00:29:24 +00:00
SAPHID 39aa8d56fb Return through local Framey panel routes 2026-10-27 00:28:47 +00:00
Alex Southwell f31c6871e4 Merge pull request #346 from saphid/feat/framey-deckyspy-backend
Make DeckySpy system monitoring work on ARM64 Frame
2026-10-27 00:28:47 +00:00
SAPHID f586214cb4 Merge remote-tracking branch 'origin/main' into feat/framey-fileserver-arm64
# Conflicts:
#	docs/framey-compatibility.md
#	scripts/framey-test-decky-runtime.py
#	tests/decky-runtime/cases.json
#	tests/decky-runtime/check.py
#	tests/framey_official.py
#	tests/test_frame_decky_store.py
#	tests/test_framey_decky_runtime.py
#	ui/frame_decky_store.py
2026-10-27 00:24:54 +00:00
SAPHID d20ff7f9aa feat(framey): port DeckyFileServer backend to ARM64 user service 2026-10-27 00:24:19 +00:00
Alex Southwell efc55919b6 Merge pull request #342 from saphid/feat/framey-sample-settings-lifecycle
Version Framey sample settings without losing old data
2026-10-27 00:20:09 +00:00
SAPHID 285b92573a Merge main and reconcile DeckySpy and Simple Timer compatibility 2026-10-27 00:18:25 +00:00
Alex Southwell 80d3244b05 Merge pull request #351 from saphid/fix/framey-simple-timer-lifecycle
Fix Simple Timer cancellation and backend restarts
2026-10-27 00:11:45 +00:00
SAPHID 5097a9f999 Show a reset confirmation in the Framey sample 2026-10-27 00:08:04 +00:00
Alex Southwell e9844473ea Merge pull request #347 from saphid/fix/framey-store-memory-job
Keep Framey store job recoverable without session storage
2026-10-27 00:03:01 +00:00
Alex Southwell 33838da03a Merge pull request #352 from saphid/docs/framey-author-api-current
Document current Framey module and dialog APIs
2026-10-26 23:56:45 +00:00
Alex Southwell 2970f5da61 Merge pull request #349 from saphid/feat/framey-scroll-hold-on-345
Keep Framey plugin scrolling while a button is held
2026-10-26 23:49:03 +00:00
Alex Southwell 5741ae6243 Merge pull request #345 from saphid/fix/framey-panel-status-session
Scope Framey panel status and input to active session
2026-10-26 23:42:21 +00:00
SAPHID 6cfaabbe8f Document current Framey module and dialog APIs 2026-10-26 23:37:12 +00:00
Alex Southwell 0905842ded Merge pull request #350 from saphid/docs/v0449-release-evidence
Record v0.4.49 release verification
2026-10-26 23:34:13 +00:00
SAPHID b3de6ecf71 Fix Simple Timer cancellation and backend restarts 2026-10-26 23:33:18 +00:00
SAPHID f4cc9ce0a1 Record v0.4.49 release verification 2026-10-26 23:26:59 +00:00
SAPHID 617661b7a2 Keep Framey plugin scrolling while a button is held 2026-10-26 23:24:42 +00:00
Alex Southwell 38db6a2846 Merge pull request #348 from saphid/release/v0.4.49
Prepare Frame Control v0.4.49 repair candidate
2026-10-26 23:05:19 +00:00
SAPHID a17ebf96fe Record locked v0.4.49 candidate smoke 2026-10-26 22:51:00 +00:00
SAPHID 6247dfbf89 Prepare Frame Control v0.4.49 candidate 2026-10-26 22:48:02 +00:00
SAPHID 02fec484d6 Keep Framey store job recoverable without session storage 2026-10-26 22:46:50 +00:00
Alex Southwell 7ed59a9b25 Merge pull request #344 from saphid/fix/release-publisher-gates
Require exact CI and installer gates before publishing
2026-10-26 22:46:48 +00:00
SAPHID 847509fa7e Merge remote-tracking branch 'origin/main' into feat/framey-deckyspy-backend 2026-10-26 22:39:48 +00:00
SAPHID 175b814e1b Record final DeckySpy validation results 2026-10-26 22:39:12 +00:00
SAPHID 134ceea2b7 Merge remote-tracking branch 'origin/main' into fix/framey-panel-status-session 2026-10-26 22:38:10 +00:00
SAPHID 1824b54140 Scope Framey panel status and input to active session 2026-10-26 22:38:05 +00:00
SAPHID 14d677f527 Merge remote-tracking branch 'origin/main' into fix/release-publisher-gates 2026-10-26 22:36:58 +00:00
Alex Southwell fe8a860afb Merge pull request #343 from saphid/test/framey-decky-runtime
Test managed shims in actual Decky 3.3.0 runtime
2026-10-26 22:36:34 +00:00
SAPHID c6fec613d6 Make DeckySpy system monitoring work on ARM64 Frame 2026-10-26 22:36:31 +00:00
SAPHID 72f8152525 Resolve gh consistently through publisher shell 2026-10-26 22:35:21 +00:00
SAPHID 62e27a2450 Require native Decky regression gate for publication 2026-10-26 22:28:50 +00:00
Alex Southwell 4c3381f006 Merge pull request #339 from saphid/docs/v0448-release-evidence
Record v0.4.48 release verification
2026-10-26 22:26:58 +00:00
SAPHID 1cd1c07b7a Keep withdrawn releases out of the update channel 2026-10-26 22:25:05 +00:00
SAPHID 55fb9226ba Fail closed on exact release CI and installer gates 2026-10-26 22:21:53 +00:00
SAPHID 5418c6f094 Clarify cancelled v0.4.48 preview versus tagged release 2026-10-26 22:18:01 +00:00
Alex Southwell 14f9506e74 Merge pull request #340 from saphid/fix/artwork-resolver-deadline-test
Keep artwork DNS deadline test stable on loaded CI
2026-10-26 22:16:56 +00:00
SAPHID 0447fe217c Test managed shims in actual Decky 3.3.0 runtime 2026-10-26 22:14:45 +00:00
SAPHID 4967b13dca Version Framey sample settings without losing old data 2026-10-26 22:10:25 +00:00
SAPHID 0026095096 Record merged repair and installed-plugin recovery 2026-10-26 22:10:06 +00:00
SAPHID 1309a0883d Compare resolver wait with computed deadline interval 2026-10-26 22:06:30 +00:00
Alex Southwell 04cde721c8 Merge pull request #338 from saphid/feat/framey-mpris-runtime
Repair Music Control for Decky's real legacy runtime
2026-10-26 22:03:56 +00:00
SAPHID d1a4270b45 Record v0.4.48 withdrawal after Decky regression 2026-10-26 22:02:20 +00:00
SAPHID de3d7d4fbc Document standing stability goal and coordinator takeover gates 2026-10-26 22:01:06 +00:00
SAPHID 1e6d742b98 Check artwork DNS deadline without runner timing flake 2026-10-26 21:58:44 +00:00
SAPHID 818ec2dcba Record v0.4.48 release verification 2026-10-26 21:54:28 +00:00
Alex Southwell 1ddb6278b8 Merge pull request #337 from saphid/docs/framey-native-port-blockers
Clarify native blockers for Emuchievements and Cloud Save
2026-10-26 21:44:14 +00:00
SAPHID cbc20f0933 Update Music Control e2e receipts for runtime repair 2026-10-26 21:30:16 +00:00
SAPHID b8d57eb6bd Repair Music Control for Decky's real legacy runtime 2026-10-26 21:23:02 +00:00
SAPHID 4252d22975 Clarify native blockers for Emuchievements and Cloud Save 2026-10-26 16:13:44 +00:00
Alex Southwell 5438e1d9d0 Merge pull request #336 from saphid/release/v0.4.48
Prepare Frame Control v0.4.48 candidate
2026-10-26 16:11:06 +00:00
SAPHID 9418a340eb Record v0.4.48 candidate Frame smoke 2026-10-26 15:46:53 +00:00
SAPHID a8bf01d660 Merge remote-tracking branch 'origin/main' into release/v0.4.48 2026-10-26 15:45:09 +00:00
Alex Southwell 5e3f9559c7 Merge pull request #335 from saphid/feat/framey-mpris-compat
Repair official Music Control backend bound-method calls
2026-10-26 15:44:46 +00:00
SAPHID a31b4a426c Prepare Frame Control v0.4.48 version 2026-10-26 15:42:38 +00:00
SAPHID ff465accc5 Merge remote-tracking branch 'origin/main' into feat/framey-mpris-compat 2026-10-26 15:31:25 +00:00
SAPHID d9c2cc3f27 Repair official Music Control backend bound-method calls 2026-10-26 15:31:06 +00:00
Alex Southwell 910d847573 Merge pull request #333 from saphid/docs/framey-emuchievements-arm
Document Emuchievements ARM64 hash helper feasibility
2026-10-26 15:30:25 +00:00
Alex Southwell 89f986b91c Merge pull request #334 from saphid/docs/v0447-release-evidence
Record v0.4.47 release verification
2026-10-26 15:18:31 +00:00
SAPHID e711945c5a Record v0.4.47 release verification 2026-10-26 15:09:02 +00:00
SAPHID 3ca8de985e Record Emuchievements ARM64 helper feasibility 2026-10-26 14:56:14 +00:00
Alex Southwell 035c59ee11 Merge pull request #332 from saphid/release/v0.4.47
Prepare Frame Control v0.4.47 candidate
2026-10-26 14:43:58 +00:00
SAPHID c687bcc71f Merge remote-tracking branch 'origin/main' into release/v0.4.47 2026-10-26 14:35:59 +00:00
Alex Southwell b6f45b287f Merge pull request #331 from saphid/feat/framey-shotty-python-compat
Load Shotty official backend with a scoped Python shim
2026-10-26 14:35:41 +00:00
SAPHID a77434d91b Record exact v0.4.47 candidate Frame smoke 2026-10-26 14:25:21 +00:00
SAPHID e07fa15592 Prepare Frame Control v0.4.47 candidate 2026-10-26 14:23:06 +00:00
SAPHID 1f5b37a755 Merge current main into Shotty compatibility branch 2026-10-26 14:17:13 +00:00
Alex Southwell 77c54c0b01 Merge pull request #330 from saphid/feat/framey-inert-steam-component
Show Game Theme Music settings in Framey
2026-10-26 14:16:29 +00:00
SAPHID d38af757ec Load Shotty's official backend with a scoped Python shim 2026-10-26 14:04:47 +00:00
SAPHID 211f4fb148 Skip rendered text when replaying private Steam snapshots 2026-10-26 13:46:47 +00:00
SAPHID 72fd779170 Merge remote-tracking branch 'origin/main' into feat/framey-inert-steam-component 2026-10-26 13:45:49 +00:00
SAPHID 286e692147 Render Game Theme Music settings with inert Steam component 2026-10-26 13:45:39 +00:00
Alex Southwell fe51d29eb4 Merge pull request #329 from saphid/docs/framey-deck-shelves-backend
Document Deck Shelves settings backend evidence
2026-10-26 13:44:49 +00:00
SAPHID 4b47c384e6 Document isolated Deck Shelves settings backend check 2026-10-26 13:32:17 +00:00
Alex Southwell 7baafc9a18 Merge pull request #328 from saphid/fix/framey-audit-module-receipts
Fix receipt-bound modules in Framey browser audit
2026-10-26 13:22:08 +00:00
SAPHID b0d8bf3968 Fix receipt-bound modules in Framey browser audit 2026-10-26 13:08:50 +00:00
Alex Southwell b05a666e50 Merge pull request #327 from saphid/docs/v0446-release-evidence
Document v0.4.46 release verification
2026-10-26 12:56:58 +00:00
SAPHID 8feb19a6d3 Document v0.4.46 release verification 2026-10-26 12:49:18 +00:00
119 changed files with 7164 additions and 205 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
name: Decky backend runtime
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
decky-runtime:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Actual Decky 3.3.0 discovery and safe backend probes
run: python3 scripts/framey-test-decky-runtime.py --output "$RUNNER_TEMP/decky-runtime.json"
- uses: actions/upload-artifact@v4
if: always()
with:
name: decky-runtime-${{ matrix.os }}
path: ${{ runner.temp }}/decky-runtime.json
if-no-files-found: warn
+1
View File
@@ -6,6 +6,7 @@ ship other people's software, unchanged, each under its own licence:
| What | Where | Licence | Details |
|---|---|---|---|
| KDE Connect 24.02.2 and five libraries, as Valve builds them for the Frame | Desktop apps and the iPhone app; copied to the Frame for the keyboard and trackpad | GPL and LGPL (per package) | [frame/kdeconnect/NOTICE.md](frame/kdeconnect/NOTICE.md), licence texts in [frame/kdeconnect/LICENSES](frame/kdeconnect/LICENSES), complete source in the [kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1) |
| psutil 7.2.2, unchanged stable-ABI Linux amd64/ARM64 wheels | Desktop and headset app bundles; copied only into exact managed DeckySpy 0.6.7 installs | BSD-3-Clause | [pins and provenance](ui/vendor/psutil-7.2.2/README.md), [license](ui/vendor/psutil-7.2.2/LICENSE.txt) |
| Python 3.12 ([python-build-standalone](https://github.com/astral-sh/python-build-standalone)) | Desktop apps | PSF License and others | Included with it, in the app's `python` folder |
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
+16
View File
@@ -13,6 +13,22 @@ const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/vendor/deckyfileserver-1.2.0/aarch64.gz",
"ui/vendor/deckyfileserver-1.2.0/x86_64.gz",
"ui/vendor/deckyfileserver-1.2.0/source.tar.gz",
"ui/vendor/deckyfileserver-1.2.0/LICENSE.txt",
"ui/vendor/deckyfileserver-1.2.0/GO-LICENSE.txt",
"ui/vendor/deckyfileserver-1.2.0/README.md",
"scripts/build-framey-fileserver.py",
"ui/vendor/localsend-0.4.5/aarch64.gz",
"ui/vendor/localsend-0.4.5/source.tar.gz",
"ui/vendor/localsend-0.4.5/LICENSE.txt",
"ui/vendor/localsend-0.4.5/GO-LICENSE.txt",
"ui/vendor/localsend-0.4.5/README.md",
"scripts/build-framey-localsend.py",
"ui/vendor/psutil-7.2.2/aarch64.whl",
"ui/vendor/psutil-7.2.2/x86_64.whl",
"ui/vendor/psutil-7.2.2/LICENSE.txt",
"ui/frame_power.py",
"ui/frame_status.py",
"scripts/frame_power_sample.py",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.46",
"version": "0.4.51",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.46",
"version": "0.4.51",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+19 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.46",
"version": "0.4.51",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -60,6 +60,21 @@
"framey-compatibility.json"
]
},
{
"from": "../ui/vendor/deckyfileserver-1.2.0",
"to": "ui/vendor/deckyfileserver-1.2.0",
"filter": ["*.gz", "*.txt", "README.md"]
},
{
"from": "../ui/vendor/localsend-0.4.5",
"to": "ui/vendor/localsend-0.4.5",
"filter": ["*.gz", "*.txt", "README.md"]
},
{
"from": "../ui/vendor/psutil-7.2.2",
"to": "ui/vendor/psutil-7.2.2",
"filter": ["*.whl", "LICENSE.txt", "README.md"]
},
{
"from": "../ui/apk_sources",
"to": "ui/apk_sources",
@@ -75,7 +90,9 @@
"*.sh",
"obs-headset-source.py",
"frame_power_sample.py",
"frame_power_report.py"
"frame_power_report.py",
"build-framey-fileserver.py",
"build-framey-localsend.py"
]
},
{
@@ -0,0 +1,32 @@
# Chunked Framey frontend audit route, 26 October 2026
The local 116-plugin Chromium audit served chunked modules at a route that
omitted the store receipt, while the production panel requested
`/framey/module/<id>/<receipt>/<file>`. The audit parsed that receipt as part
of the file path and returned 404. This made its result for chunked frontends
weaker than the separate targeted panel checks.
The audit now accepts the production URL shape only when the plugin ID,
receipt and JavaScript path are valid, the receipt equals the catalog SHA-256,
and the cached archive bytes match that SHA-256. Invalid IDs, receipts, paths
and changed archive bytes fail before the module is served. The production
Framey panel and installer are unchanged.
## Exact archived browser check
Pinned official Deck Shelves 2.0.0 (store ID 135, SHA-256
`b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91`)
ran through the repaired audit in an opaque Chromium iframe. Its entry module
and all eight JavaScript chunks returned HTTP 200 at receipt-bound URLs. The
frontend reported ready and called `get_settings`; the audit denied that
backend call and a Steam library read. Plugin network access remained blocked
by the audit policy. The previous
catalog browser observation had failed to resolve `./chunks/...` from a blob
URL. This check verifies the current audit route and frontend initializer,
not the official backend or a live shelf.
The focused route regression covers entry and chunk reads plus wrong receipt,
path traversal, malformed ID and changed archive bytes. The catalog's
**4 works / 50 partial / 51 broken / 11 N/A** statuses are unchanged. Deck
Shelves remains partial; installed settings, home-shelf behavior, worn VR and
physical controller input remain unverified.
@@ -4,7 +4,17 @@ The exact official Deck Shelves 2.0.0 archive (store ID 135, SHA-256 `b566280ad3
Framey now serves JavaScript files from the managed plugin's `dist` directory using hierarchical module URLs. Its opaque `allow-scripts` iframe still has no Frame UI key. Local Chromium received HTTP 200 for the entry and eight chunks, reported the initializer ready, and rendered the settings panel with “Enable shelves on Home,” “Create default shelves,” “Behavior,” and “Shelves.” The browser audit denied the attempted `get_settings` backend call, Steam library read and plugin network access. No backend method executed.
The catalog moves this exact version from broken to **partial** for visible local settings only. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Settings writes, official backend behavior, real Frame module serving, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
The catalog moves this exact version from broken to **partial** for visible local settings. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Actual Decky RPC, real Frame settings, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
## Isolated official backend settings check
I verified the same archive digest before loading its unchanged `Deck-Shelves/main.py` in local Python. A stub `decky` module supplied only a logger; `DECKY_PLUGIN_SETTINGS_DIR` and `DECKY_HOME` pointed inside a temporary directory. No Decky service, headset, Steam API, or network call was involved. Calling `_main`, `get_settings`, and `set_settings` returned the default disabled state, saved one shelf named `Local Test`, and returned that shelf from a fresh `Plugin` instance. The temporary directory was removed after the check.
```json
{"archiveSha256":"b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91","defaultEnabled":false,"defaultShelves":0,"saveResult":true,"reopenedEnabled":true,"reopenedShelfTitle":"Local Test","settingsFileExists":true}
```
This proves only the archive's Python settings logic under those local conditions. It does not prove that Decky 3.3.0 can dispatch these methods on the ARM64 Frame, that the opaque panel can save settings through the production bridge, or that a shelf appears on Steam Home or in VR.
## Exact source real Frame gates
@@ -0,0 +1,82 @@
{
"timeUtc": "2026-10-26T22:28:36.276360+00:00",
"storeId": 89,
"version": "0.6.7",
"officialSha256": "2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7",
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"architecture": "aarch64",
"scope": "Read-only system monitoring, a silent local warning and private plugin settings; live ARM64 backend through local opaque Chromium. No worn VR.",
"backend": {
"successfulMethods": [
"get_battery",
"get_boottime",
"get_cpu",
"get_memory",
"get_net_interface",
"get_settings",
"get_top_k_mem_procs",
"get_version"
],
"memoryTotalMatchedProcMeminfo": true,
"batteryPercent": 100,
"noNetworkOrProcessStateChanged": true
},
"browser": {
"scope": "local opaque Chromium with live actual Frame ARM64 Decky 3.3.0",
"checks": {
"cpuVisible": true,
"memoryVisible": true,
"battery100": true,
"processVisible": true,
"networkVisible": true,
"version067": true,
"silentWarning": true,
"intervalChangedTo6": true,
"reopenRetainedInterval6": true,
"reopenedLiveArm64Metrics": true
}
},
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"archiveAbsent": true
},
"independentCleanupAudit": {
"baseAbsent": true,
"unitAbsent": true,
"archiveAbsent": true,
"service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
"lockAbsent": true
},
"batteryBefore": [
100
],
"batteryAfter": [
100
],
"originalServiceStatesPreserved": true,
"lockReleased": true,
"fakeFrame": "Pinned official install/remove transaction suite passed, including dependency receipt and files; backend execution stubbed.",
"localRuntime": "Actual published Decky 3.3.0 amd64: all five cases passed, including historical bad-shim negative control.",
"tests": {
"python": {
"run": 1649,
"skipped": 16,
"result": "passed"
},
"fakeFramePinnedTransactions": "passed",
"node24": {
"run": 46,
"result": "passed"
}
},
"unverified": [
"Automatic low-battery/OOM alerts",
"Resume callbacks and game-time warnings",
"Sound",
"Worn VR and physical-controller input"
]
}
@@ -0,0 +1,60 @@
# DeckySpy system monitoring on Frame
Official ID 89, DeckySpy 0.6.7, SHA-256
`2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7`.
[Redacted results](framey-deckyspy-2026-10-26.json).
The official archive imports `psutil` but does not ship it. Published Decky
3.3.0 reproduced `ModuleNotFoundError: No module named 'psutil'` during backend
startup. The previous catalog's account/external-integration reason was wrong:
this plugin monitors local system metrics; its token is a local reload guard.
Exact managed installs now include unmodified, SHA-256-pinned BSD-3-Clause
psutil 7.2.2 stable-ABI wheels for amd64 and ARM64. The native architecture is
selected remotely and its ELF header checked. Dependencies and their license
stay in this plugin's `py_modules`; no pip, network download, system-library
changes or other plugin imports are involved. [Provenance and license](../../ui/vendor/psutil-7.2.2/README.md).
Existing copies need an explicit managed update or reinstall. Installed receipts
without `deckyspy-psutil-and-battery` stay partial and display that requirement;
they do not inherit the fixed catalog status.
On Frame OS 0.5.6, upstream psutil also misses `max1720x_bat_7-36`: its Linux
battery discovery filters names for `BAT` or `battery`. The plugin-local
fallback preserves native psutil results, then reads supplies whose `type` is
`Battery`. It reads capacity/status and optional time-to-empty in Linux sysfs
seconds, leaves unknown states unknown, and never writes sysfs.
Verification used the **published Decky 3.3.0 executable**, not a hand-built
Plugin instance. Its actual legacy websocket route passed version, CPU,
memory, battery, uptime, process and interface reads on the real ARM64 Frame.
The memory total exactly matched `/proc/meminfo`. No process/network controls
were run. Before starting, the test confirmed template migration sources were
absent so upstream's template migrations could not move existing user data.
Local headless Chromium rendered the exact official frontend in Framey's
opaque iframe, connected through a single trusted SSH RPC pipe to that live
ARM64 backend. CPU/memory/swap/uptime, 100% battery, process rows and interfaces
rendered. A test OOM warning produced a silent visible toast. Native Chromium
keyboard input changed only the private plugin refresh interval from 5 to 6;
backend setting/commit calls and a fresh panel reopening confirmed persistence.
No real media, volume, power, network or process settings changed.
The shared `/tmp/frame-test.lock` protected all device execution. The private
user service had a runtime limit and was stopped/removed, along with its base
and upload. An independent SSH audit confirmed no unit/base/upload or running
service remained. Battery stayed 100 → 100. Normal managed Decky was failed and
the headset panel inactive as found; the lock was released. No headset wipe,
normal service restart or installation outside the removable private user
service occurred. Raw process names, addresses and local guard tokens were
kept out of published evidence.
Local verification also passed all five actual-Decky amd64 regression cases,
the focused fake-Frame pinned install/remove transaction (backend execution
stubbed), 1,649 Python tests (16 skips), and 46 app tests with Node 24. The
native ARM64 CI gate additionally runs discovery/public calls for this shim.
**Works** covers useful read-only system monitoring, a silent test warning and
private settings while the panel is open. Automatic OOM/low-battery warnings,
resume callbacks, game-time warnings, sound, worn VR and physical controllers
remain unverified. No claim is made for persistent monitoring after closing
Framey's isolated panel.
@@ -0,0 +1,40 @@
# Emuchievements ARM64 hash helper feasibility, 26 October 2026
The exact official Emuchievements 2.0.5 archive (store ID 28, SHA-256
`98dcb443e81b9f9011cfe77c69eb659869c8e2df2df8b0a443df66cbfc2f5a89`)
contains an x86_64 `Emuchievements/bin/hash`. Frame Control still blocks
that archive on ARM64; this experiment did not install the plugin.
The upstream source commit
[`8567e94befffc943434b2adb842487274815f8bb`](https://github.com/EmuDeck/Emuchievements/commit/8567e94befffc943434b2adb842487274815f8bb)
declares version 2.0.5. Its `src/py/main.py` matches the published ZIP's
`Emuchievements/main.py` byte for byte (SHA-256
`d4e72f136c94e48919575937ec4190f28864cc259eb111ef47f0df99f5912645`).
The pinned build dependencies at that commit are libarchive
`b3be94ba4f36943745e64a6a0a3c21c2d1281d3c`, libchdr
`a20e04c12c0c20048bf771da6df7aa2e51f5e0cb`, and rcheevos
`85a3209398e576d79170f569fcddf277640f3f06`.
On a separate Linux host, the version-matched source and submodules built an
x86_64 `hash` with CMake. It and the official x86_64 binary both returned
`ff78ca6c21ac11504dbddbd5d4e7524a` for one synthetic `.rom` file. An
isolated Debian cross-build using `gcc-aarch64-linux-gnu` and
`g++-aarch64-linux-gnu` produced an AArch64 binary with SHA-256
`9c7aedb80bdd64941aa5dd9b41f9bb8471411c812e04c7ee7d60486779d99997`.
It links to `liblzma.so.5`, `libstdc++.so.6`, `libgcc_s.so.1`, and `libc.so.6`.
A real Frame test acquired `/tmp/frame-test.lock` atomically with a unique
owner before reading the device. SteamVR activity was **3** (standby),
proximity raw **3.0625**, and battery **100%**. The ARM64 helper ran against
the same synthetic ROM in a private `/tmp` directory, exited 0, and returned
the same hash. The owned directory and lock were removed. No normal service,
power setting, Steam library, real ROM, or installed plugin was changed.
The separate host's source/build directory was also removed after the test.
This proves only a single-file helper path. The official ZIP remains blocked
because it still bundles x86_64 bytes. A release-ready port needs a packaged
ARM64 helper with GPL-3.0 source and license compliance, format-specific hash
coverage (including archives and CHD), a managed plugin transaction, actual
Decky backend and frontend checks, and cleanup. RetroAchievements login,
real ROM scanning, wearer-visible VR, and physical controller use remain
unverified.
@@ -0,0 +1,128 @@
{
"plugin": {
"id": 60,
"name": "DeckyFileServer",
"version": "1.2.0",
"sha256": "7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0"
},
"status": "partial",
"runtime": {
"decky": "v3.3.0",
"amd64LoaderSha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"amd64Probes": [
{
"id": 60,
"result": "discovery, API1 settings, native HTTPS listing/download, thumbnail policy and stop passed; uploads disabled",
"discovered": [
{
"name": "DeckyFileServer",
"version": "1.2.0",
"load_type": 1,
"disabled": false
}
]
},
{
"id": 60,
"result": "original empty argument bug reproduced: thumbnail policy ignored",
"discovered": [
{
"name": "DeckyFileServer",
"version": "1.2.0",
"load_type": 1,
"disabled": false
}
]
}
]
},
"realFrame": {
"timeUtc": "2026-10-27T00:21:17.975213+00:00",
"arch": "aarch64",
"uid": 1000,
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"publicFixtureOnly": true,
"uploadsEnabled": false,
"actions": {
"discoveryAndApi1Rpc": true,
"settingsInitialized": true,
"nativeHttpsListing": true,
"nativeHttpsDownload": true,
"uploadsDenied401": true,
"disabledThumbnailsRespected": true,
"serverStop": true
},
"normalServicesBefore": {
"decky": "failed",
"panel": "inactive"
},
"normalServicesAfter": {
"decky": "failed",
"panel": "inactive"
},
"batteryBefore": [
99
],
"batteryAfter": [
100
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"archiveAbsent": true
},
"lockReleased": true,
"independentAudit": {
"baseAbsent": true,
"unitAbsent": true,
"process": "MainPID=0\nLoadState=not-found\n",
"lockAbsent": true
}
},
"localChromium": {
"opaqueOrigin": "null",
"warningVisible": true,
"uiStart": true,
"separateLanHostDownload": true,
"uiStop": true,
"settingsModal": true,
"reopenPersistedDirectory": true,
"passed": true
},
"fakeFrame": "Pinned official archive install/remove; native helper hash, manifest flags and shim receipt asserted; plugin code not executed by fake Frame.",
"limitations": [
"Frontend prints HTTP and steamdeck URLs although helper uses HTTPS.",
"Steam directory picker unavailable; a preconfigured directory was used.",
"Settings thumbnail toggle visually showed unchecked despite backend true; no settings save asserted.",
"Uploads and generated thumbnails unverified; all test requests read-only.",
"Timeout expiry, protected-file permissions and worn VR unverified."
],
"builds": [
{
"arch": "x86_64",
"sha256": "5e90efd1bf5163bef1686045f4714ad0e4fd0f54edb037fce8cdb06c58177bcb",
"gzipSha256": "84e49f049bbc475bc553ef52adc05dcd323e36c5f5ec75008558ae87f3a0e6d7",
"bytes": 14815380
},
{
"arch": "aarch64",
"sha256": "86b80021ebd62ed619eba065757c1122defa5a74b2c045aa44a0c6b4403a508a",
"gzipSha256": "7ca1b8aebd4e8d2c8ce382ca561e1413ad8e581f85f419a1da777c87d86c4a19",
"bytes": 13893780
}
],
"startupRetries": "Two private test-adapter failures (CEF reload, script syntax) and one overly early LAN readiness assertion preceded the successful run. Each run removed its private service/base/upload and released the lock; battery stayed 100.",
"rebuildMatchedBothAssets": true,
"automatedChecks": {
"pythonTests": 1662,
"pythonSkipped": 16,
"nodeAppTests": 46,
"publishedDecky330NativeAmd64Probes": 9,
"fakeFramePinnedInstallRemove": "passed",
"combinedDesktopResources": "passed",
"combinedHeadsetBundleBytes": 12485251
}
}
@@ -0,0 +1,84 @@
# DeckyFileServer ARM64 backend — 27 October 2026
DeckyFileServer **1.2.0**, official store ID **60**, moves **broken → partial**
only for the exact managed archive
`7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0`.
[Redacted observations](framey-fileserver-arm64-2026-10-27.json).
## Confirmed blockers and adaptation
The published ZIP contains a 19,779,282-byte x86_64 Go helper and requests `_root`.
Its actual operations use ordinary file permissions and an unprivileged port.
The managed installer replaces only this digest's original helper, checks the
replacement hash and ELF machine on the Frame, and removes only this manifest's
root request. Unknown versions, other binaries and root plugins remain blocked;
the Frame repeats archive/root/binary checks before extraction.
Pinned upstream commit `dcf3f3ce5104d05811fa8c156c4f58632b84b313` has Python,
manifest and package metadata byte-identical to the store archive. The published
Go executable lacks a VCS revision, so that binary's source revision cannot be
independently established. The rebuilt static Linux amd64/ARM64 helpers use
Go 1.25.7 and vendored dependencies. Complete corresponding GPL source, dependency
licenses, build recipe and hash pins ship with both desktop and headset bundles:
[provenance](../../ui/vendor/deckyfileserver-1.2.0/README.md).
The sole Go change sends its log to Decky's per-plugin log directory instead of
one global `/tmp` file. No compiler, pip, root service or runtime download is used.
Actual published Decky 3.3.0 discovery/public API1 RPC reproduced a hostname-DNS
failure in `get_status`. A local-address fallback selects the kernel source IP
without sending a packet, or returns loopback when no route exists.
The original command array includes empty strings for omitted flags; Go stops
parsing there and silently ignores the following `-disablethumbnails` flag.
The shim filters empty arguments and applies the thumbnail setting before start.
A negative runtime case restores the original empty arguments and proves that
thumbnails remain enabled despite the setting. **Decky 3.3.0 does expose
`decky.HOME`**; an initial assumption that it was removed was disproved by the
actual runtime. That API call and the plugin's API1 instance convention stay intact.
## Verification
- **Published Decky 3.3.0 amd64:** actual discovery, default settings, public
API1 calls, native HTTPS directory listing and marker download, disabled
thumbnails, upload endpoint returning 401, history and server stop. Execution
used an unprivileged, network-isolated, read-only disposable container.
- **Real Frame ARM64, Frame OS 0.5.6:** the same useful backend actions passed
through Steam CEF's existing Decky websocket, using a bounded private user
service and a directory containing only public test markers. No home directory
was shared and uploads stayed off.
- **Local Chromium opaque iframe with the actual Frame backend:** official
warning, Enable Server, stop, settings modal and panel reopen with persisted
private directory passed. A separate LAN host downloaded exactly the public
marker over HTTPS after the panel started the server. The relay allowed only
safe settings on that private directory; it denied uploads and other methods.
- **Fake Frame:** pinned archive install/remove, original frontend bytes,
replaced native helper hash, root-free manifest and managed shim receipt
passed through HTTP and SSH. Fake Frame does not execute plugin backends.
The shared `/tmp/frame-test.lock` was acquired normally and released after each
run. Two private test-adapter errors and an early LAN-readiness assertion were
corrected before the successful run; all attempts cleaned their private services
and artifacts. Normal managed Decky remained failed and the panel inactive,
matching their original states. Battery stayed **100 → 100** and no power setting
was changed. An independent SSH audit confirms base/unit/upload absent,
`MainPID=0`, `LoadState=not-found` and lock absent. Chromium and the local relay
were closed. No worn-VR claim is made.
The final branch, including merged DeckySpy and Simple Timer, passes **1,662
Python tests (16 skipped)**, all **9** published Decky 3.3.0 amd64 probes, **46**
app tests and pinned fake-Frame install/remove. Both helper rebuilds match byte
for byte. The combined headset bundle is 12,485,251 bytes, below its existing
32 MiB limit, and includes both native dependencies plus GPL source/licenses.
## Remaining limitations
The official frontend advertises **HTTP** and a **steamdeck** hostname even
though its helper always serves HTTPS. Use `https://<Frame address>:<port>`;
the upstream public self-signed certificate produces a browser warning.
Its Steam folder picker is unavailable in Framey; tests preconfigured only the
private fixture directory. The thumbnail toggle also appeared unchecked in the
settings modal while backend state was true; saving that modal was not tested.
These are concrete frontend follow-ups, so the plugin remains **partial**.
Uploads, thumbnail generation, inactivity shutdown, protected-file permissions
and worn VR remain unverified. The server exposes its selected directory on all
interfaces without authentication; select only files intended for sharing on a
trusted network. Existing unadapted copies require managed update/reinstall.
@@ -0,0 +1,7 @@
# Game Theme Music local panel check, 26 October 2026
The exact official Game Theme Music 1.7.1-1 archive (store ID 52, SHA-256 `9ce293ab4db212c03c6b96dfbc5e4d92e874d33387463f53223773921f75a80e`) passed the existing fake Frame install/remove audit. Its prior local Chromium run stopped before initialization with `Cannot read properties of null (reading 'type')`: the frontend dereferenced the result of `DFL.fakeRenderComponent` while looking for Steam's Library Context Menu.
Framey's isolated host now returns an inert local component type from that lookup. The patch API remains a no-op and does not expose or modify a Steam React tree. In the repository's opaque Chromium audit, the unchanged archive reported initializer ready and rendered a Settings view containing Music Volume, Default Muted, Use yt-dlp, Invidious Instance, Download Music, Overrides and Extras. The audit observed a `get_setting` request, but denied the backend call. It also denied plugin network requests; a YouTube/Invidious fetch failed as expected. The bounded root-text observation came from the test iframe and contained only these public UI labels.
The catalog therefore moves this digest from **broken** to **partial** for the visible local settings panel. No theme was played or injected into a Steam game page. Music search, playback, downloads, settings persistence, live ARM64 backend behavior, wearer-visible VR and physical controller input remain unverified. The earlier failed browser observation remains in the canonical JSON alongside this follow-up so the change in evidence is explicit.
@@ -0,0 +1,42 @@
{
"helperSha256": "e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae",
"lockAcquired": true,
"action": "private_network_marker_only",
"before": {
"activity": 3,
"proximity": [
4.6875
],
"battery": [
72
],
"services": {
"frame-control-decky.service": "inactive",
"frame-control-headset.service": "inactive"
}
},
"transfer": {
"result": "private_arm64_transfer_passed",
"protocolVersion": "2.0",
"markerBytes": 38,
"markerSha256": "3e724efac20a4a600a93f7bdddd469b8287a6aaa2b7b433d838d8ef23e1a9c2c",
"network": "private-veth-no-default-route",
"helperSha256": "e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae"
},
"after": {
"activity": 3,
"proximity": [
4.6875
],
"battery": [
72
],
"services": {
"frame-control-decky.service": "inactive",
"frame-control-headset.service": "inactive"
}
},
"result": "private_arm64_transfer_passed",
"ownedDirectoryAbsent": true,
"ownLockReleased": true
}
@@ -0,0 +1,62 @@
# Decky Localsend managed ARM64 candidate, 27 October 2026
Official Decky Localsend **0.4.5**, store ID **125**, archive SHA-256
`e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7`,
contains an x86_64 helper. Frame Control's proposed managed adaptation accepts
only that exact archive. The original x86 helper and Python backend hashes are
checked before a pinned static ARM64 helper replaces the binary. The headset
transaction checks the ARM64 SHA-256 and ELF machine again. It leaves the
official Python, frontend, manifest and BSD license unchanged, retains
managed update/remove receipts, and rejects changed archive digests.
[Source correspondence and local upstream transfer](framey-localsend-source-match-2026-10-27.md)
records the exact upstream plugin tag and Go commit. The native asset and
build recipe are [bundled](../../ui/vendor/localsend-0.4.5/README.md).
Go dependencies are checksum-pinned but not vendored. The Frame installer
does not download modules or run a compiler.
## Evidence so far
- The host transaction tests installed and removed the exact archive,
preserved its Python/LICENSE bytes, checked the managed receipt and
rejected a changed archive. The ARM64 branch independently wrote and
hashed the AArch64 ELF on x86 without executing it.
- The fake Frame managed install/remove flow passed with the exact official
archive and checked the installed helper digest. Its project containers,
volumes and network were absent after the run.
- The full local Python suite passed **1,669 tests with 16 skipped** after
rerunning with loopback socket access. The first attempt ran inside a
workspace sandbox that denied local test sockets across unrelated server
tests; it did not identify a LocalSend regression.
- A fresh local Chromium opaque iframe initialized the exact official frontend
with backend and network effects denied. Its visible text included Backend is
stopped, Device Name, Available Devices, File Upload, Receive History and
Configuration. The initializer reported ready and called only
`get_receive_history`, `get_backend_status` and `get_backend_config`.
This proves a visible initial panel with safe fallbacks, not working send,
receive or settings controls.
- The original x86 backend and helper completed one private no-route marker
transfer outside Decky. This does not prove the managed ARM64 binary or
actual Decky service.
- The published Decky 3.3.0 AMD64 and ARM64 runtime gate discovered the
adapted plugin and returned its stopped status and safe configuration through
public RPCs. Its helper remained stopped during that gate.
- A locked real Frame probe ran the rebuilt ARM64 helper with SHA-256
`e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae`
in a private network namespace with no default route. Its LocalSend 2.0
endpoint accepted and saved one 38-byte marker, and the saved bytes matched
the sent marker. The headset was in standby (SteamVR activity 3, proximity
raw 4.6875) with 72% battery before and after. Both normal Frame Control
services stayed inactive. The owned helper/process/files and shared lock
were removed. [Machine-readable result](framey-localsend-arm64-transfer-2026-10-27.json).
A useful frontend workflow still needs verification. The ARM64 marker test
exercised the helper in isolation, not the actual Decky plugin service, local
discovery, user-facing transfer, or installation on the headset. No worn
visibility or physical-controller claim follows from this candidate.
The proposed catalog status is **partial** for exact managed archive
installation and settings/status. Steam routes, local discovery, user-facing
transfer, clipboard/screenshot sharing and worn VR remain unverified. The
historical raw-archive audit fields still describe the earlier unadapted x86
ZIP; `managedNative` records the current digest-scoped path separately.
@@ -0,0 +1,55 @@
# Decky Localsend 0.4.5 source match and ARM64 build, 27 October 2026
The exact official store archive (ID 125, SHA-256
`e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7`)
remains **blocked** on Frame: it carries an x86_64 `bin/localsend-core`.
No managed plugin install, transfer, or status promotion was performed.
The archive's `main.py` has SHA-256
`db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8ad`,
identical byte for byte to `main.py` at the upstream Decky plugin tag `v0.45`
(`7c166bdc33e2a667527ffd511eb5b90d3ce0bd67`). That tag pins its Go
submodule to `ad28b8e9440e17e44b4735a6c4aa0512009cf333`. All 42 dependency
versions embedded in the official helper match that source's `go.mod`, which
declares Go 1.25.7.
Building that pinned Go commit with `CGO_ENABLED=0`, `GOOS=linux`,
`GOARCH=amd64`, and the flags `-trimpath -buildvcs=false -ldflags='-s -w'`
produced **byte-identical** official helper bytes: 15,241,400 bytes,
SHA-256 `d448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843`.
This resolves the source correspondence gap left by the earlier build from
the later `66edc509` commit. The same pinned source and flags with
`GOARCH=arm64` produced a static AArch64 ELF of 11,731,128 bytes, SHA-256
`e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae`.
Two ARM64 builds matched byte for byte. The Go core is MIT licensed; the
separate Decky Python plugin is BSD-3-Clause.
A unique-owner atomic `/tmp/frame-test.lock` preflight on the real Frame read
SteamVR activity **1**, proximity raw **88.0625**, and battery **35%**.
Because the headset was active or wearer status was unknown, the probe did
not upload or execute the ARM64 helper, start a LocalSend service, or send
input. The task-owned directory was absent and only the owned lock was
released. This is **not** a worn visual or controller result.
The unmodified plugin backend's `_main` starts a fixed-path Unix notification
socket but does not start the Go network service. Its explicit `start_backend`
method starts the helper with a default all-interface setting, HTTPS and
auto-save disabled. A local follow-up ran that exact Python backend with the
official x86_64 helper in a disposable user/network namespace. A veth pair
provided only documentation-range private addresses and there was no default
route. A private Decky module stub supplied task-owned settings, runtime,
plugin and log directories; its notification socket was moved into that
directory. The backend's `start_backend`, `proxy_get` info request and
`proxy_post` prepare/upload methods passed. One 42-byte public marker
arrived in the private upload directory with independent SHA-256
`346373ca56ec52c3f47b8d41de2a6a8479d8ad10d2891ff3b0dc7ea9c3892a39`.
The backend stopped, its process and private directory were absent, and the
global notification socket was untouched. This verifies the upstream x86
backend/proxy/transfer contract locally, **not** Decky 3.3.0 discovery, ARM64
transfer, an installed Frame plugin or real-network transfer.
Before a managed ARM64 adaptation, verify actual Decky backend discovery/RPC,
the frontend and a private transfer with the exact-source ARM64 helper;
package the source, license and exact helper digest. The official x86_64
archive remains blocked until that work passes. Worn VR and physical
controller use remain unverified.
@@ -0,0 +1,807 @@
{
"timeUtc": "2026-10-26T21:15:13.918421+00:00",
"plugins": [
{
"id": 12,
"name": "MusicControl",
"version": "1.1.6",
"sha256": "246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62",
"backend": "Python",
"frontendApi": "legacy"
},
{
"id": 111,
"name": "Music Control",
"version": "1.1.7",
"sha256": "3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9",
"backend": "Python",
"frontendApi": "legacy"
}
],
"regressions": {
"removedBoundMethodShim": {
"result": "Plugin._get_dbus_env() missing 1 required positional argument: 'self'",
"success": false
},
"realFrameSubprocessDiagnostic": {
"uid": 1000,
"ldLibrary": "<Decky PyInstaller extraction directory>",
"results": [
{
"shell": false,
"code": 0,
"hasArray": true,
"stderr": ""
},
{
"shell": true,
"code": 1,
"hasArray": false,
"stderr": "/bin/sh: /usr/lib/libc.so.6: version `GLIBC_2.42' not found (required by <Decky PyInstaller extraction directory>/libtinfo.so.6)\n"
}
]
}
},
"realFrame": {
"scope": "real Frame ARM64 Decky3.3; only discovery and selected-player read, no player controls",
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"before": {
"arch": "aarch64",
"uid": 1000,
"decky": "failed",
"panel": "inactive",
"artLinkPresent": false,
"imagesParentPresent": true,
"battery": [
100
]
},
"installs": [
{
"id": 12,
"receipt": {
"storeId": 12,
"sha256": "246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62",
"version": "1.1.6",
"compatShims": [
"music-control-system-dbus-env"
]
}
},
{
"id": 111,
"receipt": {
"storeId": 111,
"sha256": "3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9",
"version": "1.1.7",
"compatShims": [
"music-control-system-dbus-env"
]
}
}
],
"calls": [
{
"id": 12,
"method": "sp_list_media_players",
"success": true,
"playerCount": 0
},
{
"id": 12,
"method": "sp_get_media_player",
"response": {
"result": "org.mpris.MediaPlayer2.spotify",
"success": true
}
},
{
"id": 111,
"method": "sp_list_media_players",
"success": true,
"playerCount": 0
},
{
"id": 111,
"method": "sp_get_media_player",
"response": {
"result": "org.mpris.MediaPlayer2.spotify",
"success": true
}
}
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"artLinkAbsent": true
},
"after": {
"arch": "aarch64",
"uid": 1000,
"decky": "failed",
"panel": "inactive",
"artLinkPresent": false,
"imagesParentPresent": true,
"battery": [
100
]
},
"lockReleased": true
},
"isolatedRuntime": {
"version": "v3.3.0",
"architecture": "amd64",
"sha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"isolation": "Docker network none; no host audio/D-Bus/home/device mounts; dropped capabilities; private dbus-daemon and silent synthetic MPRIS service; archives and scripts read-only"
},
"backendFixtureChecks": [
{
"id": 12,
"version": "1.1.6",
"calls": [
{
"method": "sp_list_media_players",
"kwargs": {},
"response": {
"result": "org.mpris.MediaPlayer2.FrameySilent",
"success": true
}
},
{
"method": "sp_set_media_player",
"kwargs": {
"player": "org.mpris.MediaPlayer2.FrameySilent"
},
"response": {
"result": null,
"success": true
}
},
{
"method": "sp_get_media_player",
"kwargs": {},
"response": {
"result": "org.mpris.MediaPlayer2.FrameySilent",
"success": true
}
},
{
"method": "sp_identity",
"kwargs": {
"orgPath": "org.mpris.MediaPlayer2.FrameySilent"
},
"response": {
"result": "Player",
"success": true
}
},
{
"method": "get_meta_data",
"kwargs": {},
"response": {
"result": "trackid|/org/framey/TestTrack/1\nlength|180000000\ntitle|Silent Test Track\nartist|Fixture Artist\nalbum|Framey Test Album\n",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Paused",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "12000000",
"success": true
}
},
{
"method": "sp_can_seek",
"kwargs": {},
"response": {
"result": "true",
"success": true
}
},
{
"method": "sp_get_volume",
"kwargs": {},
"response": {
"result": "0.4",
"success": true
}
},
{
"method": "sp_test_volume_control",
"kwargs": {},
"response": {
"result": "true",
"success": true
}
},
{
"method": "sp_play",
"kwargs": {},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Playing",
"success": true
}
},
{
"method": "sp_pause",
"kwargs": {},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Paused",
"success": true
}
},
{
"method": "sp_next",
"kwargs": {},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_previous",
"kwargs": {},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_seek",
"kwargs": {
"amount": 2000000
},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "14000000",
"success": true
}
},
{
"method": "sp_set_position",
"kwargs": {
"position": 45000000,
"trackid": "/org/framey/TestTrack/1"
},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "45000000",
"success": true
}
},
{
"method": "sp_set_volume",
"kwargs": {
"volume": 0.25
},
"response": {
"result": "method return time=1793049278.417118 sender=:1.0 -> destination=:1.19 serial=20 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_get_volume",
"kwargs": {},
"response": {
"result": "0.25",
"success": true
}
}
],
"independentFixtureState": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 1,
"previous": 1,
"writes": [
[
"PlayPause"
],
[
"Pause"
],
[
"Next"
],
[
"Previous"
],
[
"Seek",
2000000
],
[
"SetPosition",
45000000
],
[
"Volume",
0.25
]
]
}
},
{
"id": 111,
"version": "1.1.7",
"calls": [
{
"method": "sp_list_media_players",
"kwargs": {},
"response": {
"result": "org.mpris.MediaPlayer2.FrameySilent",
"success": true
}
},
{
"method": "sp_set_media_player",
"kwargs": {
"player": "org.mpris.MediaPlayer2.FrameySilent"
},
"response": {
"result": null,
"success": true
}
},
{
"method": "sp_get_media_player",
"kwargs": {},
"response": {
"result": "org.mpris.MediaPlayer2.FrameySilent",
"success": true
}
},
{
"method": "sp_identity",
"kwargs": {
"orgPath": "org.mpris.MediaPlayer2.FrameySilent"
},
"response": {
"result": "Player",
"success": true
}
},
{
"method": "get_meta_data",
"kwargs": {},
"response": {
"result": "trackid|/org/framey/TestTrack/1\nlength|180000000\ntitle|Silent Test Track\nartist|Fixture Artist\nalbum|Framey Test Album",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Paused",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "12000000",
"success": true
}
},
{
"method": "sp_can_seek",
"kwargs": {},
"response": {
"result": "true",
"success": true
}
},
{
"method": "sp_get_volume",
"kwargs": {},
"response": {
"result": "0.4",
"success": true
}
},
{
"method": "sp_test_volume_control",
"kwargs": {},
"response": {
"result": "true",
"success": true
}
},
{
"method": "sp_play",
"kwargs": {},
"response": {
"result": "method return time=1793049473.137015 sender=:1.0 -> destination=:1.12 serial=13 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Playing",
"success": true
}
},
{
"method": "sp_pause",
"kwargs": {},
"response": {
"result": "method return time=1793049473.340963 sender=:1.0 -> destination=:1.14 serial=15 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Paused",
"success": true
}
},
{
"method": "sp_next",
"kwargs": {},
"response": {
"result": "method return time=1793049473.547273 sender=:1.0 -> destination=:1.16 serial=17 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_previous",
"kwargs": {},
"response": {
"result": "method return time=1793049473.649558 sender=:1.0 -> destination=:1.17 serial=18 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_seek",
"kwargs": {
"amount": 2000000
},
"response": {
"result": "method return time=1793049473.752790 sender=:1.0 -> destination=:1.18 serial=19 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "14000000",
"success": true
}
},
{
"method": "sp_set_position",
"kwargs": {
"position": 45000000,
"trackid": "/org/framey/TestTrack/1"
},
"response": {
"result": "method return time=1793049473.965468 sender=:1.0 -> destination=:1.20 serial=21 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "45000000",
"success": true
}
},
{
"method": "sp_set_volume",
"kwargs": {
"volume": 0.25
},
"response": {
"result": "method return time=1793049474.175137 sender=:1.0 -> destination=:1.22 serial=23 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_get_volume",
"kwargs": {},
"response": {
"result": "0.25",
"success": true
}
}
],
"independentFixtureState": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 1,
"previous": 1,
"writes": [
[
"Volume",
0.01
],
[
"Volume",
0.4
],
[
"PlayPause"
],
[
"Pause"
],
[
"Next"
],
[
"Previous"
],
[
"Seek",
2000000
],
[
"SetPosition",
45000000
],
[
"Volume",
0.25
]
]
}
}
],
"browserChecks": [
{
"id": 12,
"scope": "opaque local Chromium + real amd64 Decky3.3 + private silent MPRIS",
"checks": [
{
"rendered": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayback Volume\nPlayer\nInfo"
},
{
"clickedButton": 0,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 1,
"previous": 2
}
},
{
"clickedButton": 2,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"clickedButton": 1,
"independentReadback": {
"PlaybackStatus": "Playing",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"clickedButton": 1,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"slider": 0,
"value": 0.5,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"slider": 1,
"value": 60,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.6,
"next": 2,
"previous": 2
}
},
{
"infoRoute": "Music Control\nBack to plugin\nMusicControl\nInfoUsageTroubleshooting\nMusicControl allows you to control any media playback that is currently running that implements the MPRIS dbus interface.\nExamples of application that are supported:\nSpotify\nCider\nChrome / Firefox\nStrawberry\nOnly the flatpak versions of these applications have been tested, and not all of them might support the full feature set, so it is recommended to use the discover store to install the applications."
},
{
"reopened": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayer\nInfo",
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.6,
"next": 2,
"previous": 2
}
}
]
},
{
"id": 111,
"scope": "opaque local Chromium + real amd64 Decky3.3 + private silent MPRIS",
"checks": [
{
"rendered": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayback Volume\nPlayer\nInfo"
},
{
"clickedButton": 0,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 1,
"previous": 2
}
},
{
"clickedButton": 2,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"clickedButton": 1,
"independentReadback": {
"PlaybackStatus": "Playing",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"clickedButton": 1,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"slider": 0,
"value": 0.5,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"slider": 1,
"value": 60,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.6,
"next": 2,
"previous": 2
}
},
{
"infoRoute": "Music Control\nBack to plugin\nMusicControl\nInfoUsageTroubleshooting\nMusicControl allows you to control any media playback that is currently running that implements the MPRIS dbus interface.\nExamples of application that are supported:\nSpotify\nCider\nChrome / Firefox\nStrawberry\nOnly the flatpak versions of these applications have been tested, and not all of them might support the full feature set, so it is recommended to use the discover store to install the applications."
},
{
"reopened": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayer\nInfo",
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.6,
"next": 2,
"previous": 2
}
}
]
}
],
"limitations": [
"Real Frame had no running MPRIS players. No real media transport or volume writes ran.",
"The private fixture emits no audio and does not prove playback in a supported media app.",
"Album art, physical controller, VR keyboard and worn VR were not tested.",
"Both upstream backends truncate multiword Identity to its final word.",
"Music Control 1.1.7 automatically probes player volume by writing 0.01 and restoring it; this ran only against the private fixture.",
"Existing installed copies require remove/reinstall or an explicit managed update."
],
"validation": {
"pythonSuite": {
"run": 1639,
"skipped": 16,
"passed": true
},
"fakeFrame": {
"total": 116,
"results": {
"blocked": 55,
"installed-removed": 61
},
"backendExecution": false
},
"currentStore": {
"checkedAtUtc": "2026-10-26T21:17:39.609721+00:00",
"plugins": 116,
"allPinnedDigestsUnchanged": true
},
"independentCleanupAudit": "MPRIS_CLEANUP_AUDIT_PASS: shared lock, test services/runtime directories and owned cache link absent",
"pinnedHttpSshFlow": {
"command": "scripts/e2e.sh test_framey",
"passed": 2,
"seconds": 32.59,
"scope": "pinned official archive receipt assertions and sample install/update/remove safeguards"
}
}
}
@@ -0,0 +1,76 @@
# MusicControl / Music Control runtime checks, 26 October 2026
Both official entries remain **partial**. Their pure-Python backends need a
user-session MPRIS player and `dbus-send`, rather than Deck-specific hardware or
an x86 binary. [Results](framey-mpris-runtime-2026-10-26.json) pin both archives
and the actual Decky 3.3.0 executables used.
Two runtime failures were reproduced and repaired:
- The #335 `music-control-bound-methods` transformation was incorrect. Neither
manifest declares `api_version`, so Decky uses `module.Plugin` and explicitly
supplies the class as `self`. The earlier test used `module.Plugin()` and
therefore diagnosed a fixture error as an upstream bug. In actual Decky,
the transformation breaks discovery with `_get_dbus_env() missing ... self`
and transport with `_sp_player_dbus() missing ... parameters`. Managed installs
now retain the original class calls. This supersedes the earlier
[bound-method evidence](framey-music-control-bound-methods-2026-10-26.md).
- On Frame OS 0.5.6, MusicControl 1.1.6's `/bin/sh` subprocess inherits Decky's
PyInstaller library path. Its bundled `libtinfo.so.6` needs `GLIBC_2.42`, which
the Frame's libc lacks. A private diagnostic backend confirmed the same
read-only D-Bus command fails with a shell and succeeds without one. Both
exact-archive backends now restore `LD_LIBRARY_PATH_ORIG` (or an empty path)
only in their copied D-Bus subprocess environment. The Decky process keeps
its original environment. The receipt records `music-control-system-dbus-env`.
The patch is gated by store ID, SHA-256 and folder, and checks the expected
source before editing. Existing installed copies need **remove/reinstall** or
an explicit managed update; a desktop update alone does not rewrite them.
The store still shows partial, with the same reason as the docs table.
Actual Decky 3.3.0 amd64 ran each repaired production-managed install separately
in a container with no network, host audio, host D-Bus, home or device access.
A private `dbus-daemon` hosted a silent synthetic MPRIS service. Discovery,
selection, identity, metadata, status, progress, volume/CanSeek reads,
play/pause, next/previous, seek, track position and volume writes passed through
the real legacy websocket RPC envelope. The service's own state independently
confirmed each write. The shared plugin folder prevents co-installation; each
entry was tested in a fresh container.
Local headless Chromium loaded the shipped `headset.html` and exact official
frontends inside their ordinary opaque `allow-scripts` iframe. The relay used
actual Decky backends and allowed only the private fixture. Track/artist
rendering, previous/next, play/pause, seek to 90 seconds, volume to 60%, Info
navigation and close/reopen passed for both entries. Chromium retained its
normal process isolation and sandboxed-origin restrictions. No external media
or artwork request was permitted.
Under the shared `/tmp/frame-test.lock`, a removable private Decky 3.3.0 ARM64
user service loaded each corrected managed install on the real Frame.
Read-only `sp_list_media_players` returned an empty string successfully for
both, and `sp_get_media_player` returned their default Spotify bus name.
These final checks used Decky's direct loopback websocket through SSH; they
do not claim the deployed Framey HTTP/CEF route. Earlier CEF attempts encountered
transient reinjection failures. No player controls ran on the real Frame.
The private units, runtimes and owned Steam image-cache link were removed;
independent follow-up reads confirmed cleanup and lock release. Battery was
100 → 100. Existing managed Decky remained failed and the headset panel inactive,
as found. Power settings were not changed.
Remaining gaps: actual media-app playback/volume on ARM64, local-file album-art
routing, physical controls and worn VR. Both backends truncate multiword player
names to their last word. Music Control 1.1.7 automatically probes volume by
writing 0.01 and restoring it; this was exercised only on the silent fixture.
Validation: full Python suite **1,639 tests, 16 skipped**; focused managed-store
suite **23/23**; shared frontend action checks; all **116** store digests unchanged.
The complete fake-Frame audit passed: **61 installed/removed, 55 rejected**
for documented safeguards; official backends are stubbed in that harness. The
regression test uses Decky's actual legacy class convention for both entries,
checks original/absent library-path restoration without changing the parent
environment, and rejects transferring the patch to a different archive digest.
The pinned HTTP/SSH flow also passed `scripts/e2e.sh test_framey`: **2/2**
tests in 32.590 seconds. Its receipt assertion covers the replacement shim for
both Music Control entries; the old assertion caused the first hosted e2e
failure and left a plugin installed, cascading into the following test.
@@ -0,0 +1,15 @@
# Superseded: Music Control bound-method fixture
The #335 result was incorrect. Its test instantiated `module.Plugin()`, while
actual Decky 3.3.0 uses the class `module.Plugin` for this legacy manifest and
supplies the class as `self`. The published backend's explicit-self calls are
correct in that runtime. Removing those arguments breaks actual Decky discovery
and transport.
The follow-up [real runtime checks](framey-mpris-runtime-2026-10-26.md) reproduce
the regression, remove the incorrect transformation and replace the test with
the actual legacy calling convention. They also identify and repair a separate
Frame OS system-command library-path failure. Existing copies installed with
`music-control-bound-methods` must be removed/reinstalled or explicitly updated.
Both official Music Control entries remain partial; the follow-up describes
exactly what passed and what remains unverified.
@@ -0,0 +1,44 @@
# Native plugin packaging blockers, 26 October 2026
This is a static, digest-scoped follow-up. Neither plugin was installed or
run on the Frame. Their compatibility statuses remain **broken**.
## Decky Cloud Save 1.4.2, store ID 49
The official ZIP SHA-256 is
`b311d967ddf1b0f0046d36929645855891a40173e3a93f6f0cd8b1576b04b411`.
It was downloaded and hash-checked without executing any member. Of its 27
members, `decky-cloud-save/bin/rclone` is a regular executable of **60,776,600
bytes** with ELF machine **62 (x86_64)**. The installer permits at most 30 MiB
per member, so this exact ZIP fails before native architecture validation.
Raising that limit alone would still leave an x86_64 executable on the ARM64
Frame. No cloud credentials, saves, reads or writes were exercised.
## Emuchievements 2.0.5, store ID 28
The official ZIP SHA-256 is
`98dcb443e81b9f9011cfe77c69eb659869c8e2df2df8b0a443df66cbfc2f5a89`.
The [earlier helper check](framey-emuchievements-arm-2026-10-26.md) proved
one synthetic ROM hash on ARM64, without installing the plugin. The upstream
source commit `8567e94befffc943434b2adb842487274815f8bb` and its pinned
libarchive, libchdr and rcheevos submodules were checked out separately for
this audit. The source is GPL-3.0. An isolated Debian ARM64 cross-build made a
2.6 MB helper (`c8794558a0d75294930f969a426e7d2ac13a27e3c1ff9bd8aae093e31596b9bd`),
but it was **not shipped or run on ROM archives**. A complete source bundle
without Git metadata compressed to about 8 MB; source and license distribution
would be part of any release-ready port.
The proposed source has an additional blocker. `backend/src/util.cpp` builds
an extraction destination by appending each raw archive member pathname to
an owned `/tmp/tmpdir.*` prefix, then passes it to libarchive's disk writer.
It neither rejects `..`/absolute paths nor enables libarchive's secure path
flags. `backend/src/main.cpp` later removes the extracted file's parent path.
With an untrusted archive, those paths may leave the intended temporary
directory. The plugin's Python backend also invokes `bin/hash` through a
shell with a path argument. A port needs bounded path validation, secure
extraction/cleanup, safe subprocess arguments, format coverage and actual
Decky/backend tests before its official ZIP can be transformed or installed.
The official archive still contains an x86_64 helper. RetroAchievements
login, real ROM scanning, a packaged ARM64 backend, wearer-visible VR and
physical controller use remain unverified.
@@ -0,0 +1,9 @@
# Shotty Python compatibility, 26 October 2026
The exact official Shotty 0.1.0 archive (store ID 53, SHA-256 `ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe`) imports `click.get_app_dir` at startup but never calls it. The archive includes an empty `py_modules` directory and no `click` package. A prior actual Decky 3.3.0 amd64 backend check stopped at `ModuleNotFoundError: click`; its local frontend separately starts with Framey's read-only Steam library snapshot.
Frame Control now writes a small Linux `click.get_app_dir` compatibility module into **only this exact digest's** managed `Shotty/py_modules` folder during staging. Decky Loader adds each plugin's `py_modules` folder to that plugin's Python search path. The staging transaction records `shotty-click-app-dir` in the receipt and removes the whole staged folder on failure. A different Shotty digest receives no module. The official ZIP bytes and their verified hash remain unchanged.
An isolated transaction test installed the pinned archive, imported its unchanged `main.py` using the staged module, and called its `aggregate_all` method with a synthetic Steam screenshot under a temporary `HOME`. It copied one fixture image into that temporary home's `Pictures/Screenshots/Local Test Game` folder; reopening the copied bytes matched the source. Removal deleted the managed folder. A second synthetic Shotty archive with another digest installed without the module. The test neither started the plugin's continuous screenshot rescuer nor touched a person's Steam library or screenshots.
This supports **partial** compatibility for the local frontend and a narrow backend copy path. Actual Decky 3.3.0 ARM64 plugin startup, gamescope screenshot rescue, real screenshot aggregation, VR panel visibility, and physical controller use remain unverified. No headset lock was acquired or headset state changed for this check.
@@ -0,0 +1,119 @@
{
"timeUtc": "2026-10-26T23:23:16.239735+00:00",
"plugin": {
"id": 96,
"version": "1.0.0",
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312"
},
"scope": "Silent private timer settings/events only; local Chromium with actual Frame ARM64 backend. No sound, physical power actions or worn VR.",
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"realFrameLifecycle": {
"lifecycle": {
"freshCancel": true,
"replacementSuppressesOldCompletion": true,
"completionClearsEnd": true,
"activeRestartPreservesDeadlineAndRecents": true,
"canceledRestartIdle": true,
"expiredRestartIdle": true
},
"actualDecky330": true
},
"browser": {
"uiBegin300": true,
"uiCancel": true,
"liveCountdown": true,
"completionToast": true,
"subtleMode": true
},
"iframeSandbox": "allow-scripts allow-popups; no allow-same-origin",
"isolatedNativeAmd64": [
{
"discovered": [
{
"name": "Simple Timer",
"version": "1.0.0",
"load_type": 1,
"disabled": false
}
],
"lifecycle": {
"freshCancel": true,
"replacementSuppressesOldCompletion": true,
"completionClearsEnd": true,
"lateExpiryEmitsWithoutSelfCancel": true,
"activeRestartPreservesDeadlineAndRecents": true,
"canceledRestartIdle": true,
"expiredRestartIdle": true
},
"result": "discovery, cancel, replace, completion and persistent restart passed"
},
{
"discovered": [
{
"name": "Simple Timer",
"version": "1.0.0",
"load_type": 1,
"disabled": false
}
],
"lifecycle": {
"freshCancelError": {
"type": -1,
"id": 2,
"error": {
"name": "Exception",
"message": "'Plugin' object has no attribute 'timer_task'",
"traceback": "Traceback (most recent call last):\n File \"decky_loader/wsrouter.py\", line 70, in _call_route\n File \"decky_loader/loader.py\", line 232, in handle_plugin_method_call\n File \"decky_loader/loader.py\", line 229, in handle_plugin_method_call\n File \"decky_loader/plugin/plugin.py\", line 137, in execute_method\n File \"decky_loader/plugin/messages.py\", line 44, in wait_for_result\nException: 'Plugin' object has no attribute 'timer_task'\n"
}
},
"cancelRetainsEnd": true,
"replacedTimerStillFires": true,
"expiredRestartError": true
},
"result": "upstream timer lifecycle regressions reproduced"
}
],
"fakeFrame": "Pinned official archive installed and removed through real HTTP/SSH harness; backend execution stubbed.",
"before": {
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"battery": [
99
]
},
"after": {
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"battery": [
99
]
},
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"sharedLockAbsent": true,
"service": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
"testProcesses": []
},
"lockReleased": true,
"limits": [
"Sound and suspend unsupported",
"No alert delivery while Framey panel is closed",
"Worn VR, controller and keyboard unverified",
"UI recents after asynchronous reopen not asserted; persisted recents verified through actual backend restart"
],
"localValidation": {
"pythonTestsRun": 1648,
"pythonSkipped": 16,
"pythonResult": "OK",
"actualDeckyNativeAmd64Cases": 6,
"fakeFramePinnedInstallRemove": "PASS",
"officialStoreEntriesRefreshed": 116,
"officialStoreDigestsChanged": 0
}
}
@@ -0,0 +1,58 @@
# Simple Timer lifecycle on Frame
The exact official Simple Timer 1.0.0 archive (store ID 96, SHA-256
`973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312`)
now qualifies as **works for silent in-panel session timers** after a managed
update/reinstall. The upstream BSD license and API1 manifest remain unchanged.
[Redacted observations](framey-simple-timer-2026-10-26.json) accompany this report.
## Reproduced failures and repair
The unmodified archive, discovered and called by the actual published Decky
3.3.0 loader, fails cancel before its first timer and expired-deadline startup
with an annotation-only `timer_task` attribute. Cancel leaves `timer_end` saved,
so canceled timers resume after restart. Starting a replacement leaves the first
task alive; it still sends its own completion event.
The managed installer repairs only this exact ID/digest/folder. Lifecycle state
is initialized before startup cancellation. A lock and cancellation/join give one
task ownership of the timer. Explicit cancel and completion clear the persisted
deadline. Backend unload stops the task while preserving an active deadline;
startup resumes that exact deadline without duplicating recent timers. Late
expiry emits its silent notice without canceling itself midway. Older installed
copies remain partial until their receipt includes `simple-timer-lifecycle`.
## Verification
- **Actual published Decky 3.3.0, isolated native amd64:** production managed
install, API1 discovery and public instance RPC/events; fresh cancel,
replacement suppression, one completion, clearing persisted end, active
restart preserving deadline/recents, canceled restart and expired restart.
A separate unmodified-archive case must reproduce the original failures.
The same gate runs on native ARM64 CI; this report does not preclaim its result.
- **Real Frame ARM64, Frame OS 0.5.6:** the same lifecycle assertions through
Steam CEF's existing `DeckyBackend`, with a bounded private Decky 3.3.0 user
service and private settings. No plugin class was manually instantiated.
- **Local Chromium:** actual headset host and official frontend in a sandbox
without `allow-same-origin`; method/event bridge relayed to that real ARM64
backend. Begin created a 300-second timer; Cancel returned to idle. A safe
five-second backend timer updated the panel and produced the visible silent
completion toast. Backend tests verify persisted recents across restart;
asynchronous UI recents after reopen were not asserted.
- **Fake Frame:** exact pinned archive installed and removed through the real
HTTP/SSH store harness; execution is stubbed, so this verifies packaging only.
Only private settings and silent timers changed. The shared `/tmp/frame-test.lock`
was held for execution and then released. Successful run battery was 99 → 99;
normal Decky remained failed and the headset panel remained inactive as found.
An independent audit confirmed the private base/unit/archive absent, MainPID 0,
unit not found and no test client processes. Job-owned CEF event listeners and
private Chromium were removed. Two earlier test-client attempts were cleaned up:
one competed with Steam's single Decky websocket; the other lacked a bounded
quiet-event wait. Battery across those attempts was 100 → 99. These were test
adapter failures; the final run used CEF and passed.
Sound and suspend are unsupported. Alerts require the plugin panel to remain
open. Worn VR, physical controller/keyboard and sleep/wake notification delivery
remain unverified. A resumed timer is persisted backend behavior, not a promise
of notification delivery while the panel is closed.
+308
View File
@@ -0,0 +1,308 @@
{
"date": "2026-10-27",
"scope": "Shared standalone Toggle value prop; no plugin-wide status promotion",
"affected": [
{
"id": 7,
"name": "CSS Loader",
"version": "2.1.3",
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
"status": "partial"
},
{
"id": 13,
"name": "Pause Games",
"version": "1.0.3",
"sha256": "4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62",
"status": "partial"
},
{
"id": 32,
"name": "AutoFlatpaks",
"version": "1.6.8",
"sha256": "344fce82cfb4800caccb4d6c0ec46e1ad6c9594a622d5baa0bb31286baf2d1a5",
"status": "broken"
},
{
"id": 60,
"name": "DeckyFileServer",
"version": "1.2.0",
"sha256": "7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0",
"status": "broken"
},
{
"id": 64,
"name": "Syncthing",
"version": "0.2.7",
"sha256": "27e47379c280c92253d5533435f57c33b332b215f866d2ac4493daf18ec0d1c4",
"status": "broken"
},
{
"id": 113,
"name": "Decky LSFG-VK",
"version": "0.12.2",
"sha256": "ec59fc8aceb4e1abe2066b3a0707ed6313cabc4421b7c17765397903e2f8fd26",
"status": "partial"
},
{
"id": 116,
"name": "Muon",
"version": "0.2.1",
"sha256": "a7659c79b74110476cc4458cb10530cdcb2539fa11bc8c759038a080eaa81049",
"status": "broken"
},
{
"id": 126,
"name": "Launch Options",
"version": "1.9.2",
"sha256": "381ac8b915402176cf768d1ccc1bc29a6b82ea9255675f0a30c0c81ea5427225",
"status": "broken"
},
{
"id": 129,
"name": "ShadowDeck",
"version": "1.1.11",
"sha256": "2b97b769a49ac2a45f546090fc96d07e09e146020104d708a0efdad990687f8b",
"status": "broken"
}
],
"browser": {
"passed": true,
"opaqueOrigin": "null",
"checks": [
"saved true renders checked",
"standalone Toggle has no empty field wrapper",
"native click writes boolean false and rerenders",
"second click writes boolean true",
"external false update renders unchecked",
"external true update renders checked",
"disabled Toggle stays inert",
"ToggleField checked contract preserved"
]
},
"publishedDeckyRuntime": {
"version": "v3.3.0",
"architecture": "x86_64",
"loaderSha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"totalProbes": 8,
"pauseGames": {
"id": 13,
"name": "Pause Games",
"version": "1.0.3",
"sha256": "4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62",
"shim": null,
"calls": [
{
"method": "load_settings",
"result": {}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true
}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": false
}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true
}
},
{
"method": "add_no_auto_pause_set",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"noAutoPauseSet": [
424242
]
}
},
{
"method": "remove_no_auto_pause_set",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"noAutoPauseSet": []
}
}
],
"discovered": [
{
"name": "Pause Games",
"version": "1.0.3",
"load_type": 1,
"disabled": false
}
],
"result": "discovery and Toggle-backed private settings persistence passed; no process actions"
}
},
"realFrame": {
"timeUtc": "2026-10-27T01:10:45.197435+00:00",
"architecture": "aarch64",
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"backend": {
"passed": true,
"discovered": [
{
"name": "Pause Games",
"version": "1.0.3",
"load_type": 1,
"disabled": false
}
],
"privateSettingsPersisted": true,
"calls": [
{
"method": "load_settings",
"result": {}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true
}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": false
}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"pauseBeforeSuspend": false,
"overlayPause": false,
"noAutoPauseSet": []
}
},
{
"method": "add_no_auto_pause_set",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"pauseBeforeSuspend": false,
"overlayPause": false,
"noAutoPauseSet": [
424242
]
}
},
{
"method": "remove_no_auto_pause_set",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"pauseBeforeSuspend": false,
"overlayPause": false,
"noAutoPauseSet": []
}
}
]
},
"batteryBefore": [
100
],
"batteryAfter": [
100
],
"originalServiceStatesPreserved": true,
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"archiveAbsent": true
},
"lockReleased": true,
"independentCleanupAudit": {
"passed": true,
"privateBasesUnitsUploadsAbsent": true,
"privateUnitsNotLoaded": true
}
},
"fakeFrame": {
"blocked": 55,
"installed-removed": 61
},
"python": {
"ran": 1659,
"skipped": 16,
"result": "OK"
},
"unverified": [
"Other importer plugin backends",
"Process pause/resume",
"Automatic pausing/suspend",
"Physical controller/keyboard",
"Worn VR"
],
"affectedSnapshot": "Initial independent verification before merging main b963937; DeckyFileServer is partial in the merged catalog.",
"mergeValidation": {
"mainCommit": "b963937",
"catalog": {
"works": 6,
"partial": 51,
"broken": 48,
"na": 11
},
"python": {
"ran": 1663,
"skipped": 16,
"result": "OK"
},
"publishedDeckyRuntime": {
"version": "v3.3.0",
"architecture": "x86_64",
"probes": 10,
"result": "All passed, including Pause Games and DeckyFileServer positive/negative cases"
},
"hardwareRerun": false
}
}
+80
View File
@@ -0,0 +1,80 @@
# Shared standalone Toggle contract — 2026-10-27
Framey previously exported `DFL.Toggle` as `ToggleField`. Official standalone
toggles pass `value`, while ToggleField takes `checked`. A saved true setting
therefore appeared unchecked, and clicking it could send true again instead of
turning it off. Toggle now renders a standalone controlled checkbox from
`value`; ToggleField retains its `checked` contract. Both emit booleans and keep
disabled controls inert. No official archive or manifest is rewritten.
Nine exact catalog archives use this standalone contract: CSS Loader (7),
Pause Games (13), AutoFlatpaks (32), DeckyFileServer (60), Syncthing (64),
Decky LSFG-VK (113), Muon (116), Launch Options (126), and ShadowDeck (129).
These are affected API consumers, **not nine verified working plugins**.
Their existing root, native, Steam UI and runtime blockers still apply.
The catalog records this repair without promoting any status.
## Verification
- The Node regression fails against the old alias: true renders false. It
passes with the fix and checks true/false callbacks, disabled behavior and
the unchanged ToggleField contract.
- The pinned, unmodified Pause Games 1.0.3 frontend component renders both
persisted exclusion-list states and dispatches only its expected add/remove
settings callbacks. This VM fixture does not run effects or process actions.
- Local Chromium, React 18.3.1 and the production host in an opaque sandboxed
iframe pass eight DOM checks: saved true, standalone shape, false/true native
clicks and rerenders, external false/true updates, disabled input, and
ToggleField. This is a control fixture, not nine end-to-end plugin tests.
- Actual published Decky 3.3.0 on native amd64 discovers the exact Pause Games
archive and uses public API1 instance RPC to save/read true/false settings,
add/remove a synthetic exclusion and independently read its private settings
file. This is a permanent runtime probe alongside existing backend shims;
all eight probes, including historical negative cases, pass. Hosted CI also
runs this gate on native ARM64; local ARM64 evidence below is from the Frame.
- Under the shared headset lock, a bounded, removable private Decky 3.3.0 ARM64
user service repeats discovery and those safe settings operations through
the normal Steam CEF DeckyBackend bridge. The private file readback matches.
The harness never requests pause/resume/kill or suspend operations. Steam's
Decky frontend also populated its normal default setting keys during this
run; no running-game behavior is claimed.
- Fake-Frame audit: all 116 archives attempted, 61 installed/removed and 55
blocked by existing policy. Full Python suite: 1,659 tests, 16 skipped, OK.
The exact Pause Games archive SHA-256 is
`4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62`.
The official store refresh still contains 116 entries; all pinned IDs, versions
and digests are unchanged.
[Structured results](framey-toggle-2026-10-27.json) include scoped readbacks and
the nine version/digest pairs.
## Probe retries and cleanup
The first hardware adapter connected directly to Decky's single WebSocket and
reused a request ID also used by Steam CEF; it received an unrelated cached
reply. A second adapter retained a CEF connection across Decky's startup reload
and disconnected. Neither is a successful plugin verification. Both private
services and files were removed. The successful adapter waits for startup and
reattaches CEF during discovery, then serializes settings calls through the
normal DeckyBackend bridge.
All private services, bases and uploaded archives were removed, and the shared
lock was released. An independent locked audit confirms no matching unit is
loaded and no private base, unit file or upload remains. Original managed Decky
and panel states were preserved. Battery stayed 100 → 100 during the successful
run; no power setting changed.
No plugin gains a works claim. Real process control, other importer backends,
physical input and worn VR remain unverified. The original independent branch
had **6 works / 50 partial / 49 broken / 11 N/A**. After merging main's landed
DeckyFileServer ARM64 support, current totals are **6 works / 51 partial /
48 broken / 11 N/A**. Its managed-native receipt guard and both runtime probes
are preserved; its former Toggle display limitation is removed from the current
catalog reason. The initial verification counts above describe the original
branch; combined validation is recorded below.
Combined merge validation against main `b963937`: full Python suite passes
1,663 tests with 16 skips; all ten actual published Decky 3.3.0 native amd64
probes pass, including Pause Games settings and DeckyFileServer positive/negative
cases. Generated documentation matches the merged catalog. No hardware rerun
was needed for these fixture/catalog conflicts.
+58
View File
@@ -0,0 +1,58 @@
# v0.4.46 tagged release, 26 October 2026
[PR #326](https://github.com/saphid/frame-control/pull/326) passed every required
check on exact head `fc978a2afdfa47e60477fcb0bb573e7b6181fa89` and merged
at 12:31:06 UTC. Main became `f84a4da9b1ae938d15b658af17903c0e10a56e94`.
All seven jobs in the [post-merge main run](https://github.com/saphid/frame-control/actions/runs/40323149126)
passed, including iOS and fake-Frame e2e. The app and lockfile both say
`0.4.46`. Annotated tag `v0.4.46` dereferences to that main commit. The
[tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40324176903)
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs.
## Frame and package gates
The [candidate evidence](v0446-candidate-2026-10-26.md) records a locked
exact-version real Frame smoke: **12 passed / 1 optional x86 launch skipped /
0 failed**, with owned cleanup and lock release. The x86 launch needs Steam
Linux Runtime 4, which was absent. A separate 12:25 UTC read-only check acquired
and released its own `/tmp/frame-test.lock`; SteamVR activity was **3**
(standby), proximity raw **3.0**, and battery **100%**. No wearer was confirmed,
so no wake, controller input or interactive worn-check script ran.
The draft contained exactly ten installers plus `SHA256SUMS`, each with a
GitHub SHA-256 digest. A fresh downloaded checksum file matched the ten digest
lines regenerated from GitHub metadata byte for byte; its digest was
`365e7f6d9549ff993162964920a6560480069ed04ce1eca5551696ce7bbc4ae8`.
`scripts/publish-release.sh --dry-run v0.4.46` passed the exact tag, installer,
checksum and update-manifest gates after release notes were attached.
Independent downloads passed these checks:
| Package | Result |
| --- | --- |
| Linux x86_64 AppImage | SHA-256 `65f66f1bbb5908d49c179307950f93b643c2b8da8ac4da48a778c5c9bac5d41b`; launched under isolated Xvfb, bundled server returned HTTP 200 with `FrameControl/1`, then stopped. |
| Mac arm64 ZIP | SHA-256 `9af88f75db7936472590022ca03a03da9de4f91de7b35c5d4d130b6dc78ab4b5`; ZIP CRC valid, bundle version 0.4.46, main Mach-O CPU arm64. |
| Mac x64 ZIP | SHA-256 `28d9ac00c9c918a7cb9f2caac54505faa8a953285bb40f82e02d9233b775d37a`; ZIP CRC valid, bundle version 0.4.46, main Mach-O CPU x86_64. |
| Windows x64 Setup | SHA-256 `68e303dcabb2c82ba5b9a5a58bace2ca8154f9645f1da2214ca4c6dbb05f157d`; PE/Nullsoft installer format. Tagged Windows CI checked packaged files and Python libraries. |
| Linux arm64 DEB | SHA-256 `da9b00bd44ca7a91c542df26d8c5711ad564d618add34f89aef946785c0bd538`; package version 0.4.46 and architecture arm64. |
Tagged Mac and Windows packages were not launched on physical machines during
this release gate. Earlier physical Intel Mac acceptance covers older bytes.
## Publication and limits
`scripts/publish-release.sh v0.4.46` published
[v0.4.46](https://github.com/saphid/frame-control/releases/tag/v0.4.46) at
12:48:03 UTC. GitHub's latest-release API returned the expected tag,
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS` and
`update.json`. A fresh public latest-download of `update.json` reported
version 0.4.46, the stable release page, ten hashed installers and the
worn-VR limit in its notes.
The exact-version official catalog is **4 works / 50 partial / 51 broken /
11 N/A** out of 116. Free Loader is partial for package, local frontend and
isolated settings/empty-deals evidence. Its actual ARM64 Decky load, live
feeds and notifications remain unverified. The real Frame was in standby;
official-plugin wearer visibility, physical controller usability, keyboard
and optical comfort remain unverified. The hourly shared-lock worn check stays
enabled.
@@ -0,0 +1,46 @@
# v0.4.47 candidate gate, 26 October 2026
This candidate contains Game Theme Music's locally rendered Settings view
(#330) and a digest-scoped Python import module for the official Shotty 0.1.0
archive (#331). The 116-plugin table is **4 works / 52 partial / 49 broken /
11 N/A**. Game Theme Music is partial for its local Settings view; its
backend, network and playback were denied in that audit. Shotty is partial
for its local frontend and synthetic screenshot copy in isolated Python;
actual ARM64 Decky startup and live screenshot rescue are unverified.
## Locked Frame candidate smoke
At 14:18 UTC, source commit `1f5b37a7552880b40868617ef22398fcd39b3f5d`
ran `scripts/frame-smoke.sh` on the real Frame. The script acquired its unique
owner in `/tmp/frame-test.lock` before reading status. Locked preflight found
SteamVR activity **3** (standby), proximity raw **3.0625**, and battery
**100%** on Frame OS 0.5.6 build `20261022.6153909`. Owned ARM64 and
Windows/Proton test titles installed, launched, and were removed. The
optional x86_64 title installed and was removed; launch was **skipped**
because Steam Linux Runtime 4 was absent. The result was **12 passed /
1 skipped / 0 failed**. Cleanup passed and the command exited 0, including
release of its owned lock. Detailed results stay private in
`tests/smoke/results/2026-10-26T141812Z-20261022.6153909.json`.
No wearer was confirmed. The interactive worn-check script was not run;
the smoke did not wake the headset or test the Framey panel. Game Theme
Music playback, Shotty live rescue, wearer-visible official-plugin UI,
physical controllers, keyboard and optical comfort remain unverified.
The hourly shared-lock worn check remains enabled.
## Publication boundary
At 14:23 UTC, the exact version-bumped candidate commit
`e07fa155928d1609304e317d15e0c4ef4a977579` ran the same token-locked
`scripts/frame-smoke.sh`.
Preflight again found activity **3** (standby), proximity raw **3.0625** and
battery **100%**. The result was **12 passed / 1 optional x86 launch skipped /
0 failed**. Owned cleanup passed and the command exited 0 after releasing its
lock. Its private result is
`tests/smoke/results/2026-10-26T142321Z-20261022.6153909.json`.
This paragraph is the only change after that smoke.
PR #331's combined-head CI, green release-PR and post-merge CI, tagged
installer builds, all ten installer digests with `SHA256SUMS`, independent
package checks, publisher dry run, and public `update.json` readback remain
required.
+62
View File
@@ -0,0 +1,62 @@
# v0.4.47 tagged release, 26 October 2026
The release PR [#332](https://github.com/saphid/frame-control/pull/332)
passed its exact-head checks and all four installer preview jobs, including
Intel Mac. It merged at main commit
`035c59ee1134aa158c3cd818e8dd35f822cbfc4b`. The seven-job post-merge
main [checks run](https://github.com/saphid/frame-control/actions/runs/40342375912)
passed, including iOS and fake-Frame e2e. Annotated tag `v0.4.47`
dereferences to that main commit. The tagged
[release workflow](https://github.com/saphid/frame-control/actions/runs/40344549980)
passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac, and checksum
jobs.
## Frame and package gates
The [candidate evidence](v0447-candidate-2026-10-26.md) records a locked
exact-version real Frame smoke: **12 passed / one optional x86 launch skipped /
zero failed**, with owned cleanup and lock release. The x86 launch needs Steam
Linux Runtime 4, which was absent. The headset was in standby and no wearer was
confirmed, so the interactive worn-check script was not run. No panel or
physical-controller claim follows from the smoke.
The draft contained exactly ten installers and `SHA256SUMS`, each with a
GitHub SHA-256 digest. An independently downloaded `SHA256SUMS` matched a
fresh ten-line list regenerated from GitHub asset metadata byte for byte;
its SHA-256 was
`ab1a95e5d51333dfc962723a21b044da935e07c24cedbd430222dc482d725508`.
`scripts/publish-release.sh --dry-run v0.4.47` passed the tag, installer,
checksum, and update-manifest gates after release notes were attached.
Independent downloads passed these checks:
| Package | Result |
| --- | --- |
| Linux x86_64 AppImage | SHA-256 `e7c83cf9177d11de724caf40a829b5c97ac3d2f0a04df24bcb1e4107310f1f49`; launched under isolated Xvfb on lxso1, bundled server returned HTTP 200 with `FrameControl/1`, then stopped. |
| Mac arm64 ZIP | SHA-256 `10092131b7fd43950e0dc436a51da1e41b42803d532a89363e2b71880b92b212`; ZIP CRC valid, bundle version 0.4.47, main Mach-O CPU arm64. |
| Mac x64 ZIP | SHA-256 `24531b088e85d2e7e9174289b0b9532c78fb85e7a29775c24369c00bc61e5408`; ZIP CRC valid, bundle version 0.4.47, main Mach-O CPU x86_64. |
| Windows x64 Setup | SHA-256 `a697a7e9042eb0d9545d99639e39ff53fbf993491c0728f59f038f21175b77cc`; PE/Nullsoft installer format. Tagged Windows CI checked packaged files and Python libraries. |
| Linux arm64 DEB | SHA-256 `e0be95ffc714e71487889a7da4886676b75c993f86bb674d180dc3ea4cd4b3b6`; package version 0.4.47 and architecture arm64. |
The tagged Mac and Windows packages were not launched on physical machines
in this release gate. Earlier physical Intel Mac acceptance covers older
bytes.
## Publication and limits
`scripts/publish-release.sh v0.4.47` published
[v0.4.47](https://github.com/saphid/frame-control/releases/tag/v0.4.47) at
15:07:28 UTC. GitHub's latest-release API returned that tag with
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS`, and
`update.json`. A fresh public latest-download of `update.json` reported
version 0.4.47, the stable release page, ten hashed installers, and the
worn-VR limit in its notes.
The exact-version official catalog is **4 works / 52 partial / 49 broken /
11 N/A** out of 116. Game Theme Music has a local Settings view; its music
playback and live backend are unverified. Shotty's backend imported and
copied a synthetic screenshot in isolated Python; live ARM64 Decky loading
and screenshot rescue are unverified. The real Frame was in standby;
official-plugin wearer visibility, physical controller usability, VR
keyboard, and optical comfort remain unverified. The hourly shared-lock
worn check remains enabled.
@@ -0,0 +1,50 @@
# v0.4.48 candidate gate, 26 October 2026
This candidate contains the exact-archive Music Control 1.1.7 Python call
repair from [#335](https://github.com/saphid/frame-control/pull/335). All
**11** hosted checks passed on its head
`ff465accc508e40158c590eebffd4aec924503ac`, including fake-Frame e2e,
Windows, macOS, Linux, ARM64 host libraries and iOS. The catalog remains
**4 works / 52 partial / 49 broken / 11 N/A** out of 116. Music Control is
partial: an isolated D-Bus command fixture proves dispatch after the repair;
a live player and actual Decky ARM64 backend were not tested.
## Locked real Frame smoke
At 15:36 UTC, source head `ff465accc508e40158c590eebffd4aec924503ac`
ran `scripts/frame-smoke.sh` on SteamOS 0.5.6 build `20261022.6153909`.
The runner acquired its own unique `/tmp/frame-test.lock` owner before
reading device status. Preflight found SteamVR activity **3** (standby),
proximity raw **3.0625**, and battery **99%**. Owned ARM64 and Windows/Proton
titles installed, launched and were removed. The optional x86_64 title
installed and was removed; its launch was **skipped** because Steam Linux
Runtime 4 was absent. The result was **12 passed / 1 skipped / 0 failed**.
Owned cleanup passed and the command exited 0, including lock release.
A separate fresh locked read found battery **100%**, activity **3** and no
`fc_smoke_*` title directories; it released only its own lock. The private
runner result is `2026-10-26T153655Z-20261022.6153909.json`.
At 15:42 UTC, exact version-bumped commit
`a31b4a426cf4204e45fe218e204a26328aa0b1c9` ran the same token-locked
smoke. Preflight found activity **3**, proximity raw **3.0625** and battery
**100%**. It passed **12 / 1 optional x86 launch skipped / 0 failed**.
Cleanup passed and the command exited 0 after releasing its lock. The
private runner result is `2026-10-26T154258Z-20261022.6153909.json`.
A fresh separately locked read found battery **100%**, activity **3** and
no test-title directories, then released that lock. Merging current main
into the candidate created commit
`a8bf01d660cd9abb8d2a724626a06a0e7e6c2a6a`; its tree before this
evidence document was byte-identical to the tested version-bumped commit
(`d45c2465ddfcf877fe2c8bc30420d4719da5035e`).
No wearer was confirmed. Neither smoke woke the headset or ran the
interactive worn-check script. Official-plugin wearer visibility, physical
controllers, VR keyboard and optical comfort remain unverified. The hourly
shared-lock worn check remains enabled.
## Remaining publication gates
The release PR's exact-head CI and installer previews, post-merge main CI,
tagged builds with all ten installers and `SHA256SUMS`, independent package
checks, publisher dry run and public `update.json` readback are required
before publication.
+85
View File
@@ -0,0 +1,85 @@
# v0.4.48 release verification and withdrawal, 26 October 2026
The annotated [`v0.4.48` tag](https://github.com/saphid/frame-control/releases/tag/v0.4.48)
dereferences to main commit `5438e1d9d05ca472f200dcde50799c8ae882fafb`,
which contains the version bump and [candidate evidence](v0448-candidate-2026-10-26.md).
The exact-commit [main checks run](https://github.com/saphid/frame-control/actions/runs/40356645911)
finished successfully: all seven jobs passed, including e2e, iOS, Windows,
macOS and Linux tests. Its first two attempts had an iOS job stuck in the
runner queue and were cancelled; attempt 3 ran that job and ended with all
seven required checks successful. The later documentation-only main commit
`1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6` also passed all seven
[main checks](https://github.com/saphid/frame-control/actions/runs/40406851705).
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40406781383)
finished successfully. Its draft, Linux, Windows, Apple Silicon Mac, Intel
Mac and checksum jobs all passed; no tagged installer build failed or was
cancelled. Tagged CI launched the Linux x86_64 AppImage on Xvfb, checked both
Mac bundles against their runner architecture, installed the Windows Setup
and compared its installed resources with the unpacked build, and tested its
packaged Python and native PC libraries.
An earlier [release-PR preview run](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/1)
was not a passing gate: its Apple Silicon Mac build was cancelled and its
checksum job was skipped. [Attempt 2](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/2)
completed all four preview installer builds successfully. Publication used the
separate exact-tag workflow above, whose six jobs all succeeded.
The draft had exactly ten installers and `SHA256SUMS`, each with a GitHub
SHA-256 digest. An independently downloaded `SHA256SUMS` matched a fresh
ten-line list generated from GitHub's asset metadata byte for byte. Both
its downloaded SHA-256 and GitHub's own asset digest were
`8cf7d8808f0b0b9da08e2590461e83ef557412a0ce2cc780c8e1d02e34ce1a60`.
Independent downloads on lxso1 matched `SHA256SUMS` for these packages:
| Package | SHA-256 and independent check |
| --- | --- |
| Linux x86_64 AppImage | `7c0cbef7c0330c48ecb69f6fcee027a8b45fe84b0458985db31d16b09fc45cbe`; isolated Xvfb launch served HTTP 200 with `FrameControl/1`, then its server stopped. |
| Linux arm64 DEB | `d3c2cdc4310fc3fed11460ad970a3ff6da8b80a6ec566acf1bcaa5a9570efc3e`; package declares version 0.4.48 and arm64 architecture. |
| Mac arm64 ZIP | `3c5b0ad0877e4c1e1adbac17a9eb4f1114294a25576330050ac9ce338e0b1c00`; ZIP CRC, bundle version 0.4.48 and main Mach-O arm64 CPU passed. |
| Mac x64 ZIP | `3bb90246794f0082fd4f34d86113e292ae543671ff8bea4096d888a9273a529c`; ZIP CRC, bundle version 0.4.48 and main Mach-O x86_64 CPU passed. |
| Windows x64 Setup | `def654c238575778120c6f2a4df1af319b0cd169f64c2fceebe26ddc5e3c68ff`; downloaded file is a PE/Nullsoft installer; tagged Windows CI installed and tested its contents. |
After attaching the final release notes,
`scripts/publish-release.sh --dry-run v0.4.48` passed the exact tag, ten
installer digests, checksum asset and update-manifest checks.
`scripts/publish-release.sh v0.4.48` published the release at
**21:53:29 UTC**. GitHub's latest-release API read back tag `v0.4.48`,
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS` and
`update.json`. Immediately after publication, a fresh public latest-download
of `update.json` reported version 0.4.48, the release page, ten hashed
installers and the worn VR limit in its notes. This is historical evidence,
not the current update-channel state.
## Withdrawn after a real Decky regression was confirmed
The all-green release gate did not include Music Control's actual Decky 3.3.0
legacy class calling convention. [PR #338](https://github.com/saphid/frame-control/pull/338)
reproduced a regression in the #335 digest-scoped Music Control 1.1.7 install
shim: it removes explicit `self` from calls that the real legacy runtime needs,
so player discovery fails with `_get_dbus_env() missing 1 required positional
argument: 'self'`. The earlier fixture had instantiated `Plugin()` and missed
this behavior. A passing CI run therefore did not prove this plugin path worked.
At **22:01:20 UTC**, v0.4.48 was returned to draft and the previously green
v0.4.47 was marked latest. The v0.4.48 tag and build artifacts were preserved;
no tag or installer was deleted. The preserved draft's notes now begin with a
withdrawal notice. GitHub's latest-release API and a fresh public
`releases/latest/download/update.json` now both point to **v0.4.47** with ten
hashed installers. Existing v0.4.48 installations do not auto-downgrade.
The repair in #338 merged at `04cde721c862c80a18dad17c2aab0c86ca8d3082`.
It requires green post-merge main checks, a new release candidate and all
release gates before any replacement version is offered. An app update alone
does not rewrite the bad installed plugin shim: affected users need an explicit
managed plugin update or remove/reinstall after the repair ships.
The [candidate smoke](v0448-candidate-2026-10-26.md) on the real Frame passed
**12 / one optional x86_64 launch skipped / zero failed**. The optional launch
needed Steam Linux Runtime 4, which was absent; no x86_64 headset launch was
claimed. Owned test titles and processes were removed, the lock was released,
and a fresh locked check found standby and no test-title leftovers. A later
read-only locked check found SteamVR activity 3 (standby), proximity raw
3.0625 and battery 100%, then released its own lock. No wearer was confirmed,
so neither the worn-check script nor physical controller input was run.
Official-plugin wearer visibility, controller use, VR keyboard, optical comfort
and physical launch of these exact Mac/Windows installers remain unverified.
@@ -0,0 +1,37 @@
# v0.4.49 candidate gate, 26 October 2026
The version bump at `6247dfb` follows the merged Music Control repair
([#338](https://github.com/saphid/frame-control/pull/338)), the real Decky
runtime gate ([#343](https://github.com/saphid/frame-control/pull/343)), and
the fail-closed publisher ([#344](https://github.com/saphid/frame-control/pull/344)).
The published v0.4.48 draft remains withdrawn. This candidate is not a
published update.
## Locked real Frame smoke
At 22:48 UTC, `scripts/frame-smoke.sh` ran from exact version-bumped source
commit `6247dfb` on SteamOS 0.5.6, build `20261022.6153909`. It acquired a
unique `/tmp/frame-test.lock` owner before reading status. SteamVR activity was
**3 (standby)**, proximity raw **3.0625**, and battery **99%**. The result was
**12 passed / one optional x86_64 launch skipped / zero failed**. The x86_64
title could not launch because Steam Linux Runtime 4 was not installed; no
x86_64 launch is claimed. ARM64 and Windows/Proton test titles installed,
launched and were removed. The script's owned cleanup passed and exited 0.
The private result is
`tests/smoke/results/2026-10-26T224816Z-20261022.6153909.json`.
A separate read-only audit acquired a fresh unique lock after the smoke. It
found activity **3**, proximity raw **3.0**, battery **99%**, no `fc_smoke_*`
or `fc-smoke-*` title folders and no matching test processes. The audit
released only its own lock. Neither run woke the headset or ran the worn
interaction script. No wearer was confirmed; official-plugin wearer
visibility, physical controller input, VR keyboard and optical comfort remain
unverified.
## Remaining publication gates
The final candidate head needs green PR CI, native amd64 and ARM64 Decky
runtime checks, all four installer previews, post-merge exact-commit main CI,
tagged builds of all ten installers, checksums, independent package
verification, publisher dry run and public update-channel readback. A gate
that fails, is cancelled or remains pending holds publication.
+59
View File
@@ -0,0 +1,59 @@
# v0.4.49 tagged release verification, 26 October 2026
The annotated `v0.4.49` tag dereferences to merged main commit
`38db6a284696c3bb86bf97f6b853af7e97681701`. This is the same tree as
the reviewed [candidate PR #348](https://github.com/saphid/frame-control/pull/348).
Its [main checks run](https://github.com/saphid/frame-control/actions/runs/40417275314)
passed all seven jobs, including e2e, iOS, macOS, Windows and Linux.
The [native Decky 3.3.0 run](https://github.com/saphid/frame-control/actions/runs/40417275256)
passed on AMD64 and ARM64. The regression gate includes an expected failure
for v0.4.48's bad legacy calling convention.
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40418177330)
passed all six jobs on its first attempt: draft, Linux, Windows, Apple Silicon
Mac, Intel Mac, and checksums. No tagged build failed, was cancelled, or was
skipped. The draft contained exactly ten installers and `SHA256SUMS` before
publication. Every asset had a GitHub SHA-256 digest. A fresh download of
`SHA256SUMS` had SHA-256
`8a04f81f3b1efea9903e9acbba2c61b67f275869c160e0c6fa1d388a4803b42a`;
it matched a list regenerated from the ten GitHub installer digests byte for
byte. All ten installers were independently downloaded and passed
`sha256sum -c SHA256SUMS`.
## Independent package checks
| Package | Check |
| --- | --- |
| Linux x86_64 AppImage | SHA-256 `0ef192e6f13aed39469392c22b971b311d911a0bebae16cbbabbfa729b95ced0`; isolated Xvfb launch returned HTTP 200 with `FrameControl/1`, then its server stopped. The first local launch attempt hit the workspace sandbox's `Operation not permitted` restriction; the same downloaded bytes passed outside that sandbox. Tagged Linux CI also launched the AppImage. |
| Linux arm64 DEB | SHA-256 `a8e448b8b8f10b2813546cb9d8fea6bbe424e14dc3a739df86daa69bfd4023c1`; declares version 0.4.49 and arm64. |
| Linux amd64 DEB | SHA-256 `10bb554cc761c61c99d059ae3601f4889187d7f18bdc8f9eb3cf768822b5765d`; declares version 0.4.49 and amd64. |
| Mac arm64 ZIP | SHA-256 `c43c1017b3d244fcf59ab8f5bd35dbad17af4562b2aca3baf65908814f2ae4c3`; ZIP CRC, bundle version 0.4.49 and main Mach-O arm64 CPU passed. |
| Mac x64 ZIP | SHA-256 `0dd838a1322107928893ff5efce817719867a5db1af2fa09124b6bb03018a19d`; ZIP CRC, bundle version 0.4.49 and main Mach-O x86_64 CPU passed. |
| Windows x64 Setup | SHA-256 `386d99fd15314e1caef577f7cdf21e9e9f572d1c0dbaa65c369e5a5b0c047d31`; downloaded file is a PE/Nullsoft installer. Tagged Windows CI installed it and checked its packaged resources and Python libraries. |
| Windows x64 ZIP | SHA-256 `2c201a8d1115dd1a07ba7b9293f7e05c319bd802f574a4b2f9565268a7f5cf8b`; ZIP CRC passed. |
The remaining three packages (both Mac DMGs and the ARM64 AppImage) also
matched `SHA256SUMS`; tagged CI built and checked their platform bundles.
Physical launch of these exact Mac and Windows installers was not performed.
## Publication and real Frame scope
The [candidate evidence](v0449-candidate-2026-10-26.md) records a unique-lock
real Frame smoke on the version-bumped source: **12 passed, one optional
x86_64 launch skipped, zero failed**, followed by an independent locked audit
with no test-title folders or processes. The optional runtime was absent; no
x86_64 launch is claimed. SteamVR stayed in standby, no wearer was confirmed,
and neither a worn test nor physical controller check was run.
`scripts/publish-release.sh --dry-run v0.4.49` passed the exact main, native
Decky and tagged release job gates, ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update manifest. The real publisher repeated
those gates, attached `update.json`, and published
[v0.4.49](https://github.com/saphid/frame-control/releases/tag/v0.4.49) at
**23:25:45 UTC**. GitHub's latest-release API returned `draft=false`,
`prerelease=false`, ten installers, `SHA256SUMS` and `update.json`. A fresh
public latest-download of `update.json` reported version 0.4.49, the correct
page, ten hashed assets, the v0.4.48 repair note and the worn-VR limit.
The v0.4.48 release remains a withdrawn draft; its notice points to v0.4.49.
Existing v0.4.48 installations do not auto-downgrade, and affected installed
Music Control plugins need managed Update or Remove and reinstall.
@@ -0,0 +1,76 @@
{
"tag": "v0.4.50",
"releaseId": 424993367,
"tagNameAtAudit": "v0.4.50",
"draftAtAudit": true,
"sha256sumsSha256": "85383104a8f08f49ffde24ac867f9c2ead41951e602600a9d22d58bc19bf06f5",
"assets": [
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 173090558,
"sha256": "d402924394725bac2b28bef17c7945e2f7e4c3093227eace78a42ed80e864be9",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 172911785,
"sha256": "2583937fefdc315706018c1bf794b916726ee55b461e321c3334df21245a110d",
"zipCrc": "ok",
"members": 1666,
"bundleVersion": "0.4.50"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 180139223,
"sha256": "2128c14b15dd1a2a59790c43d42640a25185562056796a3437910d6f88f996c8",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 179998207,
"sha256": "ed684023ff087d6741e88d4ca4f8a47e67bec07255fc73fbd18a6bcaa42d14fe",
"zipCrc": "ok",
"members": 1662,
"bundleVersion": "0.4.50"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 147348168,
"sha256": "7c46fcc759cefb9060e1dc2fce39682f6dad306f3666805f77ce707e16ef3e2c",
"peMachine": 332
},
{
"name": "Frame-Control-win-x64.zip",
"size": 194302082,
"sha256": "57e71304bae96dc8aab6b845a64c27ac428931d02bb41ae60b33b19fcd11193f",
"zipCrc": "ok",
"members": 1510
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 175453095,
"sha256": "d2196b9a50d0777203e60721dd19c6edbb5c18a7639148f0a496d420e231b053",
"elfMachine": 62
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 167036625,
"sha256": "49370f9c27931bf6c28676d627dc34553550b544ab38b7f4be8e8f6dbc9b7c4b",
"elfMachine": 183
},
{
"name": "Frame-Control-linux-amd64.deb",
"size": 142209572,
"sha256": "430d057f234557f5f9dbb3e2cff3469829d041d0d7be191e70e6ef7de6663256",
"debVersion": "0.4.50",
"architecture": "amd64"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 129291236,
"sha256": "6403f8caafcdd5832415547e146ce3886cf7358e0c3a8898f4ee6e9a0a7b05ea",
"debVersion": "0.4.50",
"architecture": "arm64"
}
]
}
@@ -0,0 +1,52 @@
# v0.4.50 candidate gate, 27 October 2026
The version bump at `a160043` follows the merged Windows panel-response repair
[#356](https://github.com/saphid/frame-control/pull/356) and the Framey changes
[#353](https://github.com/saphid/frame-control/pull/353),
[#354](https://github.com/saphid/frame-control/pull/354),
[#355](https://github.com/saphid/frame-control/pull/355),
[#357](https://github.com/saphid/frame-control/pull/357),
[#358](https://github.com/saphid/frame-control/pull/358), and
[#359](https://github.com/saphid/frame-control/pull/359).
The exact DeckyFileServer catalog entry is **partial**; the shared Toggle fix
does not promote any plugin status. Worn VR and physical controller behavior
remain unverified.
## Locked real Frame smoke
Early `scripts/frame-smoke.sh` attempts exited before any headset read or test
action because lxso2's Tailnet route was down and lxso1 lacked the authorized
SSH key and pinned host record. No lock was taken by those attempts. The final
run kept lxso2's existing private key and pinned Frame host key end to end,
using lxso1 only as a TCP relay. A task-owned localhost tunnel reached the
devkit endpoint after the runner acquired `/tmp/frame-test.lock`; no global
SSH, network or security setting was changed.
At **02:25:14 UTC**, `scripts/frame-smoke.sh` ran from candidate commit
`fcae5d6` on Frame OS **0.5.6**, build `20261022.6153909`. Its locked
read-only preflight found SteamVR activity **3 (standby)**, proximity raw
**3.25** and battery **100%**. The result was **12 passed / one optional
x86_64 launch skipped / zero failed**. ARM64 and Windows/Proton test titles
installed, launched and were removed. The x86_64 title installed and was
removed, but Steam Linux Runtime 4 was absent, so no x86_64 launch is claimed.
Both the initial leftover sweep and final owned cleanup passed; the runner
released only its lock. The private result is
`/tmp/frame-control-v0450-smoke-results/2026-10-27T022514Z-20261022.6153909.json`
(SHA-256 `7b8d88aafb4459bf882edeb10f65c021c5aa25ad9ed2755faf04e3b4a95c1632`).
A separate read-only audit acquired a fresh unique lock after the smoke. It
found activity **3**, proximity raw **3.25**, battery **100%**, no exact reserved
`fc_smoke_*` or `fc-smoke-*` title directories or metadata, and no processes
owned by those paths. That audit released its own lock. The local tunnel was
stopped; the isolated lxso1 checkout and temporary SSH files were removed.
There was no wearer confirmation, wake command or worn interaction test.
## Remaining release gates
The candidate needs green exact-head PR CI, e2e, native amd64/ARM64 Decky
runtime checks and all four installer previews. After the candidate merges,
its exact main commit needs green CI and e2e. The tagged release then needs
every installer build, ten assets, matching checksums, release verification,
independent package checks and publisher dry run. A failed, cancelled, skipped,
pending or unverified release gate holds publication. The current published
release remains v0.4.49.
+75
View File
@@ -0,0 +1,75 @@
# v0.4.50 tagged release verification, 27 October 2026
The annotated `v0.4.50` tag dereferences to merged main commit
`2797212d8c95311deb8ba73043d0f90ba10bc962`. Its tree exactly matched
the reviewed [candidate PR #360](https://github.com/saphid/frame-control/pull/360)
head `dab13fa8763b4f836cd49d398d152124655ea8dc`. The candidate includes
the Framey and Windows changes listed in the public release notes and the
publisher gate that checks every workflow run attempt.
## Exact commit and tagged build gates
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40440862876)
completed successfully on the tag commit: seven of seven jobs, including
e2e, iOS, Windows, macOS, Linux and the aggregate gate.
- [Native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40440862867)
completed successfully on that commit: AMD64 and ARM64 actual Decky 3.3.0
discovery and safe probes.
- An exact-main [installer preview](https://github.com/saphid/frame-control/actions/runs/40441532929)
built Windows, Linux, Apple Silicon Mac and Intel Mac installers successfully
before tagging. Its draft and checksum jobs were inapplicable to the manual
preview and skipped by their tag-only conditions; they were not release gates.
- The [exact-tag release workflow](https://github.com/saphid/frame-control/actions/runs/40442228066)
then passed all six jobs on its first attempt: draft, four platform builds,
and checksums. No tagged job failed, was cancelled, skipped or overridden.
The draft contained exactly ten installer assets and `SHA256SUMS` before
publication. Every installer was independently downloaded by GitHub asset ID,
one file at a time. Its downloaded size and SHA-256 matched GitHub's asset
metadata and the downloaded `SHA256SUMS`; the latter's SHA-256 was
`85383104a8f08f49ffde24ac867f9c2ead41951e602600a9d22d58bc19bf06f5`.
The [sanitized audit result](v0450-assets-2026-10-27.json) records all ten
names, sizes, hashes and package checks. The audit also checked both Mac ZIP
CRCs and bundle version `0.4.50`, both DMG UDIF trailers, the Windows ZIP
CRC and PE installer header, both AppImage ELF architectures, and both DEB
version and architecture fields. The Windows NSIS installer itself has a
32-bit PE launcher; tagged Windows CI installed its x64 payload and checked
packaged resources and Python libraries.
The independently downloaded Linux x86_64 AppImage had SHA-256
`d2196b9a50d0777203e60721dd19c6edbb5c18a7639148f0a496d420e231b053`.
Its first local launch exited inside the workspace sandbox before the server
answered. The same bytes then passed outside that sandbox under isolated Xvfb:
HTTP 200 with `FrameControl/1`, followed by a stopped server. The launcher
used a temporary profile and disabled telemetry and update checks. Tagged
Linux CI also launched its own built AppImage. No physical Mac or Windows
launch of these exact installers was performed.
## Real Frame and publication
The [candidate evidence](v0450-candidate-2026-10-27.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 cleanup audit found no owned
test title directories, metadata or processes; both locks were released.
SteamVR activity was 3 (standby), proximity raw 3.25 and battery 100%.
There was no wearer confirmation, wake command or worn interaction test.
The private smoke result's SHA-256 is
`7b8d88aafb4459bf882edeb10f65c021c5aa25ad9ed2755faf04e3b4a95c1632`.
`scripts/publish-release.sh --dry-run v0.4.50` passed the exact main,
native runtime and tagged release jobs, all ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update-manifest checks. The real publisher
repeated those gates, attached `update.json`, and published
[v0.4.50](https://github.com/saphid/frame-control/releases/tag/v0.4.50) at
**03:19:21 UTC**. GitHub's latest-release API returned `draft=false`,
`prerelease=false`, ten installers, `SHA256SUMS` and `update.json`. A fresh
public latest-download of `update.json` reported version `0.4.50`, the
correct release page, ten hashes matching the independent downloads, and the
worn-headset limit in its notes.
The exact-version official Decky catalog is **6 works / 51 partial /
48 broken / 11 N/A**. These statuses have the scoped evidence in
[the compatibility table](../framey-compatibility.md). Worn headset
visibility, physical controller input, VR keyboard and optical comfort remain
unverified. The hourly shared-lock worn check remains enabled.
@@ -0,0 +1,76 @@
{
"tag": "v0.4.51",
"releaseId": 425049939,
"tagNameAtAudit": "v0.4.51",
"draftAtAudit": true,
"sha256sumsSha256": "9d28eae8610356cba56310c892b6dd16dfc55c0507c9089600ab86e9697a04f8",
"assets": [
{
"name": "Frame-Control-mac-arm64.dmg",
"size": 177621496,
"sha256": "8c84ef490402c202ad694f9d363d792c5dbc9b046e40d8d834bcb6d7752ab6e8",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-arm64.zip",
"size": 177449210,
"sha256": "3541b67985e3931b9469af9249df3f71133589d726ffbd7b0c2b9c1c3dd3f67c",
"zipCrc": "ok",
"members": 1673,
"bundleVersion": "0.4.51"
},
{
"name": "Frame-Control-mac-x64.dmg",
"size": 184691853,
"sha256": "04fecaf012e6ab4ca139823d2220bf61b788568cdffbfdad22b5d0b2c2b63763",
"udifTrailer": "ok"
},
{
"name": "Frame-Control-mac-x64.zip",
"size": 184535648,
"sha256": "7e9836d62f882fbaac3d9b35831410c5e6138d9b68fb93815cbd2bb9200bd46b",
"zipCrc": "ok",
"members": 1669,
"bundleVersion": "0.4.51"
},
{
"name": "Frame-Control-Setup-x64.exe",
"size": 151882885,
"sha256": "a45996f621901c2df8fc41d9eca2ae3fcd6b9ff40de66ed8acfca9415a3a81b0",
"peMachine": 332
},
{
"name": "Frame-Control-win-x64.zip",
"size": 198837858,
"sha256": "41791b6ca2c40164eb44cdc29e1281886556c257b88bcd5f86941daddd15d66a",
"zipCrc": "ok",
"members": 1517
},
{
"name": "Frame-Control-linux-x86_64.AppImage",
"size": 179993078,
"sha256": "5ebbb931276cd3126cb3ecab88c6cde05f490cbd0e0205b418c32d00f765ea42",
"elfMachine": 62
},
{
"name": "Frame-Control-linux-arm64.AppImage",
"size": 171579329,
"sha256": "4205422b02b9207bf2cf59ce3eb847b601306cef326df9fb005a7df540d82bb5",
"elfMachine": 183
},
{
"name": "Frame-Control-linux-amd64.deb",
"size": 146747460,
"sha256": "7c3a4eae7fb2098c759cbd9a8194c326b70f2fa078030062c047e4c8418b4bef",
"debVersion": "0.4.51",
"architecture": "amd64"
},
{
"name": "Frame-Control-linux-arm64.deb",
"size": 133829576,
"sha256": "793433d21e457d5422ea982678f50d5f3ac0c1c46d40c5dcebf42d16651d4995",
"debVersion": "0.4.51",
"architecture": "arm64"
}
]
}
@@ -0,0 +1,53 @@
# v0.4.51 candidate gate, 27 October 2026
The source candidate `91cf774` follows green main merge commit
`560f3de5631ce2dca641c68a5cba7b2dd1d2660f`. The main
[checks](https://github.com/saphid/frame-control/actions/runs/40450100545)
and [native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40450100462)
passed on that exact merge commit. This candidate bumps the app and lockfile
to 0.4.51 and requires standby-range proximity before the release smoke can
change test titles.
Since v0.4.50, [#362](https://github.com/saphid/frame-control/pull/362)
recorded the pinned official LocalSend source match, and
[#363](https://github.com/saphid/frame-control/pull/363) added an exact-digest
managed ARM64 helper, desktop/headset packaging, and a scoped **partial**
compatibility entry. Its PR head passed e2e, native Decky AMD64/ARM64, and all
four installer previews. The catalog is **6 works / 52 partial / 47 broken /
11 N/A**. A private no-route ARM64 helper marker transfer passed on the real
Frame, but the managed Decky service on Frame, user-facing transfer, local
discovery, Steam routes, worn VR and physical controllers remain unverified.
## Locked real Frame smoke
At 04:33:21 UTC, `scripts/frame-smoke.sh` ran from source candidate `91cf774`
with a 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 **4.6875**, and battery **88%**. It passed **12
steps**, skipped one optional x86_64 launch, and failed **zero**. ARM64 and
Windows/Proton test titles installed, launched and were removed. The x86_64
title installed and was removed; Steam Linux Runtime 4 was absent, so no
x86_64 launch is claimed. The runner's owned cleanup passed and it released
only its lock. The private result is
`/tmp/frame-control-v0451-smoke-results/2026-10-27T043321Z-20261022.6153909.json`
(SHA-256 `c2b9611e072fd08109c31f4aa05a4ba6b93de82f532a7d87a4a1c6dcd52024a3`).
A separate read-only audit acquired a fresh unique lock after the smoke. It
found activity **3**, proximity raw **4.6875**, battery **91%** while charging,
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-control-v0451-cleanup-audit.json` (SHA-256
`3953072bb5457f4fef2cb766d83f65f844896fc7893d2479a166cb9325f72b48`).
No power setting or security state was changed. No wearer was confirmed and no
worn panel or controller interaction was tested; the hourly worn check remains
enabled.
## Remaining release gates
The candidate needs green exact-head PR CI, e2e, native Decky runtime and all
four installer previews. After the candidate merges, its exact main commit
needs green CI and e2e. The tagged release then needs every installer build,
ten assets, matching checksums, release verification, independent package
checks and publisher dry run. A failed, cancelled, skipped, pending or
unverified release gate holds publication. The current published release
remains v0.4.50.
+75
View File
@@ -0,0 +1,75 @@
# v0.4.51 tagged release verification, 27 October 2026
The annotated `v0.4.51` tag dereferences to merged main commit
`4da7220660ba345bc46cdb525b6c512bc6a9d8bd`. Its tree
`16aa888207e8661280aa659c96a08fee05f17689` exactly matches reviewed
[candidate PR #364](https://github.com/saphid/frame-control/pull/364) head
`728a4dbe9b049e5f5cfcd6de02d9ca858d3e9aa4`. The candidate source
passed the locked smoke described in
[its evidence](v0451-candidate-2026-10-27.md).
## Exact commit and tagged build gates
- [Main checks](https://github.com/saphid/frame-control/actions/runs/40451752952)
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/40451752953)
passed on the tag commit: both published Decky 3.3.0 AMD64 and ARM64 jobs.
- An exact-main [installer preview](https://github.com/saphid/frame-control/actions/runs/40451826490)
built all four platform installers successfully before tagging. Its draft
and checksum jobs were inapplicable to manual preview runs.
- The [exact-tag release workflow](https://github.com/saphid/frame-control/actions/runs/40452627862)
passed all six jobs on its first attempt: draft, Linux, Windows, Intel Mac,
Apple Silicon Mac and checksums. No tagged job failed, was cancelled,
skipped or overridden.
The unpublished draft held exactly ten installers and `SHA256SUMS` before
publication. Every installer was independently downloaded by GitHub asset ID,
one at a time. Each downloaded size and SHA-256 matched GitHub's asset
metadata; the downloaded `SHA256SUMS` matched all ten entries byte for byte.
Its SHA-256 was
`9d28eae8610356cba56310c892b6dd16dfc55c0507c9089600ab86e9697a04f8`.
The [sanitized asset audit](v0451-assets-2026-10-27.json) records all ten
names, sizes, hashes and package checks. It checked both Mac ZIP CRCs and
bundle version `0.4.51`, 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
`5ebbb931276cd3126cb3ecab88c6cde05f490cbd0e0205b418c32d00f765ea42`.
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](v0451-candidate-2026-10-27.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 4.6875, battery 88% before and 91%
while charging at audit. There was no wearer confirmation, wake command or
worn interaction test. The private smoke result's SHA-256 is
`c2b9611e072fd08109c31f4aa05a4ba6b93de82f532a7d87a4a1c6dcd52024a3`.
`scripts/publish-release.sh --dry-run v0.4.51` passed exact-main, native
runtime and tagged release jobs, all ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update-manifest checks. The real publisher
repeated those gates, attached `update.json`, and published
[v0.4.51](https://github.com/saphid/frame-control/releases/tag/v0.4.51) at
**05:10:05 UTC**. GitHub's latest-release API returned `draft=false`,
`prerelease=false`, ten installers, `SHA256SUMS` and `update.json`. A fresh
public latest-download of `update.json` reported version `0.4.51`, the correct
release page, ten hashes matching the independent downloads, and the worn
limit in its notes. The public manifest's SHA-256 was
`27bc0e45acf6b9a5677639e66ab501f2acc3eb7f5c0f4f867ced8a5d11825c52`.
The exact-version Decky catalog is **6 works / 52 partial / 47 broken /
11 N/A**. Managed Decky Localsend is **partial**: real Frame testing proved
only its rebuilt helper's private marker transfer, while the published
Decky 3.3.0 runtime gate proved discovery and stopped status/configuration
RPCs. Real managed-service operation, user-facing transfer, network
discovery, Steam routes, worn visibility and physical-controller input remain
unverified. The hourly shared-lock worn check remains enabled.
+22 -4
View File
@@ -63,8 +63,9 @@ none of these frontend controls has passed a worn-headset check yet.
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Plugins may still use ordinary CSS overflow containers. | Parent/iframe message tests; physical controller scroll remains unverified |
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Hold either button to repeat; keyboard activation still moves one step. Plugins may use ordinary CSS overflow containers. | Parent/iframe message tests and a real Frame synthetic OpenVR pointer hold; physical controller scroll and hold remain unverified |
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
| `ProgressBar`, `ProgressBarItem`, `ProgressBarWithInfo` | Native progress control; `indeterminate` omits its value so it animates as unfinished, and `sOperationText` appears with the bar. Estimated completion time is not shown. | Host contract tests; official plugin progress behavior in worn VR remains unverified. |
| `SteamUIStore` / `DFL.Router.RunningApps`, `MainRunningApp` | Bounded detached Steam CEF reads while the panel is open; refresh about every two seconds; no MobX observation. Failed reads clear stale context. | Locked real Frame empty-list reads; nonempty local Chromium fixtures. Real running-game selection unverified. |
| `GameSessions.RegisterForAppLifetimeNotifications` | Actual Steam callbacks, bounded queue and disposable `.unregister()` handle; no events inferred from snapshot changes; lease cleanup after lost panels | Real Frame registration/poll/close; launch/exit, retry, expiry and duplicate tests use explicit fixtures. Real game transitions unverified. |
| Other `SteamClient` subscriptions | Explicit inert registration methods return disposable handles; no fabricated power/input/game-action events. Mutating Steam/power methods are unavailable. | Host contract tests; safe fallbacks only |
@@ -74,10 +75,13 @@ none of these frontend controls has passed a worn-headset check yet.
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
| `SteamClient.Storage.GetJSON` / `SetObject` | Uses the same bounded per-plugin cache; a missing key rejects with `Not found`. It cannot read Steam account or global storage. | Host contract tests and local Chromium settings views; headset persistence after reopen remains unverified. |
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values. Back walks up to 32 local page transitions and skips routes removed since navigation. | Host unit test; VR navigation unverified |
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
| `findModule`, `findModuleChild`, `findModuleExport`, class lookup and module mapping | Searches a finite registry of Framey controls and CSS maps. Unknown Steam modules return `undefined` or an empty mapping. The optional UI-audio export cannot play sound. | Host contract tests; pinned TabMaster 2.15.1 QAM and documentation render in local Chromium. Steam library editing and worn VR remain unverified. |
| `Navigation.CloseSideMenus` | Returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 shallow settings view in local host test. Live HLTB search remains unverified. |
| `Focusable.onActivate`, `showModal`, `ConfirmModal` | Pointer and Enter/Space activation, disabled state, and local confirmation dialogs with cancel/close and nested-dialog focus | Host tests and official AlarMe 1.6.3 timer/preset controls with a real ARM64 backend in local Chromium. Sound, suspend and worn VR remain unverified. |
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
| `fetchNoCors`, file picker, root calls | Unavailable | Not supported by Framey |
@@ -87,7 +91,21 @@ Frame Control store; only paths registered by that same plugin can navigate.
Optional route segments must be at the end. Framey accepts local path segments
only; query strings, external URLs, Steam library routes and duplicate parameter
names are rejected. Removing an active route returns to the plugin panel.
`Navigation.NavigateBack()` returns to the preceding local page, then the
plugin root. `Navigation.CloseSideMenus()` returns directly to the root and
clears that panel's history. Neither operation navigates Steam's own UI.
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
Treat module lookup results as Framey-local exports. Do not depend on Steam
webpack IDs, function-source signatures, or Steam-only CSS class names; a
lookup that works in the Steam Deck QAM can still return `undefined` here.
Use a local control or a visible unsupported message when it does. For a
confirmation, call `showModal` with a `ConfirmModal` and keep any irreversible
operation behind its OK action. `Focusable.onActivate` accepts pointer and
Enter/Space input in Framey, but only a worn-headset test can establish how a
physical controller reaches it. See the
[module lookup evidence](evidence/framey-module-lookup-2026-10-26.md) and
[dialog control evidence](evidence/framey-dialog-controls-2026-10-26.md) for
the exact tested official versions.
An API v1 manifest does not guarantee a working Framey panel. Test the exact
store archive and do not assume SteamClient app details, library patches, or
destructive actions work in VR. The
+31 -29
View File
@@ -4,7 +4,7 @@ Snapshot: 2026-10-26T06:10:58.537029+00:00; 116 current visible official plugins
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
**Works: 4 · Partial: 50 · Broken: 51 · N/A: 11.**
**Works: 6 · Partial: 52 · Broken: 47 · N/A: 11.**
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
@@ -29,7 +29,7 @@ A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional Line truncated
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. That initial pass left Simple Timer partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSSLine truncated
## Every current plugin
@@ -38,11 +38,11 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| Plugin / version | Manifest / bundle API | Status | Reason | Verified by |
| --- | --- | --- | --- | --- |
| 1: XR Gaming 1.6.2 | 1 / 2 | N/A | Not supported on Frame: Requires supported external XR glasses and the Breezy/XRLinuxDriver stack; it does not target Steam Frame displays. | static archive, local browser, fake-Frame |
| 7: CSS Loader 2.1.3 | legacy / legacy | partial | Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. | static archive, local browser, fake-Frame |
| 7: CSS Loader 2.1.3 | legacy / legacy | partial | Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 10: vibrantDeck 2.0.1 | legacy / legacy | N/A | Not supported on Frame: Changes X11 gamescope color properties, not the Frame SteamVR compositor. | static archive, local browser, fake-Frame |
| 11: Fantastic 0.5.1 | legacy / legacy | N/A | Not supported on Frame: Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. | static archive, local browser, fake-Frame |
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Legacy media controls initialize; player discovery and playback are unverified. | static archive, local browser, fake-Frame |
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied. | static archive, local browser, fake-Frame |
| 12: MusicControl 1.1.6 | 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 Decky 3.3.0 + MPRIS fixture, real Frame SSH |
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings and a synthetic running-game list render locally. Actual Decky 3.3.0 discovery, private settings writes/readback and exclusion-list persistence pass on amd64 and ARM64 Frame; standalone Toggle now honors its value prop. Live empty-list/lifetime checks passed earlier. Real process pause/resume, automatic pausing, suspend integration and worn VR remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel. | static archive, local browser, fake-Frame |
| 18: Bluetooth 3.1.1 | 1 / 2 | partial | Controls render with fixture replies; actual Bluetooth discovery, labels and connections remain unverified. | static archive, local browser, fake-Frame |
@@ -55,7 +55,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 27: AutoSuspend 2.2.0 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of null (reading '6'). | 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 |
| 30: TunnelDeck 1.0.4 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | 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 |
| 35: MoonDeck 1.13.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: moondeck/python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so. Frontend initializes; native CPython wheels and executables in the published archive are x86_64. | static archive, local browser, fake-Frame |
| 36: SteamGridDB 1.8.1 | 1 / 2 | broken | Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel. | static archive, local browser, fake-Frame |
| 37: Discord Status 1.5.0 | 1 / 2 | partial | Disconnected panel renders; running-activity construction accepts a synthetic game in local Chromium. Exact official is_connected returned false in real Decky 3.3.0 ARM64. Empty live running-list reads and lifetime registration/cleanup passed. Real game transitions, Discord shortcut detection/connection/activity, local custom artwork and worn VR remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
@@ -65,18 +65,18 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 44: DeckMTP 1.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: DeckMTP/bin/umtprd. | static archive, local browser, fake-Frame |
| 46: Steamback 1.0.0-1 | legacy / legacy | partial | Panel opens using the real Frame current-user/library snapshot. Account setup and empty save-list reads pass through actual Decky 3.3.0 amd64 in isolation. ARM64 backend, supported-save discovery, automatic backups and restore remain unverified; no backup/restore ran. | static archive, local browser, fake-Frame, real Frame SSH |
| 48: Quick Launch 1.2.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
| 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 |
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'type'). | static archive, local browser, fake-Frame |
| 53: Shotty 0.1.0 | legacy / legacy | broken | Library snapshot fixes the frontend allApps crash. Actual published Decky 3.3.0 amd64 backend fails importing click.get_app_dir (ModuleNotFoundError); the archive does not bundle click. Screenshot aggregation/rescue was not run. | static archive, local browser, fake-Frame, real Frame SSH |
| 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 |
| 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 | broken | Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend. | static archive, local browser, fake-Frame |
| 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 |
| 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 |
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. | static archive, local browser, fake-Frame |
| 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 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
@@ -97,14 +97,14 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 85: Decky-Undervolt 1.0.8 | 1 / 2 | N/A | Not supported on Frame: RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. | static archive, local browser, fake-Frame |
| 87: ScreenSaver 1.1.2 | legacy / legacy | partial | Initializer succeeds; global screen overlay and idle/suspend integration have no verified VR target. | static archive, local browser, fake-Frame |
| 88: steamdeck-input-disabler 1.0.2 | legacy / legacy | N/A | Not supported on Frame: Targets the built-in Steam Deck USB gamepad; Frame has different controllers. | static archive, local browser, fake-Frame |
| 89: DeckySpy 0.6.7 | legacy / legacy | partial | Initializer succeeds; account tokens and external service integration were not exercised. | static archive, local browser, fake-Frame |
| 89: DeckySpy 0.6.7 | legacy / legacy | works | Works for read-only system monitoring: live CPU, memory, uptime, processes, interfaces and Frame battery render in local Chromium with actual Decky 3.3.0 ARM64 on the Frame; silent warning and private settings save/reopen pass. Exact managed install adds pinned BSD psutil wheels and a battery-name fallback. Existing copies need update/reinstall. Automatic alerts, resume/game-time warnings, sound and worn VR remain unverified; monitoring requires an open panel. | static archive, fake-Frame, local browser, real Frame SSH |
| 90: Picture in Picture 1.0.0 | 1 / 2 | partial | Initializer succeeds; global picture-in-picture injection cannot escape the isolated panel. | static archive, local browser, fake-Frame |
| 91: PlayCount 1.7 | 1 / 2 | partial | Initializer succeeds; Steam game-page play-count patch has no supported VR target. | static archive, local browser, fake-Frame |
| 92: Crosshair 1.1.0 | 1 / 2 | partial | Initializer succeeds; global crosshair injection has no supported compositor target. | static archive, local browser, fake-Frame |
| 93: Deck Settings 1.2.6 | 1 / 2 | partial | Game picker renders with detached real Frame library/install-folder reads; search modal opens in local Chromium. Snapshot refreshes on panel reopen. Remote reports/proxy fetch, settings changes and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 94: Decky DoT DNS 0.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 95: Decky-SpoofDPI 0.12.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-spoof-dpi/bin/spoofdpi. | static archive, local browser, fake-Frame |
| 96: Simple Timer 1.0.0 | 1 / 2 | partial | Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 96: Simple Timer 1.0.0 | 1 / 2 | works | Verified for silent in-panel session timers: Chromium Begin/Cancel/countdown/toast with real ARM64 Decky 3.3.0; repaired cancel, replace, completion and active/canceled/expired restarts pass actual loader RPC/events. Update/reinstall required. Sound and suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 97: Decky-Framegen 0.11.15 | 1 / 2 | broken | Plugin archive size or SHA-256 did not match the store. | static archive, local browser, fake-Frame |
| 98: Decky-Lookup 0.1.0 | 1 / 2 | partial | Initializer succeeds; game-page context actions and live lookups remain unverified. | static archive, local browser, fake-Frame |
| 99: Decky Zerotier 0.3.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
@@ -119,12 +119,12 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 108: HueSync 3.8.0 | 1 / 2 | N/A | Not supported on Frame: Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. | static archive, local browser, fake-Frame |
| 109: MuraDeck 2.1.4 | 1 / 2 | N/A | Not supported on Frame: Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. | static archive, local browser, fake-Frame |
| 110: Dimmer Deck 0.0.4 | 1 / 2 | N/A | Not supported on Frame: Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. | static archive, local browser, fake-Frame |
| 111: Music Control 1.1.7 | legacy / legacy | partial | Panel initializer succeeds with backend/network blocked; actual controls and backend remain unverified. | static archive, local browser, fake-Frame |
| 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. | 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 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 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. | 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 |
@@ -132,17 +132,17 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 121: DeckPass 1.1.1 | 1 / 2 | partial | Initializer succeeds; KeePass Flatpak discovery and secrets integration were not exercised. | static archive, local browser, fake-Frame |
| 123: Steam Status 1.2.3 | 2 / 2 | partial | Settings/public status rendered locally; cache errors fixed. Read-only get_settings RPC returned actual Decky 3.3.0 defaults on ARM64 Frame. Persistence, live backend diagnostics and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 124: AlarMe 1.6.3 | 1 / 2 | partial | Silent labeled timer create/ticks/pause/cancel, preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with the official Decky 3.3.0 ARM64 backend. Audio, alarms/reminders, suspend inhibition, Steam overlays and worn VR remain unverified; notifications require the panel to stay open. | static archive, local browser, fake-Frame, real Frame SSH |
| 125: Decky Localsend 0.4.5 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core. | static archive, local browser, fake-Frame |
| 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
| 125: Decky Localsend 0.4.5 | 1 / 2 | partial | Exact managed 0.4.5 archive replaces its x86 helper with a pinned ARM64 build. Published Decky 3.3.0 ARM64 stopped status/configuration RPCs and a private real-Frame ARM64 helper marker transfer passed. The managed service, user-facing discovery/transfer, Steam routes and worn VR remain unverified. | exact archive and source hashes, local Chromium initial panel, host managed transaction, fake Frame managed install/remove, private x86 backend transfer, published Decky 3.3.0 AMD64/ARM64 stopped status/configuration RPCs, private real Frame ARM64 helper marker transfer |
| 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 127: Decky Translator 0.9.0 | 1 / 2 | partial | Initializer succeeds; x86_64 download and hidraw/controller assumptions need an ARM64 audit. OCR/translation not exercised. | static archive, local browser, fake-Frame |
| 128: Quick User Switcher 1.0.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 129: ShadowDeck 1.1.11 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 129: ShadowDeck 1.1.11 | 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 |
| 130: Games Prefix Manager 1.0.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 131: Download All 1.1.0 | 1 / 2 | broken | Initializer succeeds; Steam download registration is absent, so the primary action is unavailable. | static archive, local browser, fake-Frame |
| 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 now initializes and renders Deck Shelves settings in an opaque local Chromium panel. Backend calls were denied; Home-screen shelf injection has no verified Steam UI target in Framey. Settings writes, real backend behavior and worn VR are unverified. | static archive, local browser, fake-Frame |
| 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 |
| 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 |
@@ -164,7 +164,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
| CSS Loader | none detected | ButtonItem, ColorPickerModal, ConfirmModal, DialogButton, DialogCheckbox, Dropdown, DropdownItem, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SidebarNavigation, SliderField, Tabs, TextField, Toggle, ToggleField, classMap, definePlugin, findModuleChild, gamepadDialogClasses, gamepadSliderClasses, getGamepadNavigationTrees, replacePatch, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| vibrantDeck | none detected | PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, window.SP_REACT | Python | Changes X11 gamescope color properties, not the Frame SteamVR compositor. |
| Fantastic | none detected | Field, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT, QAM/global tabs | Python + native payload; x86_64 | Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. |
| MusicControl | none detected | ButtonItem, DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| MusicControl | none detected | ButtonItem, DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | User-session MPRIS player and dbus-send required; no Deck-specific hardware. |
| Pause Games | Apps, Apps.RegisterForGameActionTaskChange, GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, System.UI.RegisterForFocusChangeEvents, System.UI.RegisterForSystemKeyEvents | Button, Field, Focusable, Marquee, PanelSection, PanelSectionRow, Router, Toggle, ToggleField, beforePatch, sleep | Router, window.SP_REACT, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| ProtonDB Badges | none detected | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, Navigation, PanelSection, PanelSectionRow, ScrollPanel, ScrollPanelGroup, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleExport, showModal, staticClasses, useParams | SteamUIStore, appStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Audio Loader | GameSessions.RegisterForAppLifetimeNotifications, d.ts | ButtonItem, DialogButton, Dropdown, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, afterPatch, beforePatch, definePlugin, findModuleChild, gamepadDialogClasses, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
@@ -220,7 +220,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
| Decky-Undervolt | GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend | ButtonItem, DialogButton, DropdownItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, TextField, ToggleField, definePlugin | Router, appStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python + native payload; x86_64 | RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. |
| ScreenSaver | Input.RegisterForControllerInputMessages, Input.RegisterForControllerStateChanges, Settings.SetSetting, System.RegisterForOnSuspendRequest, System.UpdateSettings, User.RegisterForPrepareForSystemSuspendProgress | PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| steamdeck-input-disabler | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | Targets the built-in Steam Deck USB gamepad; Frame has different controllers. |
| DeckySpy | System.RegisterForOnResumeFromSuspend | ButtonItem, Field, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| DeckySpy | System.RegisterForOnResumeFromSuspend | ButtonItem, Field, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python; managed psutil 7.2.2 stable-ABI native amd64/ARM64 dependency | Read-only Linux /proc and network-interface metrics; Frame battery read by power_supply type=Battery. No Deck-specific hardware writes. |
| Picture in Picture | none detected | ButtonItem, ConfirmModal, DropdownItem, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, findModuleChild, getGamepadNavigationTrees, quickAccessMenuClasses, showModal | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
| PlayCount | none detected | Button, ButtonItem, DialogBody, DialogButton, DialogControlsSection, DialogHeader, DropdownItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, Tabs, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
| Crosshair | none detected | ButtonItem, PanelSection, PanelSectionRow, staticClasses | window.SP_REACT, QAM/global tabs | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
@@ -242,7 +242,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
| HueSync | Settings.GetCurrentLanguage, System.GetSystemInfo, System.RegisterForBatteryStateChanges, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | DialogButton, DropdownItem, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, definePlugin, findModuleChild, gamepadSliderClasses, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. |
| MuraDeck | System.Display.RegisterForBrightnessChanges | ButtonItem, ConfirmModal, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SideMenu, SidebarNavigation, SliderField, ToggleField, quickAccessControlsClasses, showModal | SteamUIStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python | Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. |
| Dimmer Deck | Settings.GetCurrentLanguage, System.Display.RegisterForBrightnessChanges, System.Display.SetBrightness | PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts, Steam stores | Python | Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. |
| Music Control | none detected | ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Music Control | none detected | ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | User-session MPRIS player and dbus-send required; no Deck-specific hardware. |
| Better Keyboard | Settings.GetCurrentLanguage, UI.GetUIMode, UI.RegisterForUIModeChanged | ButtonItem, EUIMode, PanelSection, PanelSectionRow, ToggleField, staticClasses | SteamUIStore, window.SP_REACT, Steam stores | Python | /dev/input |
| Decky LSFG-VK | none detected | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, Spinner, TextField, Toggle, ToggleField, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | flatpak; x86_64 |
| Network Sentinel | none detected | ButtonItem, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
@@ -279,21 +279,23 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
## Native payload portability
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. The installer still rejects these archives. Apart from the explicitly described LocalSend experiment, ARM64 builds were not attempted: the constraints below remain unresolved.
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.
[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) covers a reproducible MIT helper build and its isolated real-Frame protocol 2.0 info response. The official x86 archive remains blocked; full plugin integration and file transfers are 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 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.
[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)).
| Plugin | Native payloads | ARM64 result / remaining blocker |
| --- | --- | --- |
| Fantastic | bin/backend | Fan backend/driver is Deck-specific; an ARM64 rebuild would not provide compatible Frame hardware. |
| PowerTools | bin/libryzenadj.so, bin/ryzenadj, bin/backend | RyzenAdj and AMD power backend cannot control an ARM64 Frame SoC. |
| Memory Deck | bin/libscanmem.so.1.0.0 | libscanmem must be rebuilt for aarch64 and validated against target process architecture; memory writes were not tested. |
| Emuchievements | bin/hash | The bin/hash helper needs an ARM64 build and cross-platform hashing tests; no ARM64 artifact is published. |
| Emuchievements | bin/hash | A source-matched ARM64 helper matched one synthetic ROM on the real Frame (docs/evidence/framey-emuchievements-arm-2026-10-26.md). The pinned upstream source proposed for packaging also has unsafe archive extraction and cleanup paths (docs/evidence/framey-native-port-blockers-2026-10-26.md); fix and test those before shipping an ARM64 binary. The official x86_64 ZIP remains blocked; format coverage, plugin backend/UI, login and worn VR are unverified. |
| MoonDeck | python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_parser.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/reader_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/mask.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_writer.cpython-313-x86_64-linux-gnu.so, … 26 total; full list in JSON | Rebuild vendored CPython 3.13 extension wheels for the headset Python ABI and ARM64; buddy/helper dependencies also need ports. |
| Controller Tools | bin/backend | Native backend needs an ARM64 build and controller/HID dependencies; its direct websocket also bypasses the Framey bridge. |
| DeckMTP | bin/umtprd | umtprd needs ARM64 libusb/MTP dependencies and USB gadget permissions; root user-service blocker remains. |
| Decky Cloud Save | bin/rclone | rclone needs an ARM64 artifact; this ZIP also fails safe member validation. Cloud writes were not exercised. |
| DeckyFileServer | bin/backend | Native file-server backend needs ARM64 dependencies; root requirement also blocks the user service. |
| Decky Cloud Save | bin/rclone | An ARM64 rclone artifact and safe package policy are needed; the official 60,776,600-byte x86_64 member fails archive validation. No cloud writes were run (docs/evidence/framey-native-port-blockers-2026-10-26.md). |
| DeckyFileServer | bin/backend | GPL-3.0 helper rebuilt offline for ARM64 with vendored corresponding source. Actual Frame user service passes HTTPS listing/download of a private public fixture; uploads disabled. Exact managed digest only; other native/root archives remain blocked (docs/evidence/framey-fileserver-arm64-2026-10-27.md). |
| TS3 QuickAccess | bin/ts3-qs4sd.so | TeamSpeak client plugin must match an ARM64 TeamSpeak client and SDK ABI, not merely rebuild the .so. |
| Syncthing | bin/decky-syncthing-watchdog | The watchdog needs an ARM64 build, plus a supported Syncthing runtime/package and safe path validation. |
| Wine Cellar | bin/backend | Native backend needs an ARM64 build; direct websocket and Wine runtime assumptions also need integration. |
@@ -306,7 +308,7 @@ All published Linux native payloads in this snapshot are x86_64; no published aa
| HueSync | bin/hello, py_modules/portio.so | portio and handheld LED backends depend on x86 I/O/unsupported hardware; ARM64 compilation alone is insufficient. |
| Decky Clipboard | bin/xclip | xclip needs ARM64 X11 libraries/server; root and gamescope assumptions also block this archive. |
| Reroll | bin/memscan.so | memscan.so needs ARM64 process-memory support and ABI testing; root requirement remains. Frontend initializes but no memory access was tested. |
| Decky Localsend | bin/localsend-core | MIT LocalSend-Go 66edc509a28b9f877e7374504892dcbc653a4da4 reproducibly cross-built with Go 1.25.7/CGO_ENABLED=0 for ARM64; SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Help/official backend CLI flags and protocol 2.0 info endpoint passed on real Frame in an isolated network namespace. No file transfer or full plugin/CEF integration tested; exact source match to shipped helper unproven. Official ZIP stays x86_64 and blocked. |
| Decky Localsend | bin/localsend-core | Exact official x86 helper is reproducible from pinned MIT source; the managed installer replaces only the exact 0.4.5 digest with SHA-256 e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae on ARM64. The Frame rechecks original Python/helper bytes, replacement digest and ELF machine; x86 keeps the original helper. Local official x86 backend/proxy and real Frame rebuilt ARM64 helper each passed one private marker transfer. Published Decky 3.3.0 AMD64/ARM64 discovery and stopped status/configuration RPCs passed. The actual managed Decky service on Frame, network discovery, user-facing transfer, Steam routes and worn VR remain unverified (docs/evidence/framey-localsend-managed-2026-10-27.md). |
| Launch Options | bin/hello | Remove the template x86 bin/hello or publish an ARM64 rebuild; missing Steam module mapping also prevents frontend initialization. |
| Decky-Clipper | bin/gstreamer-1.0/libgstproxy.so, bin/gstreamer-1.0/libgstmidi.so, bin/gstreamer-1.0/libgstmpegtsmux.so, bin/gstreamer-1.0/gst-plugin-scanner, bin/gstreamer-1.0/libgstivfparse.so, … 115 total; full list in JSON | Rebuild all bundled GStreamer libraries/plugins for ARM64 and verify capture/codec ABI against Frame SteamVR; gamescope capture differs. |
+23 -9
View File
@@ -70,9 +70,13 @@ The [26 October live checks](evidence/framey-events-backend-2026-10-26.json)
used the actual official Notebook and Simple Timer Python backends in a private
removable ARM64 Decky service. Notebook's default notebook saved a pointer stroke
and loaded it after a backend restart. Simple Timer's start/cancel controls,
countdown, completion toast and persisted recents/subtle mode worked. Simple
Timer remains partial: its own expired-timer startup raises a missing
`timer_task` error, sound/suspend are unsupported, and worn VR was not checked.
countdown, completion toast and persisted recents/subtle mode worked. The
[Simple Timer lifecycle repair](evidence/framey-simple-timer-2026-10-26.md) now
fixes cancellation, replacement and active/canceled/expired backend restarts,
verified through actual Decky 3.3.0 on amd64 and the ARM64 Frame. Managed
update/reinstall is required. Silent in-panel session timers qualify as works;
sound/suspend are unsupported, alerts require an open panel, and worn VR remains
unverified.
DFL module and class lookup searches a finite registry of Framey's own controls,
CSS maps and an explicitly unsupported optional UI-audio player. It follows DFL's
@@ -199,18 +203,25 @@ the managed service is stopped; it does not restart the service.
Install, Update, and Remove show the current job stage in the headset panel.
The store verifies each downloaded archive and the headset applies it with a
rollback if Decky cannot start. The panel remembers the current job ID for this
browser session, so **Check result** can resume polling after a navigation or
temporary connection error. After a polling error, Install, Update, and Remove
rollback if Decky cannot start. The panel saves a random request ID before
submitting a change. The server uses that ID as the job ID and returns the
same job for a repeated, identical request while its result is retained;
reusing the ID for a different change is rejected. **Check result** can resume
polling even if the POST response is lost. After a connection error, Install, Update, and Remove
stay disabled while the saved job's result is unknown; use **Check result**
before starting another change.
If Chromium denies session storage, the open panel keeps the request ID in
memory and **Check result** still works after a connection error. A page reload
loses that memory fallback; wait for the old job to finish and refresh installed
status before starting another change.
Refresh the installed list before starting another
operation after an error. The headset panel and desktop app send the hash of
the version you saw; if the official store changes that version before the job
starts, Frame Control stops before downloading or changing the headset. Refresh
the store and review the new version before retrying. If Frame Control restarts, it cannot recover the old
job result. The panel clears that job ID and asks you to refresh the installed
list before making another change.
the store and review the new version before retrying. Job results live in server
memory for one hour after completion. If Frame Control restarts, or the request
never reached it, the panel cannot recover the result. It clears that ID and
asks you to refresh the installed list before making another change.
An open plugin panel closes before its own Update or Remove starts, so its old
frontend cannot call the changing backend. Open panel is unavailable while a
@@ -224,6 +235,9 @@ chunks from two installed versions.
The headset also includes the panel's store SHA-256 receipt with each backend
call, Steam read, and event poll. It rejects a request if the installed receipt
changed before the next status refresh; reopen the panel to use the new version.
Panel-ready, error, and trusted-pointer reports also carry the current panel
session. A delayed report from a closed panel cannot change the new panel's
status or count as input to that panel.
## For plugin authors
+7 -3
View File
@@ -61,8 +61,10 @@ No wheel event or offset change means this synthetic injection path remains
unverified; it does not establish whether a physical controller can scroll.
The exercise then clicks the visible **Scroll plugin down/up** buttons through
the same native OpenVR pointer transport and requires the test region to move
down and return to the top. This proves the button path only; it does not
identify a physical controller or repair native wheel delivery.
down and return to the top. It also holds each button through that transport,
requires Down to move farther than a single click, and requires Up to return
to the top. These prove the synthetic button paths only; they do not identify
a physical controller or repair native wheel delivery.
Temporary controls exercise the shipped
`DFL.SliderField` and `DFL.TextField`; their values remain UI-only. Text uses
CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input,
@@ -119,7 +121,9 @@ the same-plugin navigation regression without a headset.
3. **1:15–2:15:** Use the controller to open Settings, return to Panel, invoke
**Read saved setting**, close/reopen the plugin, and confirm the value
remains. Try controller scroll, a slider and a harmless text field in an
installed compatible plugin; check SteamVR's actual keyboard and Back.
installed compatible plugin. Hold **Scroll plugin down** and then **Scroll
plugin up** with the controller; record continuous movement separately from
native thumbstick or wheel scrolling. Check SteamVR's actual keyboard and Back.
4. **2:15–3:00:** Restore the original sample setting. Switch away and back,
close/reopen the Frame Control panel, and confirm controller input still
works. Record comfort/legibility, controller model, any haptic behavior,
+153
View File
@@ -0,0 +1,153 @@
# Frame Control standing goal and historical recovery handoff
Owner: Alex. This records the 27 October 2026 AEDT recovery handoff; status
checks below use UTC. Alex subsequently authorized the existing coordinator to
continue the roadmap and publish releases when every gate passes. This document
does not authorize a second coordinator to run concurrently.
## Alex's top priority
> Make Frame Control's released version always stable and running cleanly. Never release something that isn't working correctly. I don't want to break things for people.
Keep the released app stable and main green. Investigate failures before
shipping; a version number or an uploaded artifact is not proof of readiness.
## Standing roadmap
Continue official Decky compatibility, test gaps, fixes and useful features.
The existing owner is T3 job `mppkd9qczt` on lxso2. Preserve its work and avoid
competing coordinator or release jobs. Verify its current state before any
takeover; this historical handoff is not a takeover instruction.
## Mandatory release gates
- CI must be green on the **exact release commit**, not merely an earlier
commit, another branch, or a previous successful workflow attempt.
- End-to-end tests and release verification must pass for that candidate.
- Every required installer build must succeed, including Linux, Windows,
macOS Intel and macOS Apple Silicon.
- Never publish a release with failed or cancelled required builds. A retry
must actually finish successfully; a cancelled build is not a pass.
- Keep main green. Resolve required failures before merging or releasing.
- Preserve evidence linking the release tag/commit, successful workflow run,
installer assets/checksums, e2e results and real-device verification.
- Use `scripts/publish-release.sh --dry-run TAG` before publishing. Alex's
standing full-auto instruction authorizes the existing coordinator to tag and
publish after every gate passes. Stop for secrets, money, destructive Git
actions or wiping the headset; never bypass a failed release gate.
## Current release state, checked 27 October 2026 at 05:15 UTC
- The latest public release is
[v0.4.51](https://github.com/saphid/frame-control/releases/tag/v0.4.51),
published at 05:10:05 UTC from main commit
`4da7220660ba345bc46cdb525b6c512bc6a9d8bd`.
- [Exact-commit checks](https://github.com/saphid/frame-control/actions/runs/40451752952),
[native Decky runtime](https://github.com/saphid/frame-control/actions/runs/40451752953),
and the [tagged installer release](https://github.com/saphid/frame-control/actions/runs/40452627862)
succeeded. The tag release built Linux, Windows, Intel Mac and Apple Silicon
Mac installers and checksums. The published update manifest reports 0.4.51.
- The locked candidate smoke passed 12 checks, skipped one optional x86_64
launch, and failed none. SteamVR was in standby; worn display and physical
controller use remain unverified. The hourly shared-lock worn check remains
enabled. See [candidate evidence](../evidence/v0451-candidate-2026-10-27.md)
and [release evidence](../evidence/v0451-release-2026-10-27.md) for the exact
asset audit and publication record.
- Main matched the release commit and its checks were green at this check.
Revalidate before another merge or release. Disk capacity on lxso2 remains
critical; do not delete pre-existing data to make space without Alex's OK.
## Incident snapshot supplied by Alex
At the time of the recovery request, latest published was **v0.4.47** with a
passed release run; **v0.4.48 RC was not published**, after a failed run with a
cancelled macOS build. PRs **#337 and #338 were open**. Preserve this as the
historical incident snapshot, not a claim that remote state stays unchanged.
## Verified update during recovery
Checked 26 October 2026 around 21:58 UTC:
- v0.4.47 was published at 15:07:28 UTC.
- A subsequent v0.4.48 release run
[40406781383](https://github.com/saphid/frame-control/actions/runs/40406781383)
completed successfully on commit
`5438e1d9d05ca472f200dcde50799c8ae882fafb`. Its Linux, Windows, Intel macOS
and Apple Silicon macOS installer jobs, draft and checksum jobs all succeeded.
- GitHub now reports **v0.4.48 published at 21:53:29 UTC**. This publication
happened outside the recovery task. Recheck the exact-commit checks and
release evidence before drawing conclusions about all release gates.
- [#337](https://github.com/saphid/frame-control/pull/337) is merged; lxso1's
clean main was fast-forwarded to
`1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6`.
- [#338](https://github.com/saphid/frame-control/pull/338) was open at that
time; it has since merged.
- lxso2 T3 recovered at **21:42:54 UTC**; port 3773/local HTTP and existing
HTTPS returned 200 by 21:43:58 UTC. Root disk capacity remains critical;
recovery is not a capacity fix.
- Fleet guards, probe units, safety tests and incident evidence are proposed
in [fleet #38](https://github.com/saphid/fleet/pull/38); units were installed
under the owner's incident authorization. Disk critical alerts use the
existing fleet Prometheus/watchdog pipeline.
## Historical lxso1 readiness
Main checkout: `/home/saphid/projects/steam-frame` (GitHub repository
`saphid/frame-control`). Shell default Node may be older; use
`~/.nvm/versions/node/v24.22.1/bin` first in PATH for Node 24 CI commands.
Python 3.9.25 and 3.13.14 were installed through uv for the CI matrix; system
Python 3.12.3 already existed. Workflows at that time required no Rust/Go
toolchain.
Readiness commands, per-gate logs and exact-commit JSON results live at
`/tmp/frame-control-readiness-20261027/` on lxso1. These are local readiness
checks, not a substitute for GitHub CI on a future release commit. The Linux
installer command uses `--publish never`; no release was published by these
checks. Native ARM64, Windows/macOS platform gates, Xcode/iOS and real-headset
checks still require their proper runners/hardware.
Completed on main commit `1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6`,
26 October 2026 at 22:00 UTC:
| Gate | Result on lxso1 |
| --- | --- |
| Shell syntax, Python compile | Passed |
| Python 3.9 / 3.12 / 3.13 server suites | 1,639 tests each; passed with platform/optional skips |
| Linux wireless doctor, app syntax/tests, website tests/syntax | Passed |
| `npm ci`, `npm run dist:linux` | Passed; AppImage and deb built without publishing |
| AppImage launch verification | Passed |
| `scripts/e2e.sh` on x86_64 Docker | Passed: 50 tests, one optional catalog audit skipped; not native ARM64 hardware proof |
| Publish dry runs for v0.4.47 and v0.4.48 | Passed, including all ten installer digests and SHA256SUMS; no mutation |
| Native PC host build and required-native tests | Blocked/failed: GStreamer/GLib development packages absent; required native adapter unavailable |
The workflows name system GStreamer packages but do not pin package versions.
They were left uninstalled under the owner's pinned-versions-only constraint.
Resolve that dependency policy before claiming full Linux native readiness.
Main was clean and matched origin/main on the final 21:59 UTC fetch.
## How a takeover should resume
1. Read this file, `docs/testing.md`, the current roadmap/evidence and the
existing coordinator's recorded work through supported read-only tooling.
Verify remote releases, PR states and workflow conclusions again.
2. Check lxso2 health and disk headroom. Disk-full recovery requires capacity
decisions; do not delete protected data or alter T3 state databases.
3. Confirm that the existing coordinator is no longer active and that the
takeover is authorized by the current instructions. Preserve uncommitted
changes and active worktrees; do not run competing coordinator/release jobs.
4. Fetch origin and inspect local status before updating main. Resume the
roadmap from recorded gaps and existing PRs, not from assumptions about
what an interrupted worker completed.
5. Work on an isolated branch and use PRs. Validate the actual candidate
commit with every release gate above; record hardware/platform blockers.
6. Publish only after all required gates pass under Alex's current full-auto
instruction or a newer explicit instruction.
The lxso1 five-minute `fleet-t3-lxso2-health.timer` only checks and alerts; it
does not start takeover threads. Automatic stand-in launch is not configured
or proven by this recovery deployment. It would need supported gateway
capability and authorized credentials; do not copy or mint gateway secrets.
The original handoff copy was placed at
`/home/saphid/frame-control-handoff/STANDING_GOAL.md` on lxso1. This repository
document may be newer.
+15 -1
View File
@@ -34,13 +34,27 @@ count. So a build reaches people only when you publish it, after testing it.
4. Publish:
Confirm the exact tag commit's main CI and e2e, the locked real Frame smoke,
release verification, independent package checks, and all four tagged
installer builds have passed. Hold the release if any gate is failed,
cancelled, skipped, still running, or unverified. The publisher also reads
the exact commit's GitHub CI, native Decky runtime gate, and tagged build
job records and refuses to
publish unless every required job succeeded; it cannot prove physical
headset or installer behavior on its own.
```sh
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
scripts/publish-release.sh v0.4.0
```
The script checks that the tag is on GitHub and that all ten installers
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
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
+40
View File
@@ -549,3 +549,43 @@ Fixtures cover duplicate instance identity, unknown states, GUID matching,
localised/denied channel data and 6 GHz ambiguity. No tests contact a real Frame.
A Windows CI runner/VM cannot prove physical Steam dongle behaviour or audio RF
interference. See [sources and limits](windows-wireless-doctor.md).
### Actual Decky backend shim gate
Fake-Frame tests stub plugin execution, so they cannot prove Decky's Python
calling convention. `Decky backend runtime` CI separately runs the published,
SHA-256-pinned **Decky 3.3.0** executable on native amd64 and ARM64 Linux.
The loader discovers production-managed official archives and calls their public
methods over its actual websocket API. No test imports or instantiates `Plugin`.
```sh
python3 scripts/framey-test-decky-runtime.py --output /tmp/decky-runtime.json
# Offline loader cache (still checked against its pinned native digest):
python3 scripts/framey-test-decky-runtime.py --loader /path/to/PluginLoader --output /tmp/decky-runtime.json
```
Run on Linux with Docker. Image build and the pinned loader download need network;
plugin execution has no network, published ports, host home/audio/D-Bus/device
mounts, capabilities or writable repository. Each case has its own unprivileged
container, private tmpfs home and temporary files. Nothing contacts the headset
or its shared lock. Failed/timed-out containers and the private image tag are
removed by the runner.
Every `compat_shims.append` in the managed installer must have a case in
`tests/decky-runtime/cases.json`. Add each affected pinned official archive and
a safe public-method probe when introducing a shim. Ordinary unit tests reject
uncovered shim names. The runtime job verifies the managed receipt, loader
plugin discovery, and a successful backend call; a discovery listing alone
could conceal a crashed backend process.
Current cases cover both Music Control entries, Shotty and DeckySpy. DeckySpy
verifies native psutil import plus read-only CPU, memory, uptime, battery and
network metrics through actual legacy RPC calls. Music Control uses
the private silent MPRIS fixture to check discovery, metadata, play/pause,
next/previous, seek and volume, with independent service-state readbacks.
Shotty checks backend import, rescuer state and empty screenshot aggregation
inside its disposable home. A fourth negative-control case deliberately
reintroduces #335's incorrect bound-method transformation and must reproduce
the real loader's legacy backend-call failure. The gate fails if that regression goes
undetected. These checks verify backend behavior in a container; they do not
upgrade any plugin's Frame, actual media playback or worn-VR compatibility claim.
+13 -1
View File
@@ -14,9 +14,20 @@ action calls the backend, so it reads the persisted setting rather than a
browser cache. A missing, damaged, or old non-boolean value reads as the
caller's default;
the next save atomically replaces the sample's settings file.
The current file is `{"schema": 1, "enabled": true|false}`. An unversioned
`{"enabled": true|false}` file from the first sample release still reads, and
the next save writes schema 1. A file with another explicit schema returns a
visible read or save error and remains untouched, so downgrading the sample
cannot erase settings written by a newer version. Add a deliberate migration
for each future schema instead of changing this guard silently.
The panel also guards its own read and write calls synchronously. If two
controller events arrive before React redraws a disabled control, the second
event is ignored; after the request settles, a new change can be saved.
**Reset example setting** opens a local `ConfirmModal`. **Keep setting** closes
it without a backend call; **Reset** saves `false` through the same guarded
write path as the toggle. This is a safe example of putting a settings change
behind an explicit confirmation in Framey. It has not been tried with a
physical controller in a worn headset.
The **About this setting** button opens a local detail page at
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
@@ -48,7 +59,8 @@ plugin directory does not enable its VR panel.
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
repository root. It checks backend persistence and evaluates the built bundle
against Framey's actual host script, including the quick action's scoped
backend call and local route registration/navigation. The fake Frame store flow is exercised by
backend call, modal cancel/confirm behavior, and local route
registration/navigation. The fake Frame store flow is exercised by
the repository's e2e suite. No worn-headset visibility or controller-input test
has passed for this sample yet.
+9 -1
View File
@@ -31,6 +31,7 @@ function Content() {
const [busy, setBusy] = SP_REACT.useState(true);
const [message, setMessage] = SP_REACT.useState('Loading setting…');
const pending = SP_REACT.useRef(false);
const resetModalOpen = SP_REACT.useRef(false);
async function refresh() {
if (pending.current)
return;
@@ -72,7 +73,14 @@ function Content() {
setBusy(false);
}
}
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
function confirmReset() {
if (pending.current || resetModalOpen.current)
return;
const dialog = DFL.showModal(SP_JSX.jsx(DFL.ConfirmModal, { strTitle: "Reset example setting?", strDescription: "This changes only the sample plugin's saved setting.", strOKButtonText: "Reset", strCancelButtonText: "Keep setting", onOK: () => { void change(false); } }));
resetModalOpen.current = true;
void dialog.ClosedPromise.then(() => { resetModalOpen.current = false; });
}
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: confirmReset, children: "Reset example setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
}
function Details() {
const { topic } = DFL.useParams();
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useRef, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n const pending = useRef(false);\n\n async function refresh() {\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n // Two controller events can arrive before React redraws the disabled toggle.\n // Keep writes ordered so a late first reply cannot replace the final choice.\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample',\n body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return <PanelSection title=\"Sample detail page\">\n <PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>\n <PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back Line truncated
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, ConfirmModal, Navigation, PanelSection, PanelSectionRow, ToggleField, showModal, useParams} from '@decky/ui';\nimport {useEffect, useRef, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n const pending = useRef(false);\n const resetModalOpen = useRef(false);\n\n async function refresh() {\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n // Two controller events can arrive before React redraws the disabled toggle.\n // Keep writes ordered so a late first reply cannot replace the final choice.\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample',\n body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n function confirmReset() {\n if (pending.current || resetModalOpen.current) return;\n const dialog = showModal(<ConfirmModal strTitle=\"Reset example setting?\"\n strDescription=\"This changes only the sample plugin's saved setting.\"\n strOKButtonText=\"Reset\" strCancelButtonText=\"Keep setting\"\n onOK={() => {void change(false);}} />);\n resetModalOpen.current = true;\n void dialog.ClosedPromise.then(() => {resetModalOpen.current = false;});\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={confirmReset}>Reset example setting</ButtonItem></PanelSectionRLine truncated
+21 -6
View File
@@ -5,26 +5,41 @@ from pathlib import Path
import tempfile
import decky
SETTINGS_SCHEMA = 1
def settings_file():
# Decky provides this per-plugin path through its official Python module.
return Path(decky.DECKY_PLUGIN_SETTINGS_DIR) / 'settings.json'
def read_settings():
try:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return {}
if not isinstance(stored, dict):
return {}
# The first sample release wrote {"enabled": bool} without a schema.
# Keep reading it; the next save writes the current format.
schema = stored.get('schema', SETTINGS_SCHEMA)
if type(schema) is not int or schema != SETTINGS_SCHEMA:
raise ValueError('Settings were written by an unsupported sample version')
return stored
class Plugin:
async def get_setting(self, key: str, default=False):
if key != 'enabled':
raise ValueError('Unknown setting')
try:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return default
value = stored.get(key) if isinstance(stored, dict) else None
value = read_settings().get(key)
return value if type(value) is bool else default
async def set_setting(self, key: str, value: bool):
if key != 'enabled' or type(value) is not bool:
raise ValueError('Expected an enabled boolean')
# Refuse to erase settings from a newer plugin after a downgrade.
read_settings()
target = settings_file()
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = None
@@ -32,7 +47,7 @@ class Plugin:
with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=target.parent,
prefix='.settings-', delete=False) as output:
temporary = Path(output.name)
json.dump({'enabled': value}, output)
json.dump({'schema': SETTINGS_SCHEMA, 'enabled': value}, output)
output.write('\n')
temporary.chmod(0o600)
os.replace(temporary, target)
+13 -1
View File
@@ -1,5 +1,5 @@
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
import {ButtonItem, ConfirmModal, Navigation, PanelSection, PanelSectionRow, ToggleField, showModal, useParams} from '@decky/ui';
import {useEffect, useRef, useState} from 'react';
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
@@ -10,6 +10,7 @@ function Content() {
const [busy, setBusy] = useState(true);
const [message, setMessage] = useState('Loading setting…');
const pending = useRef(false);
const resetModalOpen = useRef(false);
async function refresh() {
if (pending.current) return;
@@ -49,12 +50,23 @@ function Content() {
}
}
function confirmReset() {
if (pending.current || resetModalOpen.current) return;
const dialog = showModal(<ConfirmModal strTitle="Reset example setting?"
strDescription="This changes only the sample plugin's saved setting."
strOKButtonText="Reset" strCancelButtonText="Keep setting"
onOK={() => {void change(false);}} />);
resetModalOpen.current = true;
void dialog.ClosedPromise.then(() => {resetModalOpen.current = false;});
}
return <PanelSection title="Framey settings sample">
<PanelSectionRow>
<ToggleField label="Example setting" description="Stored by the Decky Python backend"
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
</PanelSectionRow>
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><ButtonItem disabled={busy} onClick={confirmReset}>Reset example setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
</PanelSection>;
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Rebuild the pinned GPL DeckyFileServer helper offline (Go 1.25.7 required)."""
import argparse
import gzip
import hashlib
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT/'ui/vendor/deckyfileserver-1.2.0'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--go', default='go')
parser.add_argument('--output', type=Path, default=ASSETS)
args = parser.parse_args()
assert subprocess.check_output([args.go, 'version'], text=True).split()[2] == 'go1.25.7', 'Use Go 1.25.7'
assert hashlib.sha256((ASSETS/'source.tar.gz').read_bytes()).hexdigest() == '50d90a7328daa412bf963ec68bb4855a9ec0860a8475cdf7d2019e6c2507112d', 'Source archive digest mismatch'
args.output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='framey-fileserver-build-') as temp:
base = Path(temp)
with tarfile.open(ASSETS/'source.tar.gz') as archive:
assert all(m.isfile() and m.name.startswith('source/') and '..' not in Path(m.name).parts
for m in archive.getmembers())
archive.extractall(base, filter='data')
env = dict(os.environ, CGO_ENABLED='0', GOOS='linux', GOPROXY='off', GOSUMDB='off', GOTOOLCHAIN='local',
GOCACHE=str(base/'cache'), GOMODCACHE=str(base/'modules'))
for arch, name in [('amd64', 'x86_64'), ('arm64', 'aarch64')]:
output = base/'backend'
subprocess.run([args.go, 'build', '-mod=vendor', '-trimpath', '-buildvcs=false',
'-ldflags=-s -w -buildid=', '-o', str(output), '.'], cwd=base/'source',
env=dict(env, GOARCH=arch), check=True)
data = output.read_bytes()
packed = gzip.compress(data, mtime=0)
(args.output/f'{name}.gz').write_bytes(packed)
print(json.dumps({'arch': name, 'sha256': hashlib.sha256(data).hexdigest(),
'gzipSha256': hashlib.sha256(packed).hexdigest(), 'bytes': len(data)}), flush=True)
if __name__ == '__main__':
main()
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Rebuild the pinned LocalSend-Go helper (Go 1.25.7 and module access required)."""
import argparse
import gzip
import hashlib
import io
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT / 'ui/vendor/localsend-0.4.5'
SOURCE_SHA256 = 'f1f86292624b989d540689dbfad503bf15f3937d156978157b8dd418d54415d1'
EXPECTED = {
'amd64': 'd448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843',
'arm64': 'e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae',
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--go', default='go', help='Path to Go 1.25.7')
parser.add_argument('--output', type=Path, default=ASSETS)
args = parser.parse_args()
assert subprocess.check_output([args.go, 'version'], text=True).split()[2] == 'go1.25.7', 'Use Go 1.25.7'
source_data = (ASSETS/'source.tar.gz').read_bytes()
assert hashlib.sha256(source_data).hexdigest() == SOURCE_SHA256, 'Pinned source archive changed'
args.output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='framey-localsend-build-') as temporary:
base = Path(temporary)
with tarfile.open(fileobj=io.BytesIO(source_data), mode='r:gz') as archive:
members = archive.getmembers()
assert all((m.isfile() or m.isdir()) and
(m.name == 'source' or m.name.startswith('source/'))
and '..' not in Path(m.name).parts for m in members), 'Unsafe source archive'
archive.extractall(base, filter='data')
env = dict(os.environ, CGO_ENABLED='0', GOOS='linux', GOTOOLCHAIN='local',
GOCACHE=str(base/'cache'), GOMODCACHE=str(base/'modules'))
for arch in ('amd64', 'arm64'):
output = base/'localsend-core'
subprocess.run([args.go, 'build', '-mod=readonly', '-trimpath', '-buildvcs=false',
'-ldflags=-s -w', '-o', str(output), '.'], cwd=base/'source',
env=dict(env, GOARCH=arch), check=True)
data = output.read_bytes()
digest = hashlib.sha256(data).hexdigest()
assert digest == EXPECTED[arch], f'{arch} helper does not match pinned bytes'
if arch == 'arm64':
packed = gzip.compress(data, mtime=0)
(args.output/'aarch64.gz').write_bytes(packed)
packed_digest = hashlib.sha256(packed).hexdigest()
else:
packed_digest = None
print(json.dumps({'arch': arch, 'sha256': digest, 'gzipSha256': packed_digest,
'bytes': len(data)}), flush=True)
if __name__ == '__main__':
main()
+35 -11
View File
@@ -11,6 +11,7 @@ import io
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
import re
import sys
from unittest.mock import patch
import zipfile
@@ -27,6 +28,10 @@ for(const key of ['error','warn']){const original=console[key];console[key]=(...
report(key,args.map(String).join(' '));original(...args);};}
window.fetch=()=>Promise.reject(Error('Audit blocks plugin network'));
window.WebSocket=class{constructor(){throw Error('Audit blocks WebSocket');}};
addEventListener('message', event=>{
if(event.source!==parent||event.data?.type!=='audit:inspect')return;
parent.postMessage({type:'audit:snapshot',text:(document.getElementById('framey-root')?.innerText||'').slice(0,2000)},'*');
});
</script>'''
@@ -41,6 +46,27 @@ def render_panel(record, data):
return framey_panel.panel(record['id']).replace(
b'<script id="framey-bundle"', PRELUDE.encode()+b'<script id="framey-bundle"')
def read_module_asset(path, records, cache):
"""Serve the same receipt-bound module URL shape as the installed panel."""
prefix = '/framey/module/'
if not path.startswith(prefix):
raise ValueError('Invalid module path')
parts = path[len(prefix):].split('/', 2)
if len(parts) != 3 or not re.fullmatch(r'[1-9][0-9]*', parts[0]):
raise ValueError('Invalid plugin ID')
plugin_id, receipt, name = parts
if not framey_panel.RECEIPT.fullmatch(receipt) or not framey_panel.MODULE_PATH.fullmatch(name):
raise ValueError('Invalid module receipt or path')
record = records[int(plugin_id)]
if receipt != record['sha256']:
raise ValueError('Plugin receipt changed')
data = (cache/(receipt+'.zip')).read_bytes()
if hashlib.sha256(data).hexdigest() != receipt:
raise ValueError('Archive hash mismatch')
with zipfile.ZipFile(io.BytesIO(data)) as archive:
return archive.read(record['folder']+'/dist/'+name)
PAGE = '''<!doctype html><meta charset=utf-8><title>Framey catalog frontend audit</title>
<h1>Framey catalog frontend audit</h1>
<p>Exact archived frontend code runs in an opaque iframe. All backend calls and network requests are denied.
@@ -54,6 +80,7 @@ addEventListener('message',event=>{
if(event.source!==panel.contentWindow||!active)return;
const data=event.data;
if(data.type==='audit:log'&&active.logs.length<8)active.logs.push({kind:data.kind,message:data.message.split('\\n')[0].slice(0,400)});
if(data.type==='audit:snapshot')active.rootText=String(data.text).slice(0,2000);
if(data.type==='framey:error')active.error=String(data.message).slice(0,500);
if(data.type==='framey:ready')active.ready=true;
if(data.type==='framey:events')event.source.postMessage({type:'framey:reply',id:data.id,result:{events:[],cursor:0}},'*');
@@ -71,10 +98,15 @@ window.runAudit=async(ids,settleMs=1200)=>{
if(!Number.isInteger(settleMs)||settleMs<1200||settleMs>10000)throw Error('Invalid audit settle time');
if(running)throw Error('Audit already running');running=true;run.disabled=true;
try{for(const plugin of plugins.filter(plugin=>!ids||ids.includes(plugin.id))){
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],rootText:'',
library:librarySnapshot?'snapshot replay':'unavailable',running:runningSnapshot?'snapshot replay':'unavailable'};
progress.textContent=plugin.id+' '+plugin.name;panel.src='/panel/'+plugin.id;
await new Promise(resolve=>setTimeout(resolve,settleMs));results.push(active);active=null;
await new Promise(resolve=>setTimeout(resolve,settleMs));
// Detached Steam snapshots may contain personal library data. Keep text
// observations out of reports that replay either snapshot.
if(!librarySnapshot&&!runningSnapshot)panel.contentWindow.postMessage({type:'audit:inspect'},'*');
await new Promise(resolve=>setTimeout(resolve,80));
results.push(active);active=null;
panel.src='about:blank';await new Promise(resolve=>setTimeout(resolve,30));
}}finally{running=false;run.disabled=false;progress.textContent='Done: '+results.length;}
return results;
@@ -134,15 +166,7 @@ def main():
return
elif path.startswith('/framey/module/'):
try:
plugin_id, name = path.removeprefix('/framey/module/').split('/', 1)
if not framey_panel.MODULE_PATH.fullmatch(name):
raise ValueError('Invalid module path')
record = records[int(plugin_id)]
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
if hashlib.sha256(data).hexdigest() != record['sha256']:
raise ValueError('Archive hash mismatch')
with zipfile.ZipFile(io.BytesIO(data)) as archive:
body = archive.read(record['folder']+'/dist/'+name)
body = read_module_asset(path, records, args.cache)
content_type = 'text/javascript; charset=utf-8'
module_asset = True
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
+38 -6
View File
@@ -50,7 +50,7 @@ def render(data):
'record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) '
'verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, '
'countdown/completion events, a visible toast and persisted recents/subtle mode. '
'Only private test settings were written. Simple Timer remains partial because its own expired-timer startup '
'Only private test settings were written. That initial pass left Simple Timer partial because its own expired-timer startup '
'raises a missing `timer_task` error; sound/suspend are unsupported. '
'A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer '
'through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, '
@@ -80,7 +80,29 @@ def render(data):
'Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), '
'[Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), '
'[Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and '
'[legacy MusicControl](evidence/framey-legacy-2026-10-25.md).', '',
'[legacy MusicControl](evidence/framey-legacy-2026-10-25.md). '
'A [shared Music Control runtime follow-up](evidence/framey-mpris-runtime-2026-10-26.md) corrects the earlier bound-method fixture error: actual Decky uses the legacy class calling convention, so explicit-self calls must stay. '
'Exact managed installs also restore the system library path for D-Bus subprocesses, repairing a real Frame OS libtinfo/glibc failure in MusicControl 1.1.6. '
'Both official entries pass transport/seek/volume, Info and reopen in local Chromium with actual Decky 3.3.0 amd64 and a private silent MPRIS service. '
'Actual ARM64 Decky on the Frame passes read-only empty-player discovery for both. Existing copies need remove/reinstall or an explicit managed update. '
'Actual media playback on ARM64, artwork and worn VR remain unverified; both stay partial. '
'A [DeckySpy follow-up](evidence/framey-deckyspy-2026-10-26.md) repairs its omitted psutil dependency with pinned BSD stable-ABI amd64/ARM64 wheels and a plugin-local Frame battery-name fallback. '
'Its actual Decky 3.3.0 ARM64 backend passed read-only metrics under the shared lock, with memory independently matched to /proc/meminfo. '
'Local opaque Chromium used that live ARM64 backend to render CPU, memory, battery, processes and interfaces, display a silent warning toast and save/reopen a private refresh setting. '
'DeckySpy qualifies as works for system monitoring while its panel is open. Automatic alerts, resume/game-time warnings, sound and worn VR remain unverified. '
'Cleanup and an independent removal audit passed; battery stayed 100 → 100 and original service states were preserved. Existing copies need update/reinstall. '
'A [Simple Timer lifecycle follow-up](evidence/framey-simple-timer-2026-10-26.md) repairs fresh/expired startup cancellation, '
'canceled deadlines that resumed and replaced tasks that still notified. Exact managed installs retain the API1 instance convention. '
'Published Decky 3.3.0 discovery/RPC/events verify cancel, replacement, completion and active/canceled/expired restarts in isolation; '
'the real ARM64 Frame repeats those lifecycle checks under the shared lock. Local Chromium with that live backend verifies '
'Begin/Cancel, countdown and a silent completion toast. Simple Timer qualifies as works for silent in-panel session timers. '
'Update/reinstall is required; old receipts stay partial. Sound/suspend are unsupported, closed-panel alerts and worn VR unverified. '
'The private service, files, test listeners and processes were removed; independent cleanup audit passed and battery stayed 99 → 99. '
'A [shared Toggle follow-up](evidence/framey-toggle-2026-10-27.md) separates standalone Toggle.value from ToggleField.checked. '
'Nine official archives consume this API; their remaining blockers and status labels are preserved. '
'Local opaque Chromium controls and the exact Pause Games frontend callback pass. Actual published Decky 3.3.0 discovery and '
'private settings/exclusion-list persistence pass on amd64 and the ARM64 Frame through the normal CEF bridge. '
'Process control and worn VR remain unverified; independent cleanup audit passed.', '',
'## Every current plugin', '',
'The API column shows manifest / actual bundled frontend API. Many manifests say v1 while their bundle connects API2. '
'Missing manifest API is labelled legacy; an unrecognized bundle is unknown. Globals and hardware are static signals, not exhaustive dependencies.', '',
@@ -101,11 +123,21 @@ def render(data):
p['backend']+('; '+', '.join(native) if native else ''),p['hardware']]))+' |')
lines += ['', '## Native payload portability', '',
'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. The installer still rejects these archives. '
'Apart from the explicitly described LocalSend experiment, ARM64 builds were not attempted: the constraints below remain unresolved.', '',
'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.', '',
'[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) '
'covers a reproducible MIT helper build and its isolated real-Frame protocol 2.0 info response. '
'The official x86 archive remains blocked; full plugin integration and file transfers are unverified.', '',
'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)).', '',
'| Plugin | Native payloads | ARM64 result / remaining blocker |', '| --- | --- | --- |']
for p in data['plugins']:
if p['native']:
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Run every managed backend shim in native, isolated, published Decky 3.3.0.
Downloads a digest-pinned loader, or accepts --loader for an offline cached copy.
Only image build/download use network. All official plugin execution is in
network/device/session-isolated disposable Docker containers. Never uses Frame.
"""
import argparse
import ast
import hashlib
import json
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import uuid
ROOT = Path(__file__).resolve().parents[1]
RELEASE = 'v3.3.0'
LOADERS = {
'x86_64': ('PluginLoader', 'e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181', 62),
'aarch64': ('PluginLoader-arm64', '8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6', 183),
}
def check_coverage():
"""A new production shim must declare an actual-runtime probe before CI runs."""
sys.path.insert(0, str(ROOT/'ui'))
import frame_decky_store
tree = ast.parse(frame_decky_store.APPLY)
shims = set()
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == 'compat_shims' and node.func.attr == 'append'):
if len(node.args) != 1 or not isinstance(node.args[0], ast.Constant) or not isinstance(node.args[0].value, str):
raise ValueError('Managed shims must declare a literal name for runtime test 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}:
raise ValueError('Every managed shim needs an actual Decky runtime case')
return cases
def loader_bytes(path):
arch = platform.machine()
if arch not in LOADERS:
raise ValueError('Run on native Linux x86_64 or aarch64')
asset, digest, machine = LOADERS[arch]
if path:
data = path.read_bytes()
else:
url = f'https://github.com/SteamDeckHomebrew/decky-loader/releases/download/{RELEASE}/{asset}'
request = urllib.request.Request(url, headers={'User-Agent': 'Frame-Control-runtime-tests'})
with urllib.request.urlopen(request, timeout=60) as response:
data = response.read(80*1024*1024+1)
if hashlib.sha256(data).hexdigest() != digest:
raise ValueError('Decky 3.3.0 loader digest mismatch')
if data[:4] != b'\x7fELF' or int.from_bytes(data[18:20], 'little') != machine:
raise ValueError('Loader architecture does not match the native runner')
return data, digest
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)
args = parser.parse_args()
if sys.platform != 'linux' or not shutil.which('docker'):
parser.error('Linux with Docker is required')
cases = check_coverage()
data, digest = loader_bytes(args.loader)
tag = 'framey-decky-runtime:'+uuid.uuid4().hex[:12]
report = {'decky': RELEASE, 'architecture': platform.machine(), 'loaderSha256': digest, 'cases': []}
try:
subprocess.run(['docker', 'build', '-q', '-t', tag, '-f', str(ROOT/'tests/decky-runtime/Containerfile'),
str(ROOT/'tests/decky-runtime')], check=True, timeout=300)
with tempfile.TemporaryDirectory(prefix='framey-decky-runtime-') as temp:
loader = Path(temp)/'PluginLoader'
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')]
for plugin_id, negative in probes:
name = 'framey-decky-'+uuid.uuid4().hex[:12]
command = ['docker', 'run', '--rm', '--name', name, '--network', 'none', '--read-only',
'--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '128',
'--tmpfs', '/tmp:rw,exec,mode=1777',
'--tmpfs', '/home/tester:rw,exec,uid=1100,gid=1100,mode=700',
'--tmpfs', '/run/user/1100:rw,uid=1100,gid=1100,mode=700',
'--mount', f'type=bind,source={ROOT},target=/repo,readonly',
'--mount', f'type=bind,source={loader},target=/input/PluginLoader,readonly',
tag, str(plugin_id)]
if negative:
command.append(negative)
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
if result.returncode:
raise RuntimeError(f'Decky runtime case {plugin_id} failed:\n{result.stdout}\n{result.stderr}')
observation = json.loads(result.stdout)
report['cases'].append(observation)
print(f"PASS {plugin_id}: {observation['result']}", flush=True)
finally:
subprocess.run(['docker', 'rm', '-f', name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
finally:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2)+'\n')
subprocess.run(['docker', 'image', 'rm', tag], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if __name__ == '__main__':
main()
+28 -2
View File
@@ -71,13 +71,19 @@ def exercise(profile, plugin_id, folder):
if not hit:
raise RuntimeError('Native click hit another element: ' + str(panel.eval('({tag:__vrLastTarget?.tagName,text:__vrLastTarget?.textContent?.slice(0,100)})')))
def click_parent(selector):
def parent_point(selector):
parent.eval(f'document.querySelector({json.dumps(selector)}).scrollIntoView({{block:"center"}})')
time.sleep(.3)
pos = parent.eval(f'''(()=>{{let r=document.querySelector({json.dumps(selector)}).getBoundingClientRect();
return {{x:r.x+r.width/2,y:r.y+r.height/2,w:innerWidth,h:innerHeight}}}})()''')
sx, sy = vr.snapshot()['input']['scale']
x, y = pos['x'] * sx / pos['w'], sy - pos['y'] * sy / pos['h']
if not 0 <= x <= sx or not 0 <= y <= sy:
raise RuntimeError('Parent control is outside the visible viewport')
return x, y
def click_parent(selector):
x, y = parent_point(selector)
count = parent.eval('window.__vrParentClickCount')
vr.post_mouse(300, x, y)
vr.post_mouse(301, x, y, 1)
@@ -86,6 +92,21 @@ def exercise(profile, plugin_id, folder):
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
raise RuntimeError('Native click missed parent control ' + selector)
def hold_parent(selector, seconds):
x, y = parent_point(selector)
count = parent.eval('window.__vrParentClickCount')
vr.post_mouse(300, x, y)
try:
vr.post_mouse(301, x, y, 1)
time.sleep(seconds)
offset = panel.eval('document.getElementById("vr-scroll-probe").scrollTop')
finally:
vr.post_mouse(302, x, y, 1)
wait(lambda:parent.eval('window.__vrParentClickCount'), lambda n:n > count, seconds=3)
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
raise RuntimeError('Native hold missed parent control ' + selector)
return offset
try:
parent.eval(f'(async()=>{{await refreshDecky();openFrameyPanel({plugin_id},"Framey VR Check");return true}})()')
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
@@ -172,6 +193,10 @@ def exercise(profile, plugin_id, folder):
result['buttonScrollAfterUp'] = wait(
lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n == 0)
result['buttonScrollMoved'] = result['buttonScrollAfterDown'] > 0 and result['buttonScrollAfterUp'] == 0
result['holdScrollAfterDown'] = hold_parent('#frameyScrollDown', 1.2)
result['holdScrollAfterUp'] = hold_parent('#frameyScrollUp', 2.0)
result['holdScrollMoved'] = (result['holdScrollAfterDown'] > result['buttonScrollAfterDown']
and result['holdScrollAfterUp'] == 0)
panel.eval('document.getElementById("vr-scroll-probe").remove()')
panel.eval('document.getElementById("vr-controls").remove()')
# Scroll saved status into the visible iframe for compositor evidence.
@@ -179,7 +204,8 @@ def exercise(profile, plugin_id, folder):
result['finalOverlay'] = vr.snapshot()
result['passed'] = (result['trustedOverlayPointer'] and result['diskSetting'] == {'enabled':True}
and result['reopenedChecked'] and result['sliderValue'] != '10'
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved'])
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved']
and result['holdScrollMoved'])
return result
except Exception as exc:
result['error'] = str(exc)
+11
View File
@@ -31,6 +31,11 @@ trap 'rm -rf "$tmp"' EXIT
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 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 release, drafts included, one JSON object per line.
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
# The release whose tag_name is the tag; failing that, the one untagged-... draft
@@ -56,6 +61,12 @@ json.dump(r, sys.stdout)
EOF
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
python3 - "$tmp/release.json" <<'EOF'
import json, sys
release = json.load(open(sys.argv[1]))
if (release.get("body") or "").lstrip().startswith("> **WITHDRAWN"):
sys.exit("not publishing: this release is marked WITHDRAWN")
EOF
missing=""
for name in $expected; do
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Refuse publication unless the exact tag commit and tagged installers passed CI.
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
gates documented in docs/releasing.md.
"""
import json
import subprocess
import sys
from urllib.parse import quote
REPO = "saphid/frame-control"
MAIN_JOBS = {
"checks", "e2e", "ios", "wireless-windows",
"server-tests (ubuntu-latest, 3.13)",
"server-tests (macos-latest, 3.12)",
"server-tests (windows-latest, 3.12)",
}
RELEASE_JOBS = {
"draft", "checksums",
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
}
DECKY_JOBS = {"decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)"}
class GateError(Exception):
pass
def api(path):
# publish-release.sh already requires sh. Resolve gh through that same
# shell: on Git Bash, Python's Windows CreateProcess skips an extensionless
# test gh shim in PATH and accidentally launches the real gh.exe instead.
command = ["sh", "-c", 'gh api "$1"', "sh", f"repos/{REPO}/{path}"]
result = subprocess.run(command, text=True, capture_output=True, check=False)
if result.returncode:
raise GateError(f"GitHub gate read failed for {path}: {result.stderr.strip()}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as error:
raise GateError(f"invalid GitHub gate response for {path}: {error}") from error
def tag_commit(tag):
target = api(f"git/ref/tags/{quote(tag, safe='')}").get("object") or {}
for _ in range(5):
kind, sha = target.get("type"), target.get("sha")
if not isinstance(sha, str) or len(sha) != 40:
raise GateError(f"{tag} has no valid tag target")
if kind == "commit":
return sha
if kind != "tag":
raise GateError(f"{tag} points to unsupported Git object {kind!r}")
target = api(f"git/tags/{sha}").get("object") or {}
raise GateError(f"{tag} has too many nested tag objects")
def exact_run(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 []
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"])
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
def successful_jobs(run, required):
attempt = run.get("run_attempt")
if not isinstance(attempt, int) or attempt < 1:
raise GateError(f"run {run['id']} has no valid attempt number")
latest_jobs = None
for number in range(1, attempt + 1):
data = api(f"actions/runs/{run['id']}/attempts/{number}/jobs?per_page=100")
jobs = data.get("jobs") or []
if data.get("total_count") != len(jobs):
raise GateError(f"run {run['id']} attempt {number} has more jobs than the gate inspected")
names = [job.get("name") for job in jobs]
if not jobs or len(names) != len(set(names)):
raise GateError(f"run {run['id']} attempt {number} has missing or duplicate jobs")
bad = [f"{job.get('name')}: {job.get('status')}/{job.get('conclusion')}"
for job in jobs if job.get("status") != "completed" or job.get("conclusion") != "success"]
if bad:
raise GateError(f"run {run['id']} attempt {number} has unfinished, failed, cancelled, or skipped jobs: {bad}")
latest_jobs = jobs
names = [job.get("name") for job in latest_jobs]
if not required.issubset(names):
missing = sorted(required.difference(names))
raise GateError(f"run {run['id']} has missing jobs in its latest attempt: {missing}")
print(f"gate passed: {run.get('html_url')} ({len(latest_jobs)} jobs, {attempt} attempts)")
def main(tag):
if not tag.startswith("v") or not tag[1:]:
raise GateError("expected a version tag beginning with v")
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)
print(f"exact release commit: {sha}")
if __name__ == "__main__":
try:
main(sys.argv[1])
except (IndexError, GateError) as error:
sys.exit(f"release gate failed: {error}")
+12
View File
@@ -0,0 +1,12 @@
# Native amd64 or ARM64; no emulation, host session bus, audio, devices or home.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-dbus python3-gi python3-websockets dbus-bin dbus-daemon \
ca-certificates zlib1g \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -u 1100 -s /bin/sh tester \
&& mkdir -p /run/user/1100 && chown tester:tester /run/user/1100
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 XDG_RUNTIME_DIR=/run/user/1100
USER tester
WORKDIR /repo
ENTRYPOINT ["python3", "/repo/tests/decky-runtime/check.py"]
+42
View File
@@ -0,0 +1,42 @@
[
{
"id": 13,
"shim": null,
"probe": "toggle-settings"
},
{
"id": 12,
"shim": "music-control-system-dbus-env",
"probe": "mpris"
},
{
"id": 111,
"shim": "music-control-system-dbus-env",
"probe": "mpris"
},
{
"id": 53,
"shim": "shotty-click-app-dir",
"probe": "screenshots"
},
{
"id": 89,
"shim": "deckyspy-psutil-and-battery",
"probe": "system-monitor"
},
{
"id": 96,
"shim": "simple-timer-lifecycle",
"probe": "timer-lifecycle"
},
{
"id": 60,
"shim": "deckyfileserver-user-native",
"probe": "fileserver"
},
{
"id": 125,
"shim": "localsend-digest-native",
"probe": "localsend"
}
]
+326
View File
@@ -0,0 +1,326 @@
"""Container-only real Decky backend discovery and smoke tests; no host services."""
import argparse
import asyncio
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import socket
import ssl
import subprocess
import sys
import time
import urllib.request
import zipfile
import io
import websockets
from websockets.exceptions import WebSocketException
ROOT = Path('/repo')
sys.path[:0] = [str(ROOT/'ui'), str(ROOT/'tests')]
import frame_decky_store
from framey_official import PLUGINS, archive
async def request(route, args):
token = urllib.request.urlopen('http://127.0.0.1:1337/auth/token', timeout=2).read().decode()
async with websockets.connect('ws://127.0.0.1:1337/ws?auth='+token,
open_timeout=3, close_timeout=2) as ws:
await ws.send(json.dumps({'type': 0, 'id': 1, 'route': route, 'args': args}))
while True:
response = json.loads(await asyncio.wait_for(ws.recv(), timeout=8))
if response.get('id') != 1:
continue
await ws.send(json.dumps({'type': 3, 'id': 1}))
assert response['type'] == 1, response
return response['result']
def rpc(route, *args):
return asyncio.run(request(route, list(args)))
def until_ready(read, description):
deadline = time.monotonic()+20
last = None
while time.monotonic() < deadline:
try:
value = read()
if value:
return value
except (OSError, ValueError, KeyError, TimeoutError, WebSocketException) as error:
last = str(error)
time.sleep(.1)
raise RuntimeError(f'{description} not ready: {last}')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('id', type=int)
parser.add_argument('--reproduce-bound-method-regression', action='store_true')
parser.add_argument('--reproduce-fileserver-empty-flag-regression', action='store_true')
parser.add_argument('--reproduce-timer-regression', action='store_true')
args = parser.parse_args()
assert os.getuid() == 1100 and ROOT.is_dir(), 'Run through framey-test-decky-runtime.py'
record = next(p for p in PLUGINS if p['id'] == args.id)
case = next(p for p in json.loads((ROOT/'tests/decky-runtime/cases.json').read_text()) if p['id'] == args.id)
data = archive(record)
assert hashlib.sha256(data).hexdigest() == record['sha256']
assert frame_decky_store.archive_info(data, record['sha256'], record['name']) == record['folder']
home = Path.home()
base = home/'.local/share/frame-control/decky'
(base/'plugins').mkdir(parents=True)
unit = home/'.config/systemd/user/frame-control-decky.service'
unit.parent.mkdir(parents=True)
unit.write_text('# Private fixture marker; no real service manager is contacted.\n')
tools = home/'bin'
tools.mkdir()
systemctl = tools/'systemctl'
systemctl.write_text('#!/bin/sh\ncase "$*" in *is-active*) exit 3;; *) exit 0;; esac\n')
systemctl.chmod(0o700)
env = dict(os.environ, PATH=str(tools)+':'+os.environ['PATH'])
Path('/tmp/frame-control-decky-plugin-runtime-test').write_bytes(data)
install = subprocess.run([sys.executable, '-', 'install', 'runtime-test', str(record['id']),
record['sha256'], record['folder'], record['version'], '-'], input=frame_decky_store.apply_script(record['id'], record['sha256']),
capture_output=True, text=True, env=env, timeout=15)
assert install.returncode == 0, install.stderr
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
else:
assert record['id'] == 96
with zipfile.ZipFile(io.BytesIO(data)) as original:
(plugin/'main.py').write_bytes(original.read(record['folder']+'/main.py'))
# Exercise the production manifest's API convention, never import Plugin ourselves.
manifest = json.loads((plugin/'plugin.json').read_text())
assert manifest.get('api_version', 'legacy') == record['api_version'], manifest
assert '_root' not in manifest.get('flags', []), manifest
if args.reproduce_fileserver_empty_flag_regression:
assert record['id'] == 60
source = plugin/'main.py'
source.write_text(source.read_text().replace('[arg for arg in [', '[').replace('] if arg],', '],'))
if args.reproduce_bound_method_regression:
assert record['id'] == 111
source = (plugin/'main.py').read_text()
for old, new in [('self._get_dbus_env(self)', 'self._get_dbus_env()'),
('self._sp_player_dbus(self,', 'self._sp_player_dbus(')]:
assert old in source
source = source.replace(old, new)
(plugin/'main.py').write_text(source)
(home/'.local/share/Steam/steamui/images').mkdir(parents=True)
loader = base/'PluginLoader'
shutil.copy('/input/PluginLoader', loader)
loader.chmod(0o700)
env.update(UNPRIVILEGED_PATH=str(base), PRIVILEGED_PATH=str(base), CHOWN_PLUGIN_PATH='0',
LIVE_RELOAD='0', USE_CEF_CLOSE_WORKAROUND='0', KEEP_SYSTEMD_SERVICE='1',
DBUS_SESSION_BUS_ADDRESS='unix:path=/run/user/1100/bus')
processes = []
report = {'id': record['id'], 'name': record['name'], 'version': record['version'],
'sha256': record['sha256'], 'shim': case['shim'], 'calls': []}
with (home/'runtime.log').open('w+') as log:
try:
if case['probe'] == 'mpris':
bus = subprocess.Popen(['dbus-daemon', '--session', '--nofork',
'--address='+env['DBUS_SESSION_BUS_ADDRESS']], env=env, stdout=log, stderr=log,
start_new_session=True)
processes.append(bus)
until_ready(lambda: Path('/run/user/1100/bus').is_socket(), 'private session bus')
player = subprocess.Popen([sys.executable, str(ROOT/'tests/fixtures/framey-mpris/player.py')],
env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(player)
until_ready(lambda: Path('/tmp/mpris-state.json').is_file(), 'silent MPRIS player')
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log, start_new_session=True)
processes.append(backend)
discovered = until_ready(lambda: rpc('loader/get_plugins'), 'published Decky plugin discovery')
assert len(discovered) == 1 and discovered[0]['name'] == record['name'], discovered
assert discovered[0]['version'] == record['version'] and not discovered[0]['disabled'], discovered
report['discovered'] = discovered
def legacy(method, **kwargs):
response = rpc('loader/call_legacy_plugin_method', record['name'], method, kwargs)
assert response['success'] is True, (method, response)
report['calls'].append({'method': method, 'result': response['result']})
return response['result']
def current(method, *values):
response = rpc('loader/call_plugin_method', record['name'], method, *values)
report['calls'].append({'method': method, 'result': response})
return response
if case['probe'] == 'localsend':
helper = plugin/'bin/localsend-core'
expected = ('e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
if __import__('platform').machine() == 'aarch64'
else 'd448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843')
assert hashlib.sha256(helper.read_bytes()).hexdigest() == expected
status = current('get_backend_status')
assert status == {'running': False, 'url': 'https://127.0.0.1:53317'}, status
settings = current('get_backend_config')
assert settings['use_https'] and not settings['auto_save'], settings
assert settings['network_interface'] == '*', settings
assert settings['download_folder'].startswith(str(base)), settings
stopped = current('proxy_get', '/api/localsend/v2/info')
assert stopped['status'] == 503 and stopped['data']['error'] == 'Backend not running', stopped
report['result'] = 'published Decky discovery and public read-only settings/status RPC passed; network helper remained stopped'
elif case['probe'] == 'fileserver':
assert until_ready(lambda: current('get_port') == 8000, 'default port')
assert current('get_directory') == str(home)
assert current('get_server_running') is False
fixture = home/'public-fixture'
fixture.mkdir()
marker = b'Framey public read-only fixture\n'
(fixture/'marker.txt').write_bytes(marker)
# A thumbnail-compatible file must remain a normal link when disabled.
(fixture/'image.png').write_bytes(b'public placeholder; thumbnails disabled')
with socket.socket() as listener:
listener.bind(('127.0.0.1', 0))
port = listener.getsockname()[1]
status = current('set_status', {'directory': str(fixture), 'port': port, 'timeout': 1,
'allow_uploads': False, 'disable_thumbnails': True, 'server_running': True})
assert status['server_running'] and not status['allow_uploads'] and status['disable_thumbnails'], status
context = ssl._create_unverified_context() # Upstream ships its public self-signed test cert.
url = f'https://127.0.0.1:{port}'
def fetch(path):
return urllib.request.urlopen(url+path, context=context, timeout=3).read()
listing = until_ready(lambda: fetch('/files/'), 'private file server')
assert b'marker.txt' in listing and b'image.png' in listing, listing
assert (b'/preview/files/image.png' in listing) == args.reproduce_fileserver_empty_flag_regression, listing
assert fetch('/files/marker.txt') == marker
try:
fetch('/upload')
raise AssertionError('uploads must be disabled')
except urllib.error.HTTPError as error:
assert error.code == 401, error.code
assert current('set_server_running', False) is False
assert current('get_server_running') is False
assert current('get_history') == [str(fixture)]
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'] == 'toggle-settings':
def call(method, *values):
result = rpc('loader/call_plugin_method', record['name'], method, *values)
report['calls'].append({'method': method, 'result': result})
return result
assert call('load_settings') == {}
# Settings only. Never call pause/resume/kill or signal a process.
for value in (True, False, True):
call('save_setting', 'autoPause', value)
assert call('load_settings')['autoPause'] is value
call('add_no_auto_pause_set', 424242)
assert call('load_settings')['noAutoPauseSet'] == [424242]
call('remove_no_auto_pause_set', 424242)
assert call('load_settings')['noAutoPauseSet'] == []
settings = json.loads((base/'settings'/record['folder']/'settings.json').read_text())
assert settings['autoPause'] is True and settings['noAutoPauseSet'] == []
report['result'] = 'discovery and Toggle-backed private settings persistence passed; no process actions'
elif case['probe'] == 'timer-lifecycle':
from timer_lifecycle import probe
def restart(settings=None):
nonlocal backend
os.killpg(backend.pid, signal.SIGTERM)
backend.wait(timeout=8)
if settings is not None:
path = base/'settings'/record['folder']/'settings.json'
assert path.exists(), path
path.write_text(json.dumps(settings))
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')
until_ready(lambda: rpc('loader/call_plugin_method', record['name'],
'settings_getSetting', 'subtle_mode', False), 'restarted instance RPC')
report['lifecycle'] = probe(record['name'], restart, home/'runtime.log',
args.reproduce_timer_regression)
report['result'] = ('upstream timer lifecycle regressions reproduced' if args.reproduce_timer_regression
else 'discovery, cancel, replace, completion and persistent restart passed')
elif args.reproduce_bound_method_regression:
response = rpc('loader/call_legacy_plugin_method', record['name'], 'sp_list_media_players', {})
assert response['success'] is False and 'self' in response['result'], response
report['expectedRegression'] = response
report['result'] = 'historical bad shim rejected by actual Decky legacy dispatch'
elif case['probe'] == 'mpris':
player_name = 'org.mpris.MediaPlayer2.FrameySilent'
assert legacy('sp_list_media_players') == player_name
legacy('sp_set_media_player', player=player_name)
assert legacy('sp_get_media_player') == player_name
assert legacy('sp_identity', orgPath=player_name) == 'Player' # Upstream truncation.
metadata = legacy('get_meta_data')
assert 'title|Silent Test Track' in metadata and 'length|180000000' in metadata, metadata
assert legacy('sp_track_status') == 'Paused'
assert legacy('sp_track_progress') == '12000000'
assert legacy('sp_can_seek') == 'true'
assert float(legacy('sp_get_volume')) == .4
assert legacy('sp_test_volume_control') == 'true'
assert json.loads(Path('/tmp/mpris-state.json').read_text())['Volume'] == .4
legacy('sp_play')
assert legacy('sp_track_status') == 'Playing'
legacy('sp_pause')
legacy('sp_next')
legacy('sp_previous')
legacy('sp_seek', amount=2000000)
assert legacy('sp_track_progress') == '14000000'
legacy('sp_set_position', position=45000000, trackid='/org/framey/TestTrack/1')
legacy('sp_set_volume', volume=.25)
state = json.loads(Path('/tmp/mpris-state.json').read_text())
assert state['PlaybackStatus'] == 'Paused' and state['Volume'] == .25, state
assert state['next'] == state['previous'] == 1 and state['Position'] == 45000000, state
report['independentState'] = state
report['result'] = 'discovery, metadata, transport, seek and volume passed'
elif case['probe'] == 'system-monitor':
def metric(method):
response = legacy(method)
if response['code'] != 0:
raise ValueError(response['data'])
return json.loads(response['data'])
assert legacy('get_version') == {'code': 0, 'data': record['version']}
memory = until_ready(lambda: metric('get_memory'), 'DeckySpy stats thread')
assert memory['vmem']['total'] > 0 and 0 <= memory['vmem']['percent'] <= 100, memory
cpu = metric('get_cpu')
assert 0 <= cpu <= 100, cpu
assert metric('get_boottime') > 0
assert isinstance(metric('get_battery')['battery'], bool)
assert isinstance(metric('get_net_interface'), list)
assert isinstance(legacy('get_settings', key='framey.runtime-test', default=True)['data'], bool)
report['result'] = 'discovery, native psutil import and read-only system metrics passed'
else:
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'
log.flush()
log.seek(0)
assert 'Starting Decky version v3.3.0' in log.read()
print(json.dumps(report))
except Exception:
log.flush()
log.seek(0)
print(log.read()[-12000:], file=sys.stderr)
raise
finally:
for process in reversed(processes):
if process.poll() is None:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
continue
try:
process.wait(timeout=4)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=4)
if __name__ == '__main__':
main()
+148
View File
@@ -0,0 +1,148 @@
"""Simple Timer lifecycle through actual Decky instance dispatch, never Plugin()."""
import asyncio
import json
import time
import urllib.request
import websockets
class Client:
def __init__(self, name):
self.name = name
self.sequence = 0
self.events = []
async def __aenter__(self):
token = urllib.request.urlopen('http://127.0.0.1:1337/auth/token', timeout=2).read().decode()
self.ws = await websockets.connect('ws://127.0.0.1:1337/ws?auth='+token)
return self
async def __aexit__(self, *_):
await self.ws.close()
async def receive(self, timeout=8):
response = json.loads(await asyncio.wait_for(self.ws.recv(), timeout))
if response.get('type') == 5 and response.get('event') == 'loader/plugin_event':
event = response['args'][0]
if event['plugin'] == self.name:
self.events.append(event)
return response
async def call(self, method, *args, success=True):
self.sequence += 1
await self.ws.send(json.dumps({'type': 0, 'id': self.sequence,
'route': 'loader/call_plugin_method', 'args': [self.name, method, *args]}))
while True:
response = await self.receive()
if response.get('id') != self.sequence:
continue
await self.ws.send(json.dumps({'type': 3, 'id': self.sequence}))
if success:
assert response['type'] == 1, response
return response['result']
return response
async def seconds(self):
offset = len(self.events)
await self.call('load_remaining_seconds')
while not any(e['event'] == 'simple_timer_seconds_updated' for e in self.events[offset:]):
await self.receive()
return next(e['args'][0] for e in reversed(self.events)
if e['event'] == 'simple_timer_seconds_updated')
async def drain(self, seconds):
deadline = time.monotonic()+seconds
while time.monotonic() < deadline:
try:
await self.receive(max(.01, deadline-time.monotonic()))
except TimeoutError:
break
async def initial(name, negative):
async with Client(name) as client:
await client.call('set_subtle_mode', True) # No sound or physical actions.
response = await client.call('cancel_timer', success=False)
if negative:
assert response['type'] == -1 and 'timer_task' in str(response), response
else:
assert response['type'] == 1, response
await client.call('start_timer', 60)
end = await client.call('settings_getSetting', 'timer_end', None)
assert end > time.time()
await client.call('cancel_timer')
saved = await client.call('settings_getSetting', 'timer_end', None)
assert (saved is not None) if negative else (saved is None), saved
await client.call('start_timer', 1)
await client.call('start_timer', 60)
await client.drain(6.5) # Past upstream's 5-second tick and first timer's expiry.
completed = [e for e in client.events if e['event'] == 'simple_timer_event']
if negative:
assert completed, client.events
else:
assert not completed, completed
assert await client.seconds() > 45
await client.call('cancel_timer')
if negative:
return {'freshCancelError': response, 'cancelRetainsEnd': True,
'replacedTimerStillFires': True}
await client.call('start_timer', 1)
client.events.clear()
await client.drain(5.5)
completed = [e for e in client.events if e['event'] == 'simple_timer_event']
assert len(completed) == 1 and completed[0]['args'] == ['Your session has ended!', True], completed
assert await client.seconds() == 0
assert await client.call('settings_getSetting', 'timer_end', None) is None
client.events.clear()
await client.call('start_timer', -20) # An already expired, private silent deadline.
await client.drain(.3)
expired = [e for e in client.events if e['event'] == 'simple_timer_event']
assert len(expired) == 1 and expired[0]['args'] == ['Your timer has expired!', True], expired
assert await client.call('settings_getSetting', 'timer_end', None) is None
await client.call('start_timer', 60)
return {'end': await client.call('settings_getSetting', 'timer_end', None),
'recents': await client.call('settings_getSetting', 'recent_timers_seconds', [])}
async def resumed(name, saved):
async with Client(name) as client:
assert 0 < await client.seconds() < 60
assert await client.call('settings_getSetting', 'timer_end', None) == saved['end']
assert await client.call('settings_getSetting', 'recent_timers_seconds', []) == saved['recents']
await client.call('cancel_timer')
assert await client.call('settings_getSetting', 'timer_end', None) is None
async def idle(name):
async with Client(name) as client:
assert await client.seconds() == 0
assert await client.call('settings_getSetting', 'timer_end', None) is None
await client.call('cancel_timer') # Idempotent before any new timer.
assert await client.call('settings_getSetting', 'subtle_mode', False) is True
def probe(name, restart, log, negative=False):
saved = asyncio.run(initial(name, negative))
if negative:
restart({'timer_end': time.time()-60, 'subtle_mode': True})
asyncio.run(read_after_startup(name))
assert "has no attribute 'timer_task'" in log.read_text(), log.read_text()[-6000:]
return saved | {'expiredRestartError': True}
restart()
asyncio.run(resumed(name, saved))
restart()
asyncio.run(idle(name))
restart({'timer_end': time.time()-60, 'subtle_mode': True})
asyncio.run(idle(name))
return {'freshCancel': True, 'replacementSuppressesOldCompletion': True,
'completionClearsEnd': True, 'lateExpiryEmitsWithoutSelfCancel': True,
'activeRestartPreservesDeadlineAndRecents': True,
'canceledRestartIdle': True, 'expiredRestartIdle': True}
async def read_after_startup(name):
async with Client(name) as client:
# _main is scheduled independently; allow it to finish before reading its journal.
await client.call('settings_getSetting', 'subtle_mode', False)
await client.drain(.3)
+38 -2
View File
@@ -107,9 +107,16 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertEqual((catalogue[0]['version'], catalogue[0]['sha256']), ('1.0.0', HASHES[0]))
status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':999})
self.assertEqual(status, 400) # Third-party code needs explicit acknowledgement.
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':999,'acknowledged':True}))
request = {'action':'install','id':999,'acknowledged':True,
'requestId':'0123456789abcdef' * 2}
started = ok('POST', '/api/decky/plugin', request)
self.assertEqual(ok('POST', '/api/decky/plugin', request)['job'], started['job'])
status, conflict, _ = api('POST', '/api/decky/plugin', {**request, 'id':998})
self.assertEqual(status, 409)
self.assertIn('already used', conflict['error'])
job = finished(started)
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('POST', '/api/decky/plugin', request)['job'], started['job'])
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
receipt = HASHES[0]
@@ -185,6 +192,9 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
if plugin['id'] == 96:
self.assertEqual(found[0]['compatShims'], ['simple-timer-lifecycle'])
self.assertEqual(found[0]['compatibility']['status'], 'works')
if plugin['id'] == 135:
chunk = 'chunks/pluginApi-B8sV7970.js'
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
@@ -192,6 +202,32 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertEqual(
ssh(f'sha256sum {installed}/dist/{chunk}').split()[0],
hashlib.sha256(expected_chunk).hexdigest())
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
arch = ssh('uname -m').strip()
self.assertEqual(ssh(f'sha256sum {installed}/bin/backend').split()[0],
frame_decky_store.FILESERVER_BINARIES[arch])
if plugin['id'] == 125:
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['localsend-digest-native'])
arch = ssh('uname -m').strip()
self.assertEqual(ssh(f'sha256sum {installed}/bin/localsend-core').split()[0],
frame_decky_store.LOCALSEND_AARCH64 if arch == 'aarch64'
else frame_decky_store.LOCALSEND_ORIGINAL)
if plugin['id'] == 53:
self.assertEqual(ssh(f'test -f {installed}/py_modules/click.py && echo yes').strip(), 'yes')
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['shotty-click-app-dir'])
if plugin['id'] == 89:
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['deckyspy-psutil-and-battery'])
self.assertEqual(ssh(f'test -f {installed}/py_modules/psutil/_psutil_linux.abi3.so && test -f {installed}/py_modules/framey_battery.py && echo yes').strip(), 'yes')
if plugin['id'] in (12, 111):
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':plugin['id'],'acknowledged':True}))
self.assertFalse(job.get('error'), job)
+42 -1
View File
@@ -13,6 +13,7 @@ import os
import shutil
import subprocess
import sys
from urllib.parse import parse_qs
args = sys.argv[1:]
with open(os.environ["FAKEGH_LOG"], "a") as f:
@@ -49,6 +50,18 @@ while i < len(args):
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
gate = json.load(open(os.environ["FAKEGH_GATES"])) if os.environ.get("FAKEGH_GATES") else {}
commit = gate.get("tag_commit", "a" * 40)
tag_object = "b" * 40
main_jobs = ("checks", "e2e", "ios", "wireless-windows",
"server-tests (ubuntu-latest, 3.13)", "server-tests (macos-latest, 3.12)",
"server-tests (windows-latest, 3.12)")
release_jobs = ("draft", "checksums",
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)")
decky_jobs = ("decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)")
def answer(response, fallback):
@@ -67,7 +80,35 @@ if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
tag = endpoint.rsplit("/", 1)[1]
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
sys.exit("gh: Not Found (HTTP 404)")
print(json.dumps({"ref": "refs/tags/" + tag}))
print(json.dumps({"ref": "refs/tags/" + tag,
"object": {"type": "tag", "sha": tag_object}}))
elif method == "GET" and endpoint == repo + "git/tags/" + tag_object:
print(json.dumps({"object": {"type": "commit", "sha": commit}}))
elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"):
path, _, query = endpoint.partition("?")
workflow = path.split("/")[5]
branch = parse_qs(query).get("branch", [""])[0]
is_main = workflow != "release.yml"
run_id = {"checks.yml": 501, "release.yml": 502, "decky-runtime.yml": 503}[workflow]
run = {"id": run_id, "head_sha": gate.get("main_sha" if is_main else "release_sha", commit),
"head_branch": branch, "event": "push", "run_attempt": gate.get("release_attempts", 1) if run_id == 502 else 1,
"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]}))
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 = [name for name in names if name != gate.get("omit_job")]
jobs = [{"name": name, "status": "completed",
"conclusion": (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")}
for name in names]
print(json.dumps({"total_count": len(jobs), "jobs": jobs}))
elif method == "GET" and endpoint.startswith(repo + "releases?"):
# The fallback is what --jq '.[]' prints.
answer(releases, "\n".join(json.dumps(r) for r in releases))
+21
View File
@@ -0,0 +1,21 @@
# Silent MPRIS runtime fixture
`player.py` implements the synthetic target used by the
[Music Control runtime evidence](../../../docs/evidence/framey-mpris-runtime-2026-10-26.md).
It produces no audio. It requires system Python `dbus`, `gi` and GLib, and writes
its independent control state to `/tmp/mpris-state.json`.
Run it only in a disposable container with **no network, devices, host home,
audio or D-Bus mounts**. Create a private session bus listening at
`/run/user/<container UID>/bus`: both official backends hard-code that path.
Set `DBUS_SESSION_BUS_ADDRESS` to that private socket for the fixture. Use a
private home including `.local/share/Steam/steamui/images`, because the upstream
plugins create an album-art symlink on startup. Never run these plugins against
a real user's player during this test: ID111 probes volume automatically.
Install each pinned archive separately through the managed transaction, then
start the published Decky 3.3.0 loader with private `UNPRIVILEGED_PATH` and
`PRIVILEGED_PATH`. Do not instantiate the legacy Plugin class: invoke methods
through Decky's `loader/call_legacy_plugin_method` route with its keyword object.
The two official entries share the `MusicControl` folder, so cannot coexist.
This fixture is opt-in and is not imported by ordinary unit tests.
+53
View File
@@ -0,0 +1,53 @@
"""Silent MPRIS target. Run only inside the private network/device-isolated container."""
import json
from pathlib import Path
import dbus, dbus.service, dbus.mainloop.glib
from gi.repository import GLib
P='org.mpris.MediaPlayer2.Player'
R='org.mpris.MediaPlayer2'
D='org.freedesktop.DBus.Properties'
TRACK='/org/framey/TestTrack/1'
class Player(dbus.service.Object):
def __init__(self,bus):
self.name=dbus.service.BusName('org.mpris.MediaPlayer2.FrameySilent',bus=bus)
super().__init__(bus,'/org/mpris/MediaPlayer2')
self.state={'PlaybackStatus':'Paused','Position':12000000,'Volume':0.4,'next':0,'previous':0,'writes':[]}
self.save()
def save(self):Path('/tmp/mpris-state.json').write_text(json.dumps(self.state))
@dbus.service.method(D,in_signature='ss',out_signature='v')
def Get(self,interface,prop):
if interface==R and prop=='Identity':return dbus.String('Framey Silent Player')
if interface==P:
if prop=='Metadata':return dbus.Dictionary({'mpris:trackid':dbus.ObjectPath(TRACK),'mpris:length':dbus.Int64(180000000),'xesam:title':dbus.String('Silent Test Track'),'xesam:artist':dbus.Array(['Fixture Artist'],signature='s'),'xesam:album':dbus.String('Framey Test Album')},signature='sv')
if prop=='PlaybackStatus':return dbus.String(self.state[prop])
if prop=='Position':return dbus.Int64(self.state[prop])
if prop=='Volume':return dbus.Double(self.state[prop])
if prop=='CanSeek':return dbus.Boolean(True)
raise dbus.exceptions.DBusException('Unknown property')
@dbus.service.method(D,in_signature='ssv',out_signature='')
def Set(self,interface,prop,value):
assert interface==P and prop=='Volume' and 0<=value<=1
self.state['Volume']=float(value);self.state['writes'].append(['Volume',float(value)]);self.save()
@dbus.service.method(P,in_signature='',out_signature='')
def PlayPause(self):
self.state['PlaybackStatus']='Playing' if self.state['PlaybackStatus']=='Paused' else 'Paused';self.state['writes'].append(['PlayPause']);self.save()
@dbus.service.method(P,in_signature='',out_signature='')
def Pause(self):
self.state['PlaybackStatus']='Paused';self.state['writes'].append(['Pause']);self.save()
@dbus.service.method(P,in_signature='',out_signature='')
def Next(self):
self.state['next']+=1;self.state['writes'].append(['Next']);self.save()
@dbus.service.method(P,in_signature='',out_signature='')
def Previous(self):
self.state['previous']+=1;self.state['writes'].append(['Previous']);self.save()
@dbus.service.method(P,in_signature='x',out_signature='')
def Seek(self,offset):
self.state['Position']+=int(offset);self.state['writes'].append(['Seek',int(offset)]);self.save()
@dbus.service.method(P,in_signature='ox',out_signature='')
def SetPosition(self,track,pos):
assert str(track)==TRACK and 0<=pos<=180000000
self.state['Position']=int(pos);self.state['writes'].append(['SetPosition',int(pos)]);self.save()
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
player=Player(dbus.SessionBus())
print('READY silent MPRIS fixture',flush=True)
GLib.MainLoop().run()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+32 -3
View File
@@ -1,14 +1,21 @@
# Pinned Decky store test archives
These are unmodified ZIPs downloaded from the official Decky store CDN on
2026-10-25. Each ZIP includes its upstream `LICENSE`. They are test data, not
plugins shipped or installed by Frame Control. The fake Frame tests serve them
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.
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.
| File | Official plugin | Version | Store SHA-256 |
| --- | --- | --- | --- |
| `13.zip` | Pause Games | 1.0.3 | `4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62` |
| `60.zip` | DeckyFileServer | 1.2.0 | `7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0` |
| `125.zip` | Decky Localsend | 0.4.5 | `e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7` |
| `96.zip` | Simple Timer | 1.0.0 | `973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312` |
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
| `111.zip` | Music Control | 1.1.7 | `3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9` |
| `14.zip` | ProtonDB Badges | 1.3.4 | `90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358` |
| `123.zip` | Steam Status | 1.2.3 | `9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e` |
| `50.zip` | Storage Cleaner | 1.4.0 | `1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591` |
@@ -17,9 +24,10 @@ from a local fixture so CI does not depend on the store being online.
| `36.zip` | SteamGridDB | 1.8.1 | `e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0` |
| `135.zip` | Deck Shelves | 2.0.0 | `b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91` |
The matching source projects are
The matching source projects include [Simple Timer](https://github.com/decktools-xyz/simple-timer) and
[CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader),
[MusicControl](https://github.com/nikurasu/MusicControl),
[Music Control](https://github.com/martinpl/music-control),
[ProtonDB Badges](https://github.com/bschelst/protondb-decky),
[Steam Status](https://github.com/bschelst/steamstatus-decky),
[Storage Cleaner](https://github.com/mcarlucci/decky-storage-cleaner),
@@ -38,3 +46,24 @@ Bluetooth result and limits are in
Deck Shelves' chunked frontend renders its settings in local Chromium with
backend/network calls denied. Its Steam Home shelves cannot be verified in the
isolated VR panel; no worn-headset test is claimed.
Simple Timer also runs through actual published Decky 3.3.0 discovery, instance
RPC and events in the native isolated runtime gate. Cancel/replace/completion and
active/canceled/expired backend restarts are regression checks; the unmodified
archive must reproduce its historical lifecycle errors. Only private silent
timers/settings are used. The upstream BSD license stays unchanged.
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.
DeckyFileServer also runs through actual Decky 3.3.0 discovery/API1 RPC with a
native helper replacement. HTTPS listing/download use only public markers in a
private directory; uploads remain disabled. The exact source build, GPL licenses
and reproducible recipe are in [the native assets](../../../ui/vendor/deckyfileserver-1.2.0/README.md).
The original empty optional-argument bug is reproduced by a negative runtime case.
Decky Localsend's exact original x86 backend/proxy passed an isolated private
marker transfer outside Decky. The native Decky gate checks discovery and
read-only backend status/configuration with its network helper stopped. The
managed ARM64 helper and transfer remain separately gated; the fixture does
not prove worn VR or a transfer on Frame.
+14
View File
@@ -4,7 +4,19 @@ from pathlib import Path
ROOT = Path(__file__).parent / 'fixtures/framey-official'
PLUGINS = (
{'id': 13, 'name': 'Pause Games', 'version': '1.0.3', 'api_version': 1,
'folder': 'SDH-PauseGames', 'sha256': '4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62'},
{'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,
'folder': 'decky-localsend', 'sha256': 'e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7'},
{'id': 89, 'name': 'DeckySpy', 'version': '0.6.7', 'api_version': 'legacy',
'folder': 'decky-spy', 'sha256': '2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7'},
{'id': 96, 'name': 'Simple Timer', 'version': '1.0.0', 'api_version': 1,
'folder': 'simple-timer', 'sha256': '973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312'},
{'id': 12, 'name': 'MusicControl', 'version': '1.1.6', 'api_version': 'legacy', 'folder': 'MusicControl', 'sha256': '246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62'},
{'id': 111, 'name': 'Music Control', 'version': '1.1.7', 'api_version': 'legacy',
'folder': 'MusicControl', 'sha256': '3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9'},
{'id': 14, 'name': 'ProtonDB Badges', 'version': '1.3.4', 'api_version': 2,
'folder': 'protondb-decky', 'sha256': '90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358'},
{'id': 123, 'name': 'Steam Status', 'version': '1.2.3', 'api_version': 2,
@@ -17,6 +29,8 @@ PLUGINS = (
'folder': 'hltb-for-deck', 'sha256': 'bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558'},
{'id': 36, 'name': 'SteamGridDB', 'version': '1.8.1', 'api_version': 1,
'folder': 'decky-steamgriddb', 'sha256': 'e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0'},
{'id': 53, 'name': 'Shotty', 'version': '0.1.0', 'api_version': 'legacy',
'folder': 'Shotty', 'sha256': 'ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe'},
{'id': 63, 'name': 'Free Loader', 'version': '1.6.0', 'api_version': 1,
'folder': 'free-loader', 'sha256': '05208aee6f6ba824391d8ddc81b1168b2599a9711e7528cdaa789f6cc710b42a'},
{'id': 135, 'name': 'Deck Shelves', 'version': '2.0.0', 'api_version': 1,
+64
View File
@@ -29,6 +29,10 @@ vm.runInContext(testSource,context);
// No Steam React tree exists inside the isolated Framey iframe.
assert.equal(window.DFL.getReactRoot({}),null);
assert.equal(window.DFL.findInReactTree(window.DFL.getReactRoot(null),()=>true),null);
const inertSteamComponent=window.DFL.fakeRenderComponent(undefined).type;
assert.equal(inertSteamComponent.name,'MissingSteamComponent');
assert.equal(window.DFL.fakeRenderComponent(undefined).type,inertSteamComponent);
assert.equal(typeof window.DFL.afterPatch(inertSteamComponent.prototype,'render',()=>{}).unpatch,'function');
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
const jsx=window.SP_JSX.jsx('p',{id:'message',children:'Saved'},'message-key');
assert.equal(jsx.type,'p');
@@ -42,6 +46,22 @@ const nodes=(tree,type)=>Array.isArray(tree)?tree.flatMap(child=>nodes(child,typ
[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))];
// Disabled controls stay inert even if a caller invokes their handlers directly.
let changed=0;
// DFL.Toggle is a standalone value-controlled input. ToggleField uses checked.
const toggleChanges=[];
for(const value of [true,false]) {
const input=nodes(window.DFL.Toggle({value,onChange:v=>toggleChanges.push(v)}),'input')[0];
assert.equal(input.props.checked,value,'Toggle must render its value prop');
input.props.onChange({target:{checked:!value}});
}
assert.deepEqual(toggleChanges,[false,true]);
const disabledToggle=nodes(window.DFL.Toggle({value:true,disabled:true,
onChange:()=>changed++}),'input')[0];
assert.equal(disabledToggle.props.checked,true);
assert.equal(disabledToggle.props.disabled,true);
disabledToggle.props.onChange({target:{checked:false}});
assert.equal(changed,0);
const fieldToggle=nodes(window.DFL.ToggleField({checked:true,value:false}),'input')[0];
assert.equal(fieldToggle.props.checked,true,'ToggleField must retain its checked contract');
const toggle=nodes(window.DFL.ToggleField({label:'Enabled',checked:true,disabled:true,
onChange:()=>changed++}),'input')[0];
const options=[{label:'Modes',options:[{label:'Slow',data:'slow'},{label:'Fast',data:'fast'}]}];
@@ -249,6 +269,14 @@ nodes(tabs,'button')[2].props.onClick();
assert.deepEqual(tabChanges,['one']);
assert.equal(window.DFL.Spinner({}).props.role,'status');
assert.equal(window.DFL.ProgressBar({nProgress:120}).props.value,100);
assert.equal(window.DFL.ProgressBar({nProgress:40,indeterminate:true}).props.value,undefined,
'an indeterminate progress element must omit its value');
assert.equal(window.DFL.ProgressBarItem({nProgress:35}).props.value,35);
const progressInfo=window.DFL.ProgressBarWithInfo({indeterminate:true,
sOperationText:'Downloading plugin'});
assert.equal(nodes(progressInfo,'progress')[0].props.value,undefined);
assert.equal(nodes(progressInfo,'small')[0].props.role,'status');
assert.equal(nodes(progressInfo,'small')[0].children[0],'Downloading plugin');
assert.equal(window.DFL.joinClassNames('one',null,'two'),'one two');
let eventCount=0;
const unsubscribe=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(()=>eventCount++);
@@ -460,11 +488,47 @@ assert.equal(window.DFL.useParams().view,'art');
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art/extra'),false);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123?admin=1'),false);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game//art'),false);
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(window.DFL.useParams().appid,'123');
assert.equal(window.DFL.useParams().view,undefined,'Back returns to the previous local page');
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(window.DFL.useParams().appid,undefined,'A second Back returns to the plugin root');
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true);
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(window.DFL.useParams().view,undefined,'Navigating to the same page did not add a history entry');
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(window.DFL.useParams().appid,undefined);
assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute));
assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
api.routerHook.addRoute('/plugin/other',LocalRoute);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
assert.equal(window.DFL.Navigation.Navigate('/plugin/other'),true);
removeGame();
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0,
'Back skips a route that the plugin unregistered');
assert.equal(window.DFL.useParams().appid,undefined);
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
api.routerHook.removeRoute('/plugin/other');
api.routerHook.addRoute('/plugin/first',LocalRoute);
api.routerHook.addRoute('/plugin/second',LocalRoute);
for(let index=0;index<40;index++)
assert.equal(window.DFL.Navigation.Navigate(index%2 ? '/plugin/first' : '/plugin/second'),true);
for(let index=0;index<32;index++)window.DFL.Navigation.NavigateBack();
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
window.DFL.Navigation.NavigateBack();
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0,
'local route history is bounded and falls back to the plugin root');
window.DFL.Navigation.Navigate('/plugin/first');
window.DFL.Navigation.Navigate('/plugin/second');
window.DFL.Navigation.CloseSideMenus();
window.DFL.Navigation.NavigateBack();
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0,
'CloseSideMenus clears the local route history');
api.routerHook.removeRoute('/plugin/first');
api.routerHook.removeRoute('/plugin/second');
// Event subscription is acknowledged before a method that immediately emits.
const onMessage=listeners.find(x=>x.type==='message').handler;
+50 -1
View File
@@ -5,6 +5,7 @@ import vm from 'node:vm';
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
const source=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
const elements=new Map(),listeners=[],requests=[],replies=[];
const timeouts=new Map(),intervals=new Map();let nextTimer=0;
const el=id=>{if(!elements.has(id))elements.set(id,{removeAttribute(){},scrollIntoView(){}});return elements.get(id);};
const frame={postMessage:data=>replies.push(data)};
el('frameyFrame').contentWindow=frame;
@@ -12,7 +13,10 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
location:{hash:'',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem:()=>null,setItem(){}},localStorage:{getItem:()=>null,setItem(){}},
window:{addEventListener:(type,fn)=>listeners.push({type,fn})},
setTimeout(){},setInterval(){},console,
setTimeout(fn){const id=++nextTimer;timeouts.set(id,fn);return id;},
clearTimeout(id){timeouts.delete(id);},
setInterval(fn){const id=++nextTimer;intervals.set(id,fn);return id;},
clearInterval(id){intervals.delete(id);},console,
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
vm.runInContext(source,context);
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};openFrameyPanel(96,"Timer")',context);
@@ -101,13 +105,58 @@ el('frameyScrollDown').onclick();
assert.equal(replies.at(-1).type,'framey:scroll');
assert.equal(replies.at(-1).session,scrollSession);
assert.equal(replies.at(-1).direction,'down');
const down=el('frameyScrollDown');
const beforeHold=replies.length;
down.onpointerdown({button:0,isPrimary:true,pointerId:7});
assert.equal(replies.length,beforeHold+1,'pointer press scrolls once');
down.onclick({detail:1});
assert.equal(replies.length,beforeHold+1,'pointer click must not scroll twice');
timeouts.get(nextTimer)();
assert.equal(intervals.size,1);
intervals.get(nextTimer)();
assert.equal(replies.length,beforeHold+2,'held pointer scrolls again');
down.onpointerup({pointerId:7});
assert.equal(intervals.size,0,'release stops scrolling');
down.onclick({detail:0});
assert.equal(replies.length,beforeHold+3,'keyboard click scrolls once');
await send({type:'framey:scroll:result',session:'old',direction:'down',moved:true});
assert.equal(el('frameyPanelStatus').textContent,'Loading plugin panel…');
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:true});
assert.equal(el('frameyPanelStatus').textContent,'Scrolled plugin down');
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
down.onpointerdown({button:0,isPrimary:true,pointerId:8});
timeouts.get(nextTimer)();
assert.equal(intervals.size,1);
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
assert.equal(intervals.size,0,'end of content stops held scrolling');
down.onpointerdown({button:0,isPrimary:true,pointerId:9});
const lastHoldTimer=nextTimer;
assert.equal(timeouts.has(lastHoldTimer),true);
listeners.find(({type})=>type==='blur').fn();
assert.equal(timeouts.has(lastHoldTimer),false,'loss of panel focus cancels a held scroll');
// WindowProxy also survives panel navigation for lifecycle and input messages.
// An old iframe must not mark a replacement ready, hide its error, or record
// controller evidence for the new panel.
const beforeStatusRequests=requests.length;
for(const type of ['framey:ready','framey:input','framey:error']) {
await send({type,session:'old',message:'old panel failed'});
await send({type,message:'missing session'});
}
assert.equal(requests.length,beforeStatusRequests);
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
await send({type:'framey:ready',session:scrollSession});
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel ready');
assert.equal(requests.at(-1).path,'/api/framey/check');
assert.equal(requests.at(-1).body.kind,'panel');
await send({type:'framey:input',session:scrollSession});
assert.equal(requests.at(-1).body.kind,'input');
await send({type:'framey:error',session:scrollSession,message:'current panel failed'});
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel unavailable: current panel failed');
down.onpointerdown({button:0,isPrimary:true,pointerId:10});
const closeTimer=nextTimer;
el('frameyClose').onclick();
assert.equal(timeouts.has(closeTimer),false,'closing the panel cancels a held scroll');
const replyCount=replies.length;
el('frameyScrollUp').onclick();
assert.equal(replies.length,replyCount);
+32 -1
View File
@@ -8,6 +8,7 @@ const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js'
assert.match(bundle,/SP_JSX\.jsx/);
const React={Fragment:Symbol('fragment'),Component:class {},
createElement:(type,props,...children)=>({type,props:props||{},children}),
cloneElement:(element,props)=>({...element,props:{...element.props,...props}}),
useState:value=>[value,()=>{}],useRef:value=>({current:value}),
useEffect(){},isValidElement:value=>!!value?.type};
class Element {
@@ -20,13 +21,15 @@ const elements=new Map(), roots=new Map();
const document={getElementById:id=>{
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);
},createElement:()=>new Element()};
const window={React,ReactDOM:{createRoot:node=>{
const window={React,location:{hash:'#sample-session'},ReactDOM:{createRoot:node=>{
const root={render(value){this.value=value;},unmount(){this.unmounted=true;}};
roots.set(node,root);return root;
}},addEventListener(){}};
const listeners=[];
const calls=[];
const messages=[];
const parent={postMessage(message){
messages.push(message);
if(message.type!=='framey:call')return;
calls.push(message);
listeners.find(x=>x.type==='message').handler({source:parent,
@@ -36,6 +39,12 @@ const context=vm.createContext({window,document,
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(host,context);
const pointerup=listeners.find(listener=>listener.type==='pointerup').handler;
pointerup({isTrusted:false});
assert.equal(messages.some(message=>message.type==='framey:input'),false);
pointerup({isTrusted:true});
assert.equal(messages.at(-1).type,'framey:input');
assert.equal(messages.at(-1).session,'sample-session');
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
vm.runInContext(bundle.replace(/export \{ index as default \};/, 'window.sample = index;'),context);
const plugin=window.sample();
@@ -71,6 +80,28 @@ toggle.props.onChange(false);
await new Promise(resolve=>setImmediate(resolve));
assert.equal(calls.filter(call=>call.method==='set_setting').length,2,
'a completed save must release the sample setting lock');
// The sample reset is a real Framey modal: Cancel cannot write, OK writes the
// sample setting only after explicit confirmation.
const reset=content.children[0][2].children[0];
const setCalls=()=>calls.filter(call=>call.method==='set_setting');
const dialog=()=>roots.get(elements.get('framey-modal')).value[0].children[1];
reset.props.onClick();
reset.props.onClick();
assert.equal(roots.get(elements.get('framey-modal')).value.length,1,
'a rapid second tap must not stack reset dialogs');
assert.equal(dialog().props.strTitle,'Reset example setting?');
let rendered=dialog().type(dialog().props);
rendered.children.find(child=>child?.children?.[0]==='Keep setting').props.onClick();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(setCalls().length,2,'cancel leaves the saved value untouched');
assert.equal(roots.get(elements.get('framey-modal')).value,null);
reset.props.onClick();
rendered=dialog().type(dialog().props);
rendered.children.find(child=>child?.children?.[0]==='Reset').props.onClick();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(setCalls().length,3);
assert.deepEqual(Array.from(setCalls().at(-1).args),['enabled',false]);
assert.equal(roots.get(elements.get('framey-modal')).value,null);
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),true);
assert.equal(window.DFL.useParams().topic,'saved-setting');
assert.equal(window.DFL.Navigation.Navigate('/library/home'),false);
+85 -1
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import {webcrypto} from 'node:crypto';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
@@ -17,6 +18,7 @@ const document = {getElementById(id) {if(!elements.has(id)) elements.set(id,new
createElement() {return new Element();}, hidden:false};
const session = new Map();
const ctx = vm.createContext({document, location:{hash:'',pathname:'/'},
crypto:webcrypto,
sessionStorage:{getItem:key=>session.get(key)||'',setItem:(key,value)=>session.set(key,value),removeItem:key=>session.delete(key)},
history:{replaceState(){}}, confirm:()=>true,
window:{addEventListener(){}}, setInterval(){}, setTimeout(){},
@@ -61,6 +63,45 @@ assert.equal(el('deckyJobProgress').value,100);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
// If the server accepts the POST but its response is lost, the panel already
// has the ID needed to find the job. It must not offer a second install.
let lostJobId, lostPosts=0;
ctx.mockApi=async (path,body) => {
if(path==='/api/decky/plugin'){
lostPosts++; lostJobId=body.requestId;
assert.match(lostJobId,/^[0-9a-f]{32}$/);
throw Error('POST response lost');
}
if(path.startsWith('/api/job')){
assert.equal(new URL(path,'http://localhost').searchParams.get('id'),lostJobId);
return {done:true,message:'Recovered install'};
}
if(path==='/api/decky')return {managed:true,running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(JSON.parse(session.get('frameyPluginJob')).job,lostJobId);
assert.equal(el('deckyJobCheck').hidden,false);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(lostPosts,1);
await vm.runInContext('checkDeckyJob()',ctx);
assert.equal(el('deckyJobStatus').textContent,'Recovered install');
assert.equal(session.has('frameyPluginJob'),false);
// A definite validation rejection never reserved a job on the server.
ctx.mockApi=async path => {
if(path==='/api/decky/plugin'){
const error=Error('Invalid reviewed store version');error.status=400;throw error;
}
if(path==='/api/decky')return {managed:true,running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"bad"})',ctx);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyJobCheck').hidden,true);
// A stopped Decky service must still offer recovery by removing its owned plugin.
ctx.mockApi=async path => path==='/api/decky' ?
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
@@ -149,7 +190,7 @@ ctx.mockApi=async path => {
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
assert.match(el('deckyJobStatus').textContent,/server has no record of this request/);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
@@ -244,3 +285,46 @@ vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('frameyPanel').hidden,true);
assert.match(el('deckyState').textContent,/panel was closed until status can be refreshed/);
// A denied sessionStorage must not lose an accepted job in the current panel.
// Keep Update/Remove disabled after a polling outage and allow Check result.
for(const method of ['getItem','setItem','removeItem'])
ctx.sessionStorage[method]=()=>{throw Error('storage denied');};
let accepted=0;
ctx.mockApi=async path => {
if(path==='/api/decky/plugin'){accepted++;return {job:'memory-job'};}
if(path.startsWith('/api/job'))throw Error('connection lost');
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi;deckyState={managed:true,running:true,plugins:[]};' +
'deckyStore=[{id:2,name:"Plugin 2",version:"1.0",description:"A panel",root:false}];showDeckyStore()',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(accepted,1);
assert.equal(vm.runInContext('deckyJobPending()',ctx),true);
assert.equal(el('deckyJobCheck').hidden,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,true);
await vm.runInContext('deckyChange("remove",{id:2,name:"Plugin 2"})',ctx);
assert.equal(accepted,1);
ctx.mockApi=async path => {
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.equal(vm.runInContext('deckyJobPending()',ctx),false);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
// Opening the headset panel with a key in its URL must survive denied storage.
const deniedDocument={getElementById:id=>new Element(id),createElement:()=>new Element()};
const deniedBoot=vm.createContext({document:deniedDocument,
location:{hash:'#private-key',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem(){throw Error('storage denied');},setItem(){throw Error('storage denied');}},
window:{addEventListener(){}},setInterval(){},setTimeout(){},confirm:()=>true,console,
fetch(){throw Error('No network expected');}});
vm.runInContext(script,deniedBoot);
assert.equal(vm.runInContext('key',deniedBoot),'private-key');
+46
View File
@@ -0,0 +1,46 @@
// Exact official Pause Games component and settings callbacks; effects/process
// actions never execute here. Actual Decky discovery/RPC has a separate gate.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const bundle=readFileSync(process.argv[2],'utf8');
const official=bundle.replace(/export \{ index as default \};/,
'window.__toggleTest = {AppItem, Settings};');
assert.notEqual(official,bundle);
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const host=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
assert.notEqual(host,source);
const React={Fragment:'fragment',Component:class {},
createElement:(type,props,...children)=>({type,props:props||{},children}),
createContext:()=>({}),useState:value=>[value,()=>{}],useEffect(){},
useRef:value=>({current:value}),useMemo:fn=>fn(),useCallback:fn=>fn};
const messages=[],listeners=[];
const parent={postMessage:message=>messages.push(message)};
const window={React,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
const context=vm.createContext({window,parent,navigator:{language:'en'},
document:{getElementById:()=>({})},console,URL,Blob,
addEventListener:(type,handler)=>listeners.push({type,handler}),setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(host,context);
Object.assign(context,{DFL:window.DFL,SP_REACT:React});
vm.runInContext(official,context,{timeout:2000});
const {AppItem,Settings}=window.__toggleTest;
const find=(node,type)=>node?.type===type?node:(node?.children||[]).flat(Infinity)
.map(child=>find(child,type)).find(Boolean);
for(const excluded of [false,true]) {
vm.runInContext(`window.__toggleTest.Settings.data.noAutoPauseSet=new Set(${excluded?'[424242]':'[]'});`,context);
const row=AppItem({app:{appid:'424242',display_name:'Private fixture'},autoPause:true});
const toggle=find(row,window.DFL.Toggle);
assert.ok(toggle);
const input=window.DFL.Toggle(toggle.props);
assert.equal(input.props.checked,!excluded,'official persisted exclusion must display correctly');
input.props.onChange({target:{checked:excluded}});
const call=messages.at(-1);
assert.equal(call.type,'framey:call');
assert.equal(call.method,excluded?'remove_no_auto_pause_set':'add_no_auto_pause_set');
assert.deepEqual(Array.from(call.args),[424242]);
assert.equal(Settings.data.noAutoPauseSet.has(424242),!excluded);
listeners.find(x=>x.type==='message').handler({source:parent,
data:{type:'framey:reply',id:call.id,result:null}});
}
assert.equal(messages.filter(x=>x.type==='framey:call').length,2);
+8 -2
View File
@@ -6,8 +6,9 @@ BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
Before any status read or title change, it atomically owns /tmp/frame-test.lock
on the Frame and checks SteamVR standby, proximity and battery read-only. A
busy lock, awake headset or battery below 20% stops the run. Only this run's
lock is released, including after test failures.
busy lock, awake headset, missing or active-range proximity reading, or battery
below 20% stops the run. Only this run's lock is released, including after
test failures.
- properties.json from Valve's devkit service names the login user;
- status (ui/frame_status.py) reads the build, battery and storage;
@@ -107,6 +108,11 @@ def preflight():
state = json.loads(result.stdout)
if state.get('activity') != 3:
raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched')
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)):
raise RuntimeError('Frame proximity is active or unknown; active session untouched')
battery = state.get('battery')
if not battery or min(battery) < 20:
raise RuntimeError('Frame battery is unavailable or below the smoke threshold')
+23 -6
View File
@@ -276,12 +276,29 @@ class ArtworkTests(unittest.TestCase):
import threading
from apk_sources import _images
gate = threading.Event()
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
start = time.monotonic()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=start + 0.1)
self.assertLess(time.monotonic() - start, 0.4)
gate.set()
real_join = threading.Thread.join
waits = []
def bounded_join(worker, timeout=None):
waits.append(timeout)
return real_join(worker, timeout)
try:
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []), \
patch.object(_images.threading.Thread, 'join', bounded_join):
start = time.monotonic()
deadline = start + 0.1
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=deadline)
# Check the requested wait, not elapsed runner time: a loaded CI
# host can deschedule this test after the bounded join returns.
self.assertEqual(len(waits), 1)
self.assertIsNotNone(waits[0])
self.assertGreaterEqual(waits[0], 0)
self.assertLessEqual(waits[0], deadline - start)
finally:
gate.set()
wait_for_idle_resolvers()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
+337 -3
View File
@@ -65,6 +65,15 @@ class ArchiveTests(unittest.TestCase):
return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([(p['id'], p['root']) for p in frame_decky_store.catalog()], [(42, True)])
def test_only_exact_fileserver_adaptation_clears_store_root(self):
records = [{'id': 60, 'name': 'DeckyFileServer', 'tags': ['root'],
'versions': [{'name': '1.2.0', 'hash': frame_decky_store.FILESERVER_DIGEST}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertFalse(frame_decky_store.catalog()[0]['root'])
records[0]['versions'][0]['hash'] = 'a'*64
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertTrue(frame_decky_store.catalog()[0]['root'])
def test_accepts_hashed_non_root_archive(self):
data, digest = archive()
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
@@ -121,15 +130,15 @@ class StoreTransaction(unittest.TestCase):
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
self.folder.parent.mkdir(parents=True)
def apply(self, action, data=None, expected='-'):
def apply(self, action, data=None, expected='-', plugin_id=42, folder='Example', version='1.0'):
token = secrets.token_hex(12)
source = Path('/tmp/frame-control-decky-plugin-' + token)
if data is not None:
source.write_bytes(data)
digest = hashlib.sha256(data or b'').hexdigest()
try:
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0', expected],
input=frame_decky_store.APPLY, text=True, capture_output=True,
return subprocess.run([sys.executable, '-', action, token, str(plugin_id), digest, folder, version, expected],
input=frame_decky_store.apply_script(plugin_id, digest), text=True, capture_output=True,
env=self.env, timeout=15)
finally:
source.unlink(missing_ok=True)
@@ -161,6 +170,331 @@ class StoreTransaction(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertTrue(external.exists())
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)
original = official_archive(record)
with zipfile.ZipFile(io.BytesIO(original)) as source:
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as target:
for entry in source.infolist():
target.writestr(entry, source.read(entry))
target.writestr(record['folder']+'/changed.txt', b'new version')
data = stream.getvalue()
with self.assertRaisesRegex(ValueError, 'non-ARM64'):
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), record['name'])
result = self.apply('install', data, plugin_id=60, folder=record['folder'], version=record['version'])
# Remote defence rejects root even when the desktop validation is bypassed.
self.assertNotEqual(result.returncode, 0)
self.assertIn('Root', result.stderr)
def test_corrupt_fileserver_offline_helper_is_rejected(self):
with patch.object(frame_decky_store, 'FILESERVER_BINARIES', {'x86_64': '0'*64}):
with self.assertRaisesRegex(ValueError, 'SHA-256 mismatch'):
frame_decky_store.apply_script(60, frame_decky_store.FILESERVER_DIGEST)
self.assertEqual(frame_decky_store.apply_script(60, '0'*64), frame_decky_store.APPLY)
def test_exact_fileserver_gets_native_user_helper(self):
from framey_official import PLUGINS, archive as official_archive
import platform
if platform.machine() not in frame_decky_store.FILESERVER_BINARIES:
self.skipTest('native Linux helper architecture')
record = next(p for p in PLUGINS if p['id'] == 60)
data = official_archive(record)
self.assertEqual(frame_decky_store.archive_info(data, record['sha256'], record['name']), record['folder'])
result = self.apply('install', data, plugin_id=60, folder=record['folder'], version=record['version'])
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'])
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())
@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):
from framey_official import PLUGINS, archive as official_archive
import platform
plugin = next(p for p in PLUGINS if p['id'] == 125)
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=125, 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'], ['localsend-digest-native'])
self.assertEqual(json.loads((folder/'plugin.json').read_text())['flags'], [])
expected = (frame_decky_store.LOCALSEND_AARCH64 if platform.machine() == 'aarch64'
else frame_decky_store.LOCALSEND_ORIGINAL)
self.assertEqual(hashlib.sha256((folder/'bin/localsend-core').read_bytes()).hexdigest(), expected)
self.assertEqual((folder/'bin/localsend-core').stat().st_mode & 0o777, 0o700)
with zipfile.ZipFile(io.BytesIO(data)) as source:
for name in ('main.py', 'LICENSE'):
self.assertEqual((folder/name).read_bytes(), source.read(plugin['folder']+'/'+name))
result = self.apply('remove', plugin_id=125, folder=plugin['folder'])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(folder.exists())
def test_localsend_exception_does_not_accept_another_archive_or_corrupt_helper(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 125)
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as source:
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as target:
for entry in source.infolist():
target.writestr(entry, source.read(entry))
target.writestr(plugin['folder']+'/changed.txt', b'new version')
changed = stream.getvalue()
with self.assertRaisesRegex(ValueError, 'non-ARM64'):
frame_decky_store.archive_info(changed, hashlib.sha256(changed).hexdigest(), plugin['name'])
self.assertEqual(frame_decky_store.apply_script(125, '0'*64), frame_decky_store.APPLY)
with patch.object(frame_decky_store, 'LOCALSEND_AARCH64', '0'*64):
with self.assertRaisesRegex(ValueError, 'SHA-256 mismatch'):
frame_decky_store.apply_script(125, frame_decky_store.LOCALSEND_DIGEST)
def test_localsend_arm64_transaction_checks_written_helper(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 125)
# The local x86 host only writes the ELF; native ARM execution is gated
# separately by the published Decky runtime runner.
with patch.object(frame_decky_store, 'APPLY',
"import platform\nplatform.machine=lambda:'aarch64'\n"+frame_decky_store.APPLY):
result = self.apply('install', official_archive(plugin), plugin_id=125,
folder=plugin['folder'], version=plugin['version'])
self.assertEqual(result.returncode, 0, result.stderr)
helper = self.folder.parent/plugin['folder']/'bin/localsend-core'
self.assertEqual(hashlib.sha256(helper.read_bytes()).hexdigest(),
frame_decky_store.LOCALSEND_AARCH64)
self.assertEqual(int.from_bytes(helper.read_bytes()[18:20], 'little'), 183)
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
def test_exact_deckyspy_gets_native_dependency_and_license_only_in_its_folder(self):
from framey_official import PLUGINS, archive as official_archive
import platform
plugin = next(p for p in PLUGINS if p['id'] == 89)
result = self.apply('install', official_archive(plugin), plugin_id=89,
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'], ['deckyspy-psutil-and-battery'])
modules = folder / 'py_modules'
native = (modules / 'psutil/_psutil_linux.abi3.so').read_bytes()
self.assertEqual(native[:4], b'\x7fELF')
self.assertEqual(int.from_bytes(native[18:20], 'little'),
{'x86_64': 62, 'aarch64': 183}[platform.machine()])
self.assertIn('BSD 3-Clause', (modules / 'psutil-7.2.2.dist-info/LICENSE').read_text())
result = self.apply('remove', plugin_id=89, folder=plugin['folder'])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(folder.exists())
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
def test_deckyspy_missing_or_corrupt_payload_leaves_no_installed_copy(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 89)
for prefix, error in [('', 'Missing native psutil'),
("FRAMEY_BACKEND_WHEELS = {'x86_64':'AAAA','aarch64':'AAAA'}\n",
'SHA-256 mismatch')]:
with patch.object(frame_decky_store, 'apply_script', return_value=prefix+frame_decky_store.APPLY):
result = self.apply('install', official_archive(plugin), plugin_id=89,
folder=plugin['folder'], version=plugin['version'])
self.assertNotEqual(result.returncode, 0)
self.assertIn(error, result.stderr)
self.assertFalse((self.folder.parent / plugin['folder']).exists())
def test_exact_simple_timer_gets_lifecycle_repair_and_keeps_api1_and_license(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 96)
result = self.apply('install', official_archive(plugin), plugin_id=96,
folder=plugin['folder'], version=plugin['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent / plugin['folder']
self.assertEqual(json.loads((folder / '.frame-control-store.json').read_text())['compatShims'],
['simple-timer-lifecycle'])
self.assertEqual(json.loads((folder / 'plugin.json').read_text())['api_version'], 1)
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as original:
self.assertEqual((folder / 'LICENSE').read_bytes(), original.read('simple-timer/LICENSE'))
# Backend behavior is verified separately by the real Decky lifecycle gate.
result = self.apply('remove', plugin_id=96, folder=plugin['folder'])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(folder.exists())
def test_changed_simple_timer_digest_does_not_get_pinned_repair(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 96)
data = io.BytesIO(official_archive(plugin))
with zipfile.ZipFile(data, 'a') as changed:
original_main = changed.read('simple-timer/main.py')
changed.writestr('simple-timer/new-version.txt', 'unrecognized new payload')
result = self.apply('install', data.getvalue(), plugin_id=96,
folder=plugin['folder'], version=plugin['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent / plugin['folder']
self.assertEqual((folder / 'main.py').read_bytes(), original_main)
self.assertEqual(json.loads((folder / '.frame-control-store.json').read_text()).get('compatShims', []), [])
def test_exact_shotty_archive_gets_isolated_click_import(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 53)
data = official_archive(plugin)
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
shotty = self.folder.parent / plugin['folder']
args = {'plugin_id': plugin['id'], 'folder': plugin['folder'], 'version': plugin['version']}
result = self.apply('install', data, **args)
self.assertEqual(result.returncode, 0, result.stderr)
receipt = json.loads((shotty / '.frame-control-store.json').read_text())
self.assertEqual(receipt['sha256'], plugin['sha256'])
self.assertEqual(receipt['compatShims'], ['shotty-click-app-dir'])
compat = shotty / 'py_modules/click.py'
self.assertTrue(compat.is_file())
source = '''import asyncio, importlib.util, logging, sys, types
from pathlib import Path
folder = Path(sys.argv[1])
sys.path.insert(0, str(folder/'py_modules'))
decky = types.ModuleType('decky_plugin')
decky.logger = logging.getLogger('shotty-test')
decky.DECKY_PLUGIN_DIR = str(folder)
sys.modules['decky_plugin'] = decky
spec = importlib.util.spec_from_file_location('shotty', folder/'main.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
assert module.Plugin.__name__ == 'Plugin'
from click import get_app_dir
assert get_app_dir('Shotty') == str(Path.home()/'.config/Shotty')
photo = Path.home()/'.local/share/Steam/userdata/100/760/remote/42/screenshots/test.jpg'
photo.parent.mkdir(parents=True)
photo.write_bytes(b'local screenshot fixture')
plugin = module.Plugin()
assert asyncio.run(plugin.aggregate_all([[42, 'Local Test Game']])) == 1
copied = Path.home()/'Pictures/Screenshots/Local Test Game/test.jpg'
assert copied.read_bytes() == b'local screenshot fixture'
'''
imported = subprocess.run([sys.executable, '-c', source, str(shotty)],
capture_output=True, text=True,
env={**self.env, 'XDG_CONFIG_HOME': str(Path(self.tmp.name)/'.config')}, timeout=10)
self.assertEqual(imported.returncode, 0, imported.stderr)
result = self.apply('remove', expected=plugin['sha256'], **args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(shotty.exists())
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w') as bundle:
bundle.writestr('Shotty/plugin.json', json.dumps({'name': 'Shotty', 'flags': []}))
bundle.writestr('Shotty/dist/index.js', 'export default {};' + ' ' * 1200)
other = stream.getvalue()
result = self.apply('install', other, **args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse((shotty / 'py_modules/click.py').exists())
self.assertNotIn('compatShims', json.loads((shotty / '.frame-control-store.json').read_text()))
def test_legacy_music_control_keeps_decky_class_calling_convention(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 111)
data = official_archive(plugin)
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
folder = self.folder.parent / plugin['folder']
args = {'plugin_id': plugin['id'], 'folder': plugin['folder'], 'version': plugin['version']}
result = self.apply('install', data, **args)
self.assertEqual(result.returncode, 0, result.stderr)
receipt = json.loads((folder / '.frame-control-store.json').read_text())
self.assertEqual(receipt['sha256'], plugin['sha256'])
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
self.assertNotIn('api_version', json.loads((folder / 'plugin.json').read_text()))
self.assertIn('self._get_dbus_env(self)', (folder / 'main.py').read_text())
dbus = Path(self.tmp.name) / 'dbus-send'
dbus.write_text('''#!/usr/bin/env python3
import os, sys
from pathlib import Path
args = ' '.join(sys.argv[1:])
assert os.environ['LD_LIBRARY_PATH'] == os.environ.get('LD_LIBRARY_PATH_ORIG', '')
with Path(os.environ['MPRIS_CALLS']).open('a') as log: log.write(args + '\\n')
if 'ListNames' in args:
print('method return\\n array [\\n string "org.freedesktop.DBus"\\n string "org.mpris.MediaPlayer2.fixture"\\n ]')
elif 'string:PlaybackStatus' in args:
print('method return\\n variant string "Paused"')
else:
print('method return')
''')
dbus.chmod(0o700)
calls = Path(self.tmp.name) / 'mpris-calls'
source = '''import asyncio, importlib.util, os, sys, types
from pathlib import Path
decky = types.ModuleType('decky_plugin')
sys.modules['decky_plugin'] = decky
spec = importlib.util.spec_from_file_location('music_control', Path(sys.argv[1])/'main.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Decky 3.3 uses the class for manifests with no api_version.
plugin = module.Plugin
async def check():
assert await plugin.sp_list_media_players(plugin) == 'org.mpris.MediaPlayer2.fixture'
await plugin.sp_set_media_player(plugin, 'org.mpris.MediaPlayer2.fixture')
assert await plugin.sp_get_media_player(plugin) == 'org.mpris.MediaPlayer2.fixture'
await plugin.sp_play(plugin)
await plugin.sp_pause(plugin)
await plugin.sp_next(plugin)
await plugin.sp_previous(plugin)
await plugin.sp_set_volume(plugin, 0.25)
assert await plugin.sp_track_status(plugin) == 'Paused'
asyncio.run(check())
assert os.environ['LD_LIBRARY_PATH'] == 'FrameyBundledLibraries'
'''
called = subprocess.run([sys.executable, '-c', source, str(folder)],
capture_output=True, text=True,
env={**self.env, 'MPRIS_CALLS': str(calls), 'LD_LIBRARY_PATH': 'FrameyBundledLibraries',
'LD_LIBRARY_PATH_ORIG': 'FrameySystemLibraries'}, timeout=10)
self.assertEqual(called.returncode, 0, called.stderr)
logged = calls.read_text()
self.assertIn('org.freedesktop.DBus.ListNames', logged)
self.assertIn('org.mpris.MediaPlayer2.Player.PlayPause', logged)
self.assertIn('org.mpris.MediaPlayer2.Player.Pause', logged)
self.assertIn('string:Volume variant:double:0.25', logged)
self.assertIn('org.mpris.MediaPlayer2.fixture', logged)
# An explicit same-version update replaces the previously shipped bad shim.
main = folder / 'main.py'
main.write_text(main.read_text().replace('self._get_dbus_env(self)', 'self._get_dbus_env()'))
receipt['compatShims'] = ['music-control-bound-methods']
(folder / '.frame-control-store.json').write_text(json.dumps(receipt))
result = self.apply('update', data, expected=plugin['sha256'], **args)
self.assertEqual(result.returncode, 0, result.stderr)
called = subprocess.run([sys.executable, '-c', source, str(folder)],
capture_output=True, text=True,
env={**self.env, 'MPRIS_CALLS': str(calls),
'LD_LIBRARY_PATH': 'FrameyBundledLibraries'}, timeout=10)
self.assertEqual(called.returncode, 0, called.stderr)
result = self.apply('remove', expected=plugin['sha256'], **args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(folder.exists())
older = next(p for p in PLUGINS if p['id'] == 12)
old_data = official_archive(older)
old_args = {'plugin_id': older['id'], 'folder': older['folder'], 'version': older['version']}
result = self.apply('install', old_data, **old_args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads((folder / '.frame-control-store.json').read_text())['compatShims'],
['music-control-system-dbus-env'])
self.assertNotIn('api_version', json.loads((folder / 'plugin.json').read_text()))
self.assertIn('self._get_dbus_env(self)', (folder / 'main.py').read_text())
old_env = {**self.env, 'MPRIS_CALLS': str(calls), 'LD_LIBRARY_PATH': 'FrameyBundledLibraries'}
old_env.pop('LD_LIBRARY_PATH_ORIG', None)
called = subprocess.run([sys.executable, '-c', source, str(folder)],
capture_output=True, text=True, env=old_env, timeout=10)
self.assertEqual(called.returncode, 0, called.stderr)
result = self.apply('remove', expected=older['sha256'], **old_args)
self.assertEqual(result.returncode, 0, result.stderr)
# A future archive at the same store ID must retain its own source.
other = io.BytesIO()
untouched = 'class Plugin: pass\n'
with zipfile.ZipFile(other, 'w') as bundle:
bundle.writestr('MusicControl/plugin.json', json.dumps({'name': 'Music Control', 'flags': []}))
bundle.writestr('MusicControl/dist/index.js', 'export default {};' + ' ' * 1200)
bundle.writestr('MusicControl/main.py', untouched)
result = self.apply('install', other.getvalue(), **args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((folder / 'main.py').read_text(), untouched)
self.assertNotIn('compatShims', json.loads((folder / '.frame-control-store.json').read_text()))
def test_installed_script_entrypoints_execute_without_zip_exec_bits(self):
data, _ = archive(extra=('Example/scripts/start.py',
b'#!/bin/sh\nprintf "Framey script: %s\\n" "$1"\n'))
+7
View File
@@ -26,6 +26,13 @@ class HeadsetBundle(unittest.TestCase):
self.assertIn('ui/headset.html', names)
self.assertIn('ui/frame_headset_runtime.py', names)
self.assertIn('ui/server.py', names)
for filename in ('aarch64.gz', 'x86_64.gz', 'source.tar.gz', 'LICENSE.txt', 'GO-LICENSE.txt', 'README.md'):
self.assertIn('ui/vendor/deckyfileserver-1.2.0/'+filename, names)
for filename in ('aarch64.gz', 'source.tar.gz', 'LICENSE.txt', 'GO-LICENSE.txt', 'README.md'):
self.assertIn('ui/vendor/localsend-0.4.5/'+filename, names)
self.assertIn('scripts/build-framey-fileserver.py', names)
for name in ('aarch64.whl', 'x86_64.whl', 'LICENSE.txt'):
self.assertIn('ui/vendor/psutil-7.2.2/'+name, names)
self.assertFalse(any('\\' in name for name in names))
self.assertFalse(any('__pycache__' in name for name in names))
+19
View File
@@ -5,6 +5,7 @@ import hashlib
import importlib.util
import io
from pathlib import Path
import tempfile
import unittest
import zipfile
@@ -21,6 +22,7 @@ class ReceiptBoundAudit(unittest.TestCase):
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w') as archive:
archive.writestr('Example/dist/index.js', bundle)
archive.writestr('Example/dist/chunks/frontend.js', b'export const ready = true;')
data = stream.getvalue()
digest = hashlib.sha256(data).hexdigest()
record = {'id':42, 'folder':'Example', 'sha256':digest}
@@ -30,3 +32,20 @@ class ReceiptBoundAudit(unittest.TestCase):
self.assertIn(b'const report=(kind,message)', page)
with self.assertRaisesRegex(ValueError, 'hash mismatch'):
audit.render_panel({**record, 'sha256':'0'*64}, data)
with tempfile.TemporaryDirectory() as directory:
cache = Path(directory)
(cache/(digest+'.zip')).write_bytes(data)
records = {42:record}
route = f'/framey/module/42/{digest}/'
self.assertEqual(audit.read_module_asset(route+'index.js', records, cache), bundle)
self.assertEqual(audit.read_module_asset(route+'chunks/frontend.js', records, cache),
b'export const ready = true;')
for bad in (f'/framey/module/42/{"0"*64}/index.js',
route+'chunks/../frontend.js', route+'other.json',
f'/framey/module/042/{digest}/index.js'):
with self.subTest(bad=bad), self.assertRaises((ValueError, KeyError)):
audit.read_module_asset(bad, records, cache)
(cache/(digest+'.zip')).write_bytes(data+b'changed')
with self.assertRaisesRegex(ValueError, 'hash mismatch'):
audit.read_module_asset(route+'index.js', records, cache)
+59
View File
@@ -0,0 +1,59 @@
"""Frame battery names are not the BAT/battery names psutil searches."""
import sandbox # noqa: F401
import sys
from pathlib import Path
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'ui'))
import framey_battery
class FrameBattery(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.battery = self.root/'max1720x_bat_7-36'
self.battery.mkdir()
self.psutil = SimpleNamespace(sensors_battery=lambda: None,
POWER_TIME_UNLIMITED=-2, POWER_TIME_UNKNOWN=-1)
self.patch = patch.dict(sys.modules, {'psutil': self.psutil})
self.patch.start()
self.addCleanup(self.patch.stop)
def files(self, **values):
for name, value in values.items():
(self.battery/name).write_text(str(value))
def test_frame_name_uses_type_capacity_and_status_without_writes(self):
self.files(type='Battery', capacity=99, status='Full')
result = framey_battery.sensors_battery(self.root)
self.assertEqual((result.percent, result.power_plugged, result.secsleft), (99, True, -2))
self.assertEqual((self.battery/'capacity').read_text(), '99')
def test_time_to_empty_is_seconds_and_missing_time_stays_unknown(self):
self.files(type='Battery', capacity=42, status='Discharging', time_to_empty_now=1234)
self.assertEqual(framey_battery.sensors_battery(self.root).secsleft, 1234)
(self.battery/'time_to_empty_now').unlink()
self.assertEqual(framey_battery.sensors_battery(self.root).secsleft, -1)
def test_unknown_charge_state_is_not_claimed_as_plugged(self):
self.files(type='Battery', capacity=42, status='Unknown')
result = framey_battery.sensors_battery(self.root)
self.assertIsNone(result.power_plugged)
self.assertEqual(result.secsleft, -1)
def test_native_psutil_result_is_preserved_and_invalid_supplies_are_ignored(self):
native = object()
self.psutil.sensors_battery = lambda: native
self.assertIs(framey_battery.sensors_battery(self.root), native)
self.psutil.sensors_battery = lambda: None
for values in [dict(type='USB', capacity=99, status='Charging'),
dict(type='Battery', capacity=101, status='Full'),
dict(type='Battery', capacity=50, status='Full', present=0)]:
self.files(**values)
self.assertIsNone(framey_battery.sensors_battery(self.root))
self.assertIsNone(framey_battery.sensors_battery(self.root/'missing'))
+21
View File
@@ -59,6 +59,27 @@ class Compatibility(unittest.TestCase):
frame_decky_store.change(no_ssh, 'install', 42)
download.assert_not_called()
def test_old_deckyspy_copy_does_not_inherit_fixed_install_status(self):
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 89)
installed = {'plugins': [dict(storeId=89, version=plugin['version'], sha256=plugin['sha256']),
dict(storeId=89, version=plugin['version'], sha256=plugin['sha256'],
compatShims=['deckyspy-psutil-and-battery'])]}
result = frame_decky.status(lambda *a, **k: json.dumps(installed))['plugins']
self.assertEqual(result[0]['compatibility']['status'], 'partial')
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
self.assertEqual(result[1]['compatibility']['status'], 'works')
self.assertEqual(compatibility.lookup(89, plugin['sha256'], plugin['version'])['status'], 'works')
def test_old_simple_timer_copy_requires_lifecycle_reinstall(self):
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 96)
installed = {'plugins': [dict(storeId=96, version=plugin['version'], sha256=plugin['sha256']),
dict(storeId=96, version=plugin['version'], sha256=plugin['sha256'],
compatShims=['simple-timer-lifecycle'])]}
result = frame_decky.status(lambda *a, **k: json.dumps(installed))['plugins']
self.assertEqual(result[0]['compatibility']['status'], 'partial')
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
self.assertEqual(result[1]['compatibility']['status'], 'works')
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')
+37
View File
@@ -0,0 +1,37 @@
"""Managed backend shims must stay covered by the actual-Decky CI gate."""
import sandbox # noqa: F401
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('decky_runtime_gate', ROOT/'scripts/framey-test-decky-runtime.py')
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
class RuntimeCoverage(unittest.TestCase):
def test_current_shims_have_unique_official_archive_probes(self):
from framey_official import PLUGINS
cases = gate.check_coverage()
official = {record['id'] for record in PLUGINS}
self.assertEqual(len(cases), len({case['id'] for case in cases}))
for case in cases:
self.assertIn(case['id'], official)
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
'toggle-settings', 'fileserver', 'localsend'))
def test_new_shim_without_runtime_case_fails_coverage(self):
import frame_decky_store
with patch.object(frame_decky_store, 'APPLY', frame_decky_store.APPLY+
'\ncompat_shims.append("uncovered-new-shim")\n'):
with self.assertRaisesRegex(ValueError, 'Every managed shim'):
gate.check_coverage()
def test_dynamic_shim_names_cannot_evade_inventory(self):
import frame_decky_store
with patch.object(frame_decky_store, 'APPLY', frame_decky_store.APPLY+
'\ncompat_shims.append(variable_name)\n'):
with self.assertRaisesRegex(ValueError, 'literal name'):
gate.check_coverage()
+18
View File
@@ -21,6 +21,16 @@ from framey_official import LEGACY_PLUGINS, PLUGINS, archive
class OfficialBundles(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
def test_pause_games_toggle_renders_saved_value_and_dispatches_settings_only(self):
plugin = next(p for p in PLUGINS if p['id'] == 13)
with zipfile.ZipFile(io.BytesIO(archive(plugin))) as bundle, tempfile.TemporaryDirectory() as root:
frontend = Path(root)/'index.js'
frontend.write_bytes(bundle.read(plugin['folder']+'/dist/index.js'))
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_toggle.mjs'), str(frontend)],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_legacy_css_loader_store_archive_has_unversioned_frontend(self):
plugin = LEGACY_PLUGINS[0]
data = archive(plugin)
@@ -74,6 +84,14 @@ class OfficialBundles(unittest.TestCase):
folder = plugins / plugin['folder']
(folder / '.frame-control-store.json').write_text(json.dumps({
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
if plugin['id'] == 60:
# This renderer fixture extracts the original ZIP. The
# real managed adaptation is covered by transaction and
# published Decky tests; an unadapted root copy must fail.
with patch.object(framey_panel, 'PLUGINS', plugins), self.assertRaises(FileNotFoundError):
framey_panel.panel(plugin['id'])
manifest['flags'] = []
(folder/'plugin.json').write_text(json.dumps(manifest))
with patch.object(framey_panel, 'PLUGINS', plugins):
page = framey_panel.panel(plugin['id']).decode()
encoded = re.search(r'<script id="framey-bundle" type="application/json"[^>]*>([^<]+)</script>', page)
+40 -1
View File
@@ -1,6 +1,7 @@
"""The author example stores settings as a normal Decky user plugin."""
import asyncio
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
@@ -36,7 +37,8 @@ class SamplePlugin(unittest.TestCase):
self.assertTrue(asyncio.run(first.set_setting('enabled', True)))
second = backend(folder)
self.assertTrue(asyncio.run(second.get_setting('enabled', False)))
self.assertTrue((Path(folder) / 'settings.json').is_file())
self.assertEqual(json.loads((Path(folder) / 'settings.json').read_text()),
{'schema': 1, 'enabled': True})
def test_backend_rejects_unknown_keys_and_non_boolean_values(self):
with tempfile.TemporaryDirectory() as folder:
@@ -57,6 +59,43 @@ class SamplePlugin(unittest.TestCase):
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
def test_legacy_boolean_survives_restart_and_migrates_on_save(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
path.write_text('{"enabled": true}\n')
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
self.assertEqual(json.loads(path.read_text()), {'enabled': True},
'Reading must not rewrite a user setting')
self.assertTrue(asyncio.run(backend(folder).set_setting('enabled', False)))
self.assertEqual(json.loads(path.read_text()), {'schema': 1, 'enabled': False})
self.assertFalse(asyncio.run(backend(folder).get_setting('enabled', True)))
def test_newer_schema_is_preserved_on_read_and_save(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
original = '{"schema": 2, "enabled": true, "future": "keep"}\n'
path.write_text(original)
plugin = backend(folder)
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
asyncio.run(plugin.get_setting('enabled', False))
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
asyncio.run(plugin.set_setting('enabled', False))
self.assertEqual(path.read_text(), original)
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
def test_failed_atomic_replace_keeps_previous_setting(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
plugin = backend(folder)
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
previous = path.read_bytes()
with patch('os.replace', side_effect=OSError('storage unavailable')):
with self.assertRaisesRegex(OSError, 'storage unavailable'):
asyncio.run(plugin.set_setting('enabled', False))
self.assertEqual(path.read_bytes(), previous)
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
if __name__ == '__main__':
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""Store requests can reconnect when the headset panel loses the POST response."""
import sandbox # noqa: F401
import sys
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import server
class StoreJobs(unittest.TestCase):
def test_same_request_id_runs_once_and_conflicting_reuse_is_rejected(self):
request_id = 'a' * 32
started = threading.Event()
finish = threading.Event()
calls = []
def change(_ssh, action, plugin_id, report, reviewed_hash=None):
calls.append((action, plugin_id, reviewed_hash))
started.set()
self.assertTrue(finish.wait(5))
report('Installed', 100)
return {'message': 'Installed once'}
body = {'action': 'install', 'id': 2, 'sha256': 'b' * 64,
'acknowledged': True, 'requestId': request_id}
try:
with patch.object(server.frame_decky_store, 'change', side_effect=change):
first = server.decky_plugin(body)
self.assertTrue(started.wait(5))
self.assertEqual(server.decky_plugin(body), first)
for changed in ({'action': 'update'}, {'id': 3}, {'sha256': 'c' * 64}):
with self.subTest(changed=changed), self.assertRaises(server.Failure) as error:
server.decky_plugin({**body, **changed})
self.assertEqual(error.exception.status, 409)
self.assertEqual(calls, [('install', 2, 'b' * 64)])
self.assertNotIn('_request', server.job_status('id=' + request_id))
finish.set()
for _ in range(100):
if server.job_status('id=' + request_id)['done']:
break
time.sleep(0.01)
self.assertEqual(server.job_status('id=' + request_id)['message'], 'Installed once')
self.assertEqual(server.decky_plugin(body), first)
self.assertEqual(len(calls), 1)
finally:
finish.set()
with server._jobs_lock:
server._jobs.pop(request_id, None)
def test_invalid_request_id_does_not_start_work(self):
with patch.object(server.frame_decky_store, 'change') as change:
for request_id in (1, '', 'A' * 32, 'f' * 31, 'f' * 33, '../' * 11):
with self.subTest(request_id=request_id), self.assertRaises(server.Failure) as error:
server.decky_plugin({'action': 'install', 'id': 2,
'acknowledged': True, 'requestId': request_id})
self.assertEqual(error.exception.status, 400)
change.assert_not_called()
if __name__ == '__main__':
unittest.main()
+6
View File
@@ -309,6 +309,12 @@ class Renderer(unittest.TestCase):
resources = {r['from']: r.get('filter', ['**/*']) for r in config['build']['extraResources']}
self.assertIn('*.js', resources['../ui'])
self.assertIn('*.js', resources['../frame/android'])
self.assertIn('*.gz', resources['../ui/vendor/localsend-0.4.5'])
self.assertIn('*.txt', resources['../ui/vendor/localsend-0.4.5'])
self.assertIn('build-framey-localsend.py', resources['../scripts'])
checker = (ROOT / 'app/build/check-resources.js').read_text()
for name in ('aarch64.gz', 'source.tar.gz', 'LICENSE.txt', 'GO-LICENSE.txt', 'README.md'):
self.assertIn(f'ui/vendor/localsend-0.4.5/{name}', checker)
class SteamGridDB(unittest.TestCase):
+45 -2
View File
@@ -37,12 +37,14 @@ 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"):
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
(d / "gates.json").write_text(json.dumps(gates or {}))
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_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
FAKEGH_GATES=str(d / "gates.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
@@ -120,6 +122,47 @@ class PublishRelease(unittest.TestCase):
self.assertIsNone(upload)
self.assertTrue(all("-X" not in c for c in log))
def test_refuses_failed_e2e_or_cancelled_installer_before_mutation(self):
for gates in ({"bad_job": "e2e", "bad_job_conclusion": "failure"},
{"bad_job": "decky-runtime (ubuntu-24.04-arm)",
"bad_job_conclusion": "failure"},
{"bad_job": "build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
"bad_job_conclusion": "cancelled"}):
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
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)",
"prior_bad_job_conclusion": "cancelled"}
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
self.assertNotEqual(p.returncode, 0)
self.assertIn("attempt 1", p.stderr)
self.assertIn("cancelled", 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"},
{"release_status": "in_progress"}):
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
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_withdrawn_release_even_when_ci_is_green(self):
rel = dict(draft(), body="> **WITHDRAWN 26 October 2026.** Do not install.\n\n# Notes")
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("marked WITHDRAWN", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -95,6 +95,19 @@ class RealProcesses(unittest.TestCase):
class Safety(unittest.TestCase):
def test_locked_preflight_rejects_active_or_unknown_proximity_before_titles(self):
for proximity in ([], [88.0625], [4.6875, 88.0625], [None]):
with self.subTest(proximity=proximity), mock.patch.object(
frame_smoke, 'remote', return_value=mock.Mock(
returncode=0, stdout=json.dumps({
'activity': 3, 'proximity': proximity, 'battery': [72]}))):
with self.assertRaisesRegex(RuntimeError, 'proximity is active or unknown'):
frame_smoke.preflight()
with mock.patch.object(frame_smoke, 'remote', return_value=mock.Mock(
returncode=0, stdout=json.dumps({
'activity': 3, 'proximity': [4.6875], 'battery': [72]}))):
self.assertEqual(frame_smoke.preflight()['proximity'], [4.6875])
def test_missing_x86_runtime_skips_launch_but_still_removes_title(self):
smoke = frame_smoke.Smoke()
gid = 'fc_smoke_x86_64'
+5 -2
View File
@@ -40,7 +40,9 @@ def plugins(folder):
'author': str(data.get('author', ''))[:80],
'storeId': owned.get('storeId') if isinstance(owned.get('storeId'), int) else None,
'version': str(owned.get('version', ''))[:40],
'sha256': str(owned.get('sha256', ''))[:64]})
'sha256': str(owned.get('sha256', ''))[:64],
'compatShims': [s[:100] for s in owned.get('compatShims', []) if isinstance(s, str)][:16]
if isinstance(owned.get('compatShims'), list) else []})
except (ValueError, OSError, UnicodeError): pass
return found
os_release = {}
@@ -166,7 +168,8 @@ def status(ssh):
result = json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
for plugin in result.get('plugins', []):
plugin['compatibility'] = framey_compatibility.lookup(
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'))
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'),
installed_shims=plugin.get('compatShims', []))
return result
+339 -5
View File
@@ -3,14 +3,18 @@
Only Frame Control owned plugin folders can be updated or removed. The store
digest validates transport, not plugin trust; store plugins run as the user.
"""
import base64
import gzip
import hashlib
import io
import json
import os
from pathlib import Path
import re
import secrets
import urllib.request
import zipfile
from pathlib import Path
import frame_decky
import framey_compatibility
@@ -23,6 +27,17 @@ MAX_ZIP = 50 * 1024 * 1024
MAX_UNPACKED = 150 * 1024 * 1024
SAFE_FOLDER = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\Z')
SHA = re.compile(r'[0-9a-f]{64}\Z')
FILESERVER_DIGEST = '7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0'
FILESERVER_BINARIES = {
'x86_64': '5e90efd1bf5163bef1686045f4714ad0e4fd0f54edb037fce8cdb06c58177bcb',
'aarch64': '86b80021ebd62ed619eba065757c1122defa5a74b2c045aa44a0c6b4403a508a',
}
FILESERVER_ORIGINAL = 'd9eab8559c87c01736188bc676f3b4b44dee47cf952b6e571c770d1ea61733e9'
LOCALSEND_DIGEST = 'e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7'
LOCALSEND_ORIGINAL = 'd448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843'
LOCALSEND_MAIN = 'db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8ad'
LOCALSEND_AARCH64 = 'e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
@@ -68,6 +83,8 @@ def catalog():
counts[item['id']] = counts.get(item['id'], 0) + 1
unique = [item for item in result if counts[item['id']] == 1]
for item in unique:
if item['id'] == 60 and item['name'] == 'DeckyFileServer' and item['sha256'] == FILESERVER_DIGEST:
item['root'] = False # Exact managed adaptation runs as the Frame user.
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
return unique
@@ -86,6 +103,10 @@ def archive_info(data, expected_hash, expected_name):
folder = manifests[0].split('/')[0]
if not SAFE_FOLDER.fullmatch(folder):
raise ValueError('Invalid plugin folder')
fileserver = (expected_hash == FILESERVER_DIGEST and expected_name == 'DeckyFileServer'
and folder == 'DeckyFileServer')
localsend = (expected_hash == LOCALSEND_DIGEST and expected_name == 'Decky Localsend'
and folder == 'decky-localsend')
seen = {}
for entry in files:
name = entry.filename
@@ -115,7 +136,11 @@ def archive_info(data, expected_hash, expected_name):
with archive.open(entry) as source:
header = source.read(20)
if header[:4] == b'\x7fELF' and int.from_bytes(header[18:20], 'little') != 183:
raise ValueError('Plugin contains a non-ARM64 Linux binary: ' + name)
if not ((fileserver and name == 'DeckyFileServer/bin/backend'
and hashlib.sha256(archive.read(entry)).hexdigest() == FILESERVER_ORIGINAL)
or (localsend and name == 'decky-localsend/bin/localsend-core'
and hashlib.sha256(archive.read(entry)).hexdigest() == LOCALSEND_ORIGINAL)):
raise ValueError('Plugin contains a non-ARM64 Linux binary: ' + name)
if header[:2] == b'MZ':
raise ValueError('Plugin contains a Windows binary: ' + name)
manifest = json.loads(archive.read(manifests[0]))
@@ -126,13 +151,56 @@ def archive_info(data, expected_hash, expected_name):
flags = manifest.get('flags', [])
if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags):
raise ValueError('Invalid plugin flags')
if 'root' in flags or '_root' in flags:
if ('root' in flags or '_root' in flags) and not fileserver:
raise ValueError('This plugin needs root and is not supported by the Frame user service')
if localsend and hashlib.sha256(archive.read(folder+'/main.py')).hexdigest() != LOCALSEND_MAIN:
raise ValueError('LocalSend Python source differs from the pinned archive')
return folder
DECKYSPY_DIGEST = '2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7'
PSUTIL_DIGESTS = {
'x86_64': '076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
'aarch64': 'b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e',
}
def apply_script(plugin_id, digest):
"""Deliver verified offline dependencies only to exact managed archives."""
if plugin_id == 125 and digest == LOCALSEND_DIGEST:
packed = (Path(__file__).parent/'vendor/localsend-0.4.5/aarch64.gz').read_bytes()
data = gzip.decompress(packed)
if hashlib.sha256(data).hexdigest() != LOCALSEND_AARCH64:
raise ValueError('Bundled LocalSend ARM64 helper SHA-256 mismatch')
return ('FRAMEY_LOCALSEND_AARCH64 = '+repr(base64.b64encode(packed).decode('ascii'))+'\n'+APPLY)
if plugin_id == 60 and digest == FILESERVER_DIGEST:
assets = Path(__file__).parent/'vendor/deckyfileserver-1.2.0'
payloads = {}
for arch, expected in FILESERVER_BINARIES.items():
packed = (assets/f'{arch}.gz').read_bytes()
data = gzip.decompress(packed)
if hashlib.sha256(data).hexdigest() != expected:
raise ValueError('Bundled DeckyFileServer SHA-256 mismatch')
payloads[arch] = base64.b64encode(packed).decode('ascii')
# Python stdin, never shell arguments. The Frame rechecks hash and ELF machine.
return ('FRAMEY_FILESERVER_BINARIES = '+repr(payloads)+'\n'
+'FRAMEY_FILESERVER_HASHES = '+repr(FILESERVER_BINARIES)+'\n'+APPLY)
if plugin_id != 89 or digest != DECKYSPY_DIGEST:
return APPLY
wheels = {}
for arch, expected in PSUTIL_DIGESTS.items():
data = (Path(__file__).parent/'vendor/psutil-7.2.2'/f'{arch}.whl').read_bytes()
if hashlib.sha256(data).hexdigest() != expected:
raise ValueError('Bundled psutil dependency SHA-256 mismatch')
wheels[arch] = base64.b64encode(data).decode('ascii')
# stdin is Python source, never shell arguments or a shared remote temp file.
battery = (Path(__file__).parent/'framey_battery.py').read_text(encoding='utf-8')
return ('FRAMEY_BACKEND_WHEELS = '+repr(wheels)+'\n'
+'FRAMEY_BACKEND_BATTERY_SOURCE = '+repr(battery)+'\n'+APPLY)
APPLY = r'''
import fcntl,hashlib,json,os,pathlib,shutil,subprocess,sys,zipfile
import base64,fcntl,gzip,hashlib,io,json,os,pathlib,platform,shutil,subprocess,sys,zipfile
action,token,plugin_id,digest,folder,version,expected_installed = sys.argv[1:]
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
unit=pathlib.Path.home()/'.config/systemd/user/frame-control-decky.service'
@@ -174,6 +242,15 @@ try:
files=z.infolist()
if not files or len(files)>3000 or sum(f.file_size for f in files)>150*1024*1024:
raise RuntimeError('Plugin archive too large')
fileserver=(int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer')
localsend=(int(plugin_id)==125 and digest=='e271ccb55eb23f918b99a69f79aec905df4c9b2ef7b23bbf4ec1c2d1a6f0c6b7' and folder=='decky-localsend')
manifest=json.loads(z.read(folder+'/plugin.json'))
if not isinstance(manifest,dict): raise RuntimeError('Invalid plugin manifest')
flags=manifest.get('flags',[])
if not isinstance(flags,list) or not all(isinstance(flag,str) for flag in flags):
raise RuntimeError('Invalid plugin flags')
if ('root' in flags or '_root' in flags) and not fileserver:
raise RuntimeError('Root plugins are not supported by the Frame user service')
seen={}
for f in files:
parts=f.filename.split('/')
@@ -194,6 +271,15 @@ try:
z.read(prior)!=z.read(f)):
raise RuntimeError('Unsafe plugin archive')
else: seen[f.filename]=f
if not f.is_dir():
with z.open(f) as source: header=source.read(20)
if header[:4]==b'\x7fELF' and int.from_bytes(header[18:20],'little')!=183:
if not ((fileserver and f.filename==folder+'/bin/backend' and
hashlib.sha256(z.read(f)).hexdigest()=='d9eab8559c87c01736188bc676f3b4b44dee47cf952b6e571c770d1ea61733e9')
or (localsend and f.filename==folder+'/bin/localsend-core' and
hashlib.sha256(z.read(f)).hexdigest()=='d448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843')):
raise RuntimeError('Plugin contains a non-ARM64 Linux binary')
if header[:2]==b'MZ': raise RuntimeError('Plugin contains a Windows binary')
stage.mkdir(mode=0o700)
z.extractall(stage)
# zipfile drops execute bits. Legacy plugins also publish shebang
@@ -208,6 +294,252 @@ try:
entry.chmod(entry.stat().st_mode|0o100)
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
compat_shims=[]
if localsend:
helper=unpacked/'bin/localsend-core'
source=unpacked/'main.py'
if hashlib.sha256(helper.read_bytes()).hexdigest()!='d448d03861d578685e6c70ea3ace25eb34fca99cb9d4f98bf51ba23aa5cae843':
raise RuntimeError('LocalSend original helper mismatch')
if hashlib.sha256(source.read_bytes()).hexdigest()!='db03f9201fe50b75dea99a7a05b54ec3f352d1bdfe174115b0e569154198d8ad':
raise RuntimeError('LocalSend Python source mismatch')
if manifest.get('name')!='Decky Localsend' or manifest.get('api_version')!=1 or manifest.get('flags')!=[]:
raise RuntimeError('LocalSend manifest mismatch')
arch=platform.machine()
if arch=='aarch64':
packed=globals().get('FRAMEY_LOCALSEND_AARCH64')
if not isinstance(packed,str): raise RuntimeError('Missing LocalSend ARM64 helper')
native=gzip.decompress(base64.b64decode(packed,validate=True))
if (hashlib.sha256(native).hexdigest()!='e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae'
or native[:4]!=b'\x7fELF' or int.from_bytes(native[18:20],'little')!=183):
raise RuntimeError('LocalSend ARM64 helper mismatch')
helper.write_bytes(native)
elif arch!='x86_64':
raise RuntimeError('Unsupported LocalSend helper architecture')
helper.chmod(0o700)
compat_shims.append('localsend-digest-native')
# GPL source and reproducible build recipe ship with Frame Control.
# The exact official helper is x86_64; this plugin needs no root.
if int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer':
helper=unpacked/'bin/backend'
source=unpacked/'main.py'
if hashlib.sha256(helper.read_bytes()).hexdigest()!='d9eab8559c87c01736188bc676f3b4b44dee47cf952b6e571c770d1ea61733e9':
raise RuntimeError('DeckyFileServer original helper mismatch')
if hashlib.sha256(source.read_bytes()).hexdigest()!='4fb60745c083465d198d84e1abb0afdb59695d9eec3c873b0b35247676af05c3':
raise RuntimeError('DeckyFileServer Python source mismatch')
arch=platform.machine()
machine={'aarch64':183,'x86_64':62}.get(arch)
hashes=globals().get('FRAMEY_FILESERVER_HASHES',{})
packed=globals().get('FRAMEY_FILESERVER_BINARIES',{}).get(arch)
if not machine or not packed: raise RuntimeError('Missing native DeckyFileServer helper')
native=gzip.decompress(base64.b64decode(packed,validate=True))
if (hashlib.sha256(native).hexdigest()!=hashes.get(arch) or native[:4]!=b'\x7fELF'
or int.from_bytes(native[18:20],'little')!=machine):
raise RuntimeError('DeckyFileServer native helper mismatch')
helper.write_bytes(native)
helper.chmod(0o700)
manifest_path=unpacked/'plugin.json'
manifest=json.loads(manifest_path.read_text())
if manifest.get('name')!='DeckyFileServer' or manifest.get('api_version')!=1 or manifest.get('flags')!=['_root']:
raise RuntimeError('DeckyFileServer manifest mismatch')
manifest['flags']=[]
manifest['publish']['tags']=[tag for tag in manifest['publish']['tags'] if tag not in ('root','_root')]
manifest_path.write_text(json.dumps(manifest)+'\n')
text=source.read_text()
# Go flag parsing stops at an empty positional argument. Filter
# omitted boolean flags so -disablethumbnails actually takes effect.
text=text.replace(' ],\n stdout=PIPE,',
' ] if arg],\n stdout=PIPE,')
text=text.replace(' [\n f"{decky.DECKY_PLUGIN_DIR}',
' [arg for arg in [\n f"{decky.DECKY_PLUGIN_DIR}')
# Hostname DNS is not guaranteed on Frame OS. A UDP connect
# selects the kernel source address without sending any packet.
text=text.replace(' return socket.gethostbyname(socket.gethostname())',
' try:\n return socket.gethostbyname(socket.gethostname())\n'
' except OSError:\n with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:\n'
' try:\n probe.connect(("192.0.2.1", 9))\n'
' return probe.getsockname()[0]\n'
' except OSError:\n return "127.0.0.1"')
# Configure thumbnail policy before launching the helper.
old=' if "server_running" in status:\n await Plugin.set_server_running(self, status["server_running"])\n if "disable_thumbnails" in status:\n await Plugin.set_disable_thumbnails(self, status["disable_thumbnails"])'
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))
compat_shims.append('deckyfileserver-user-native')
# 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.
if int(plugin_id)==96 and digest=='973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312' and folder=='simple-timer':
main=unpacked/'main.py'
source=main.read_text(encoding='utf-8')
start=' async def start_timer('; end=' async def load_recents('
if source.count(start)!=1 or source.count(end)!=1: raise RuntimeError('Simple Timer compatibility source changed')
a=source.index(start); b=source.index(end,a)
source=source[:a]+""" async def _stop_timer(self):
# Call only under timer_lock. Joining prevents a replaced task from
# updating the new timer or sending a stale completion notification.
task, self.timer_task = self.timer_task, None
if task is not None and task is not asyncio.current_task():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def start_timer(self, seconds: int):
async with self.timer_lock:
await self._stop_timer()
recent = await self.settings_getSetting(settings_key_recent_timers, [])
await self.settings_setSetting(settings_key_recent_timers, [seconds] + recent[:4])
deadline = time.time() + seconds
await self.settings_setSetting(settings_key_timer_end, deadline)
await self.settings_commit()
self.seconds_remaining = seconds
self.timer_task = self.loop.create_task(self.timer_handler(datetime.fromtimestamp(deadline)))
await self.load_recents()
await self.load_remaining_seconds()
async def timer_handler(self, timer_end: datetime):
while True:
remaining = timer_end.timestamp() - time.time()
if remaining <= 0:
break
await asyncio.sleep(min(5, remaining))
self.seconds_remaining = max(0, timer_end.timestamp() - time.time())
await self.load_remaining_seconds()
async with self.timer_lock:
if self.timer_task is not asyncio.current_task():
return
self.timer_task = None
self.seconds_remaining = 0
await self.settings_setSetting(settings_key_timer_end, None)
await self.settings_commit()
await self.load_remaining_seconds()
subtle = await self.settings_getSetting(settings_key_subtle_mode, False)
if remaining <= -10:
await decky.emit("simple_timer_event", "Your timer has expired!", True)
else:
await decky.emit("simple_timer_event", "Your session has ended!", subtle)
async def cancel_timer(self):
async with self.timer_lock:
await self._stop_timer()
self.seconds_remaining = 0
await self.settings_setSetting(settings_key_timer_end, None)
await self.settings_commit()
await self.load_remaining_seconds()
"""+source[b:]
start=' async def _main('; end=' async def _uninstall('
if source.count(start)!=1 or source.count(end)!=1: raise RuntimeError('Simple Timer compatibility source changed')
a=source.index(start); b=source.index(end,a)
source=source[:a]+""" async def _main(self):
self.loop = asyncio.get_running_loop()
self.timer_task = None
self.timer_lock = asyncio.Lock()
self.seconds_remaining = 0
await self.settings_read()
deadline = await self.settings_getSetting(settings_key_timer_end, None)
if deadline is not None:
self.seconds_remaining = max(0, deadline - time.time())
if self.seconds_remaining > 0:
# Resume the exact deadline without adding another recent timer.
self.timer_task = self.loop.create_task(self.timer_handler(datetime.fromtimestamp(deadline)))
else:
await self.cancel_timer()
await self.load_recents()
await self.load_subtle_mode()
decky.logger.info("Simple Timer has been initialised.")
async def _unload(self):
async with self.timer_lock:
# Backend unload is a pause; only explicit cancel clears the deadline.
await self._stop_timer()
decky.logger.info("Simple Timer has been unloaded.")
"""+source[b:]
main.write_text(source,encoding='utf-8')
compat_shims.append('simple-timer-lifecycle')
# Shotty 0.1.0 imports click.get_app_dir but never calls it. Its
# official ZIP omits click and Decky's bundled Python lacks it.
# Supply the Linux function only inside this exact plugin's
# py_modules path; other plugins keep their original imports.
if int(plugin_id)==53 and digest=='ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe' and folder=='Shotty':
compat=unpacked/'py_modules/click.py'
if compat.exists(): raise RuntimeError('Shotty compatibility module collides with archive')
compat.parent.mkdir(parents=True,exist_ok=True)
compat.write_text(
'"""Frame Control Linux compatibility for Shotty 0.1.0."""\n'
'import os\n'
'def get_app_dir(app_name, roaming=True, force_posix=False):\n'
' if not isinstance(app_name, str) or not app_name:\n'
' raise ValueError("app_name must be a nonempty string")\n'
' base = os.environ.get("XDG_CONFIG_HOME")\n'
' if not base or not os.path.isabs(base):\n'
' base = os.path.join(os.path.expanduser("~"), ".config")\n'
' return os.path.join(base, app_name)\n',encoding='utf-8')
compat_shims.append('shotty-click-app-dir')
# DeckySpy's official archive omits its required psutil dependency.
# The BSD wheels are packaged offline by Frame Control and selected
# for the native loader; no pip, network or system path changes.
if int(plugin_id)==89 and digest=='2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7' and folder=='decky-spy':
arch=platform.machine()
pins={
'x86_64':'076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
'aarch64':'b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e',
}
encoded=globals().get('FRAMEY_BACKEND_WHEELS',{}).get(arch)
if arch not in pins or not encoded:
raise RuntimeError('Missing native psutil dependency for DeckySpy')
wheel_data=base64.b64decode(encoded,validate=True)
if hashlib.sha256(wheel_data).hexdigest()!=pins[arch]:
raise RuntimeError('DeckySpy psutil dependency SHA-256 mismatch')
modules=unpacked/'py_modules'
for name in ('psutil','psutil-7.2.2.dist-info'):
if (modules/name).exists(): raise RuntimeError('DeckySpy dependency collides with archive')
with zipfile.ZipFile(io.BytesIO(wheel_data)) as wheel:
for member in wheel.infolist():
path=pathlib.PurePosixPath(member.filename)
if (path.is_absolute() or '..' in path.parts or not path.parts or
path.parts[0] not in ('psutil','psutil-7.2.2.dist-info')):
raise RuntimeError('Unsafe psutil dependency member')
if member.is_dir(): continue
payload=wheel.read(member)
if payload[:4]==b'\x7fELF' and int.from_bytes(payload[18:20],'little')!=({'x86_64':62,'aarch64':183}[arch]):
raise RuntimeError('Incorrect psutil native architecture')
dest=modules/member.filename
dest.parent.mkdir(parents=True,exist_ok=True)
dest.write_bytes(payload)
dest.chmod(0o600)
compat=modules/'framey_battery.py'
if compat.exists(): raise RuntimeError('DeckySpy battery module collides with archive')
battery_source=globals().get('FRAMEY_BACKEND_BATTERY_SOURCE')
if not isinstance(battery_source,str) or not battery_source:
raise RuntimeError('Missing DeckySpy battery compatibility source')
compat.write_text(battery_source,encoding='utf-8')
main=unpacked/'main.py'
source=main.read_text(encoding='utf-8')
old=' battery = psutil.sensors_battery()\n'
if source.count(old)!=1: raise RuntimeError('DeckySpy battery source changed')
source=source.replace(old,' from framey_battery import sensors_battery\n battery = sensors_battery()\n')
main.write_text(source,encoding='utf-8')
compat_shims.append('deckyspy-psutil-and-battery')
# These legacy backends launch system dbus-send (1.1.6 via /bin/sh).
# Decky's PyInstaller LD_LIBRARY_PATH selects bundled libtinfo built
# against newer glibc than Frame OS. Restore its saved system path
# only in the copied subprocess environment; retain legacy self calls.
music_digests={
12:'246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62',
111:'3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9',
}
if digest==music_digests.get(int(plugin_id)) and folder=='MusicControl':
main=unpacked/'main.py'
source=main.read_text(encoding='utf-8')
old=' env = os.environ.copy()\n'
if source.count(old)!=1:
raise RuntimeError('Music Control compatibility source changed')
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')
# 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')
@@ -221,7 +553,9 @@ try:
if isinstance(peer_data,dict) and peer_data.get('name')==proposed_name:
raise RuntimeError('A different plugin with this name is already installed')
except (OSError,ValueError,UnicodeError): continue
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
record={'storeId':int(plugin_id),'sha256':digest,'version':version}
if compat_shims: record['compatShims']=compat_shims
(unpacked/marker).write_text(json.dumps(record)+'\n')
# Stop even a failed unit before removal to cancel any pending auto-restart.
if running or action=='remove': service('stop','frame-control-decky.service')
try:
@@ -304,7 +638,7 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
report({'install':'Installing', 'update':'Updating', 'remove':'Removing'}[action] + ' plugin on Frame', 80)
return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]} {expected_installed}',
stdin=APPLY, timeout=90))
stdin=apply_script(plugin_id, plugin["sha256"]), timeout=90))
finally:
if data is not None:
try: ssh('rm -f ' + remote, timeout=10)
+4 -1
View File
@@ -163,7 +163,10 @@ def bundle():
for path in sorted(folder.rglob('*')):
if not path.is_file() or path.is_symlink() or '__pycache__' in path.parts:
continue
if path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md') and path.name not in ('ssh',):
native_dependency = path.relative_to(ROOT).as_posix() in (
'ui/vendor/psutil-7.2.2/aarch64.whl', 'ui/vendor/psutil-7.2.2/x86_64.whl')
if (path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md')
and path.name != 'ssh' and not native_dependency):
continue
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(ROOT).as_posix())
+10 -1
View File
@@ -137,7 +137,16 @@ class Handler(BaseHTTPRequestHandler):
return self.reply(403, {'error': 'Forbidden'})
try:
size = int(self.headers.get('Content-Length', '0'))
if not 0 < size <= 1024:
if size > 1024:
# Windows can reset a connection closed with an unread POST
# body before the client receives our 400 response. Drain only
# a bounded rejected body; never buffer an unbounded payload.
if size <= 64 * 1024:
self.rfile.read(size)
else:
self.close_connection = True
raise ValueError('Invalid request size')
if size <= 0:
raise ValueError('Invalid request size')
body = json.loads(self.rfile.read(size))
if not isinstance(body, dict):
+179 -49
View File
@@ -183,7 +183,7 @@
"https://github.com/suchmememanyskill/SDH-CssLoader.git"
],
"status": "partial",
"reason": "Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell.",
"reason": "Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": false,
"verification": [
"static archive",
@@ -238,6 +238,11 @@
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -474,12 +479,14 @@
"https://github.com/mirobouma/MusicControl.git"
],
"status": "partial",
"reason": "Legacy media controls initialize; player discovery and playback are unverified.",
"reason": "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.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"isolated Decky 3.3.0 + MPRIS fixture",
"real Frame SSH"
],
"browser": {
"id": 12,
@@ -506,7 +513,7 @@
"folder": "MusicControl"
},
"backend": "Python",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"hardware": "User-session MPRIS player and dbus-send required; no Deck-specific hardware.",
"uiSurfaces": [
"local/Steam routes"
],
@@ -515,7 +522,9 @@
"sha256": "246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62",
"archiveValidation": "accepted",
"result": "installed-removed"
}
},
"compatShim": "music-control-system-dbus-env",
"evidence": "docs/evidence/framey-mpris-runtime-2026-10-26.md"
},
{
"id": 13,
@@ -580,12 +589,13 @@
"https://github.com/wynn1212/SDH-PauseGames.git"
],
"status": "partial",
"reason": "Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied.",
"reason": "Settings and a synthetic running-game list render locally. Actual Decky 3.3.0 discovery, private settings writes/readback and exclusion-list persistence pass on amd64 and ARM64 Frame; standalone Toggle now honors its value prop. Live empty-list/lifetime checks passed earlier. Real process pause/resume, automatic pausing, suspend integration and worn VR remain unverified/unsupported.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"real Frame SSH"
],
"browser": {
"id": 13,
@@ -618,6 +628,11 @@
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
"mode": "synthetic nonempty running/library replay; backend and network denied",
"frontendReady": true
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": true
}
},
{
@@ -927,7 +942,7 @@
"logs": [
{
"kind": "error",
"message": "[Bluetooth] !! get_bluetooth_status \u2192 Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
"message": "[Bluetooth] !! get_bluetooth_status → Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
@@ -935,7 +950,7 @@
},
{
"kind": "error",
"message": "[Bluetooth] !! get_paired_devices_with_info \u2192 Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
"message": "[Bluetooth] !! get_paired_devices_with_info → Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
@@ -1790,7 +1805,7 @@
},
"backend": "Python + native payload",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"nativePort": "The bin/hash helper needs an ARM64 build and cross-platform hashing tests; no ARM64 artifact is published.",
"nativePort": "A source-matched ARM64 helper matched one synthetic ROM on the real Frame (docs/evidence/framey-emuchievements-arm-2026-10-26.md). The pinned upstream source proposed for packaging also has unsafe archive extraction and cleanup paths (docs/evidence/framey-native-port-blockers-2026-10-26.md); fix and test those before shipping an ARM64 binary. The official x86_64 ZIP remains blocked; format coverage, plugin backend/UI, login and worn VR are unverified.",
"uiSurfaces": [
"local/Steam routes",
"toasts",
@@ -1958,7 +1973,7 @@
"https://github.com/jurassicplayer/decky-autoflatpaks.git"
],
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service.",
"reason": "This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -2000,6 +2015,11 @@
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -3593,7 +3613,7 @@
"https://github.com/GedasFX/decky-cloud-save.git"
],
"status": "broken",
"reason": "Unsafe plugin archive member.",
"reason": "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.",
"blockInstall": true,
"verification": [
"static archive",
@@ -3628,7 +3648,7 @@
},
"backend": "Python + native payload",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"nativePort": "rclone needs an ARM64 artifact; this ZIP also fails safe member validation. Cloud writes were not exercised.",
"nativePort": "An ARM64 rclone artifact and safe package policy are needed; the official 60,776,600-byte x86_64 member fails archive validation. No cloud writes were run (docs/evidence/framey-native-port-blockers-2026-10-26.md).",
"uiSurfaces": [
"local/Steam routes",
"toasts",
@@ -3795,8 +3815,8 @@
"https://github.com/SteamDeckHomebrew/decky-loader",
"https://github.com/yt-dlp/yt-dlp"
],
"status": "broken",
"reason": "Frontend audit failed: Cannot read properties of null (reading 'type').",
"status": "partial",
"reason": "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.",
"blockInstall": false,
"verification": [
"static archive",
@@ -3829,6 +3849,13 @@
"sha256": "9ce293ab4db212c03c6b96dfbc5e4d92e874d33387463f53223773921f75a80e",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"renderFollowUp": {
"evidence": "docs/evidence/framey-game-theme-music-2026-10-26.md",
"mode": "exact archive in opaque local Chromium; backend and network denied",
"frontendReady": true,
"settingsRendered": true,
"steamGamePagePatched": false
}
},
{
@@ -3875,14 +3902,15 @@
"sourceURLs": [
"https://github.com/safijari/Shotty"
],
"status": "broken",
"reason": "Library snapshot fixes the frontend allApps crash. Actual published Decky 3.3.0 amd64 backend fails importing click.get_app_dir (ModuleNotFoundError); the archive does not bundle click. Screenshot aggregation/rescue was not run.",
"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.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame",
"real Frame SSH"
"real Frame SSH",
"isolated Python backend"
],
"browser": {
"id": 53,
@@ -3913,6 +3941,13 @@
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
"frontendReady": true
},
"backendFollowUp": {
"evidence": "docs/evidence/framey-shotty-python-2026-10-26.md",
"mode": "exact archive managed transaction and isolated Python import/copy with temporary HOME",
"compatShim": "shotty-click-app-dir",
"syntheticScreenshotCopied": true,
"actualDeckyArm64": false
}
},
{
@@ -4313,13 +4348,15 @@
"https://github.com/SteamDeckHomebrew/decky-plugin-template.git",
"https://github.com/grimkor/DeckyFileServer"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend.",
"blockInstall": true,
"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.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"real Decky 3.3.0 amd64",
"real Frame SSH"
],
"browser": {
"id": 60,
@@ -4338,7 +4375,7 @@
},
"backend": "Python + native payload",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"nativePort": "Native file-server backend needs ARM64 dependencies; root requirement also blocks the user service.",
"nativePort": "GPL-3.0 helper rebuilt offline for ARM64 with vendored corresponding source. Actual Frame user service passes HTTPS listing/download of a private public fixture; uploads disabled. Exact managed digest only; other native/root archives remain blocked (docs/evidence/framey-fileserver-arm64-2026-10-27.md).",
"uiSurfaces": [],
"fakeFrame": {
"id": 60,
@@ -4346,6 +4383,20 @@
"archiveValidation": "Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"requiredShims": [
"deckyfileserver-user-native"
],
"managedNative": {
"shim": "deckyfileserver-user-native",
"helperSha256": "86b80021ebd62ed619eba065757c1122defa5a74b2c045aa44a0c6b4403a508a",
"sourceCommit": "dcf3f3ce5104d05811fa8c156c4f58632b84b313",
"verification": "Real Decky discovery/public RPC and private read-only HTTPS fixture; docs/evidence/framey-fileserver-arm64-2026-10-27.md"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -4772,7 +4823,7 @@
"https://github.com/theCapypara/steamdeck-decky-syncthing.git"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog.",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -4813,6 +4864,11 @@
"archiveValidation": "Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog",
"result": "blocked",
"reason": "ValueError: Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog."
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -6966,13 +7022,14 @@
"https://github.com/Seraphli/decky-spy",
"https://github.com/Seraphli/decky-spy.git"
],
"status": "partial",
"reason": "Initializer succeeds; account tokens and external service integration were not exercised.",
"status": "works",
"reason": "Works for read-only system monitoring: live CPU, memory, uptime, processes, interfaces and Frame battery render in local Chromium with actual Decky 3.3.0 ARM64 on the Frame; silent warning and private settings save/reopen pass. Exact managed install adds pinned BSD psutil wheels and a battery-name fallback. Existing copies need update/reinstall. Automatic alerts, resume/game-time warnings, sound and worn VR remain unverified; monitoring requires an open panel.",
"blockInstall": false,
"verification": [
"static archive",
"fake-Frame",
"local browser",
"fake-Frame"
"real Frame SSH"
],
"browser": {
"id": 89,
@@ -6992,8 +7049,8 @@
"accepted": true,
"folder": "decky-spy"
},
"backend": "Python",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"backend": "Python; managed psutil 7.2.2 stable-ABI native amd64/ARM64 dependency",
"hardware": "Read-only Linux /proc and network-interface metrics; Frame battery read by power_supply type=Battery. No Deck-specific hardware writes.",
"uiSurfaces": [
"toasts"
],
@@ -7007,7 +7064,18 @@
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
"mode": "synthetic nonempty running/library replay; backend and network denied",
"frontendReady": true
}
},
"backendFollowUp": {
"evidence": "docs/evidence/framey-deckyspy-2026-10-26.json",
"mode": "actual Decky 3.3.0 ARM64 with private removable service under shared lock; local opaque Chromium through trusted SSH RPC pipe",
"compatShims": [
"deckyspy-psutil-and-battery"
],
"existingInstallRequires": "explicit managed update or reinstall"
},
"requiredShims": [
"deckyspy-psutil-and-battery"
]
},
{
"id": 90,
@@ -7593,8 +7661,8 @@
"https://github.com/decktools-xyz/simple-timer",
"https://github.com/decktools-xyz/simple-timer.git"
],
"status": "partial",
"reason": "Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified.",
"status": "works",
"reason": "Verified for silent in-panel session timers: Chromium Begin/Cancel/countdown/toast with real ARM64 Decky 3.3.0; repaired cancel, replace, completion and active/canceled/expired restarts pass actual loader RPC/events. Update/reinstall required. Sound and suspend unsupported; alerts require an open panel; worn VR unverified.",
"blockInstall": false,
"verification": [
"static archive",
@@ -7642,6 +7710,22 @@
"subscriptionsAfterPanelClose": 0
},
"method": "local Chromium + real Frame SSH; private actual Decky 3.3.0 ARM64 service"
},
"requiredShims": [
"simple-timer-lifecycle"
],
"lifecycle": {
"evidence": "docs/evidence/framey-simple-timer-2026-10-26.md",
"actualDeckyVersion": "3.3.0",
"instanceApi": 1,
"backendRestarts": [
"active",
"canceled",
"expired"
],
"realFrame": true,
"localBrowser": true,
"fakeFrame": true
}
},
{
@@ -9567,12 +9651,15 @@
"https://github.com/martinpl/music-control"
],
"status": "partial",
"reason": "Panel initializer succeeds with backend/network blocked; actual controls and backend remain unverified.",
"reason": "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.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"isolated Python backend",
"isolated Decky 3.3.0 + MPRIS fixture",
"real Frame SSH"
],
"browser": {
"id": 111,
@@ -9599,7 +9686,7 @@
"folder": "MusicControl"
},
"backend": "Python",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"hardware": "User-session MPRIS player and dbus-send required; no Deck-specific hardware.",
"uiSurfaces": [
"local/Steam routes"
],
@@ -9608,7 +9695,9 @@
"sha256": "3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9",
"archiveValidation": "accepted",
"result": "installed-removed"
}
},
"compatShim": "music-control-system-dbus-env",
"evidence": "docs/evidence/framey-mpris-runtime-2026-10-26.md"
},
{
"id": 112,
@@ -9778,7 +9867,7 @@
"https://github.com/xXJSONDeruloXx/lsfg-vk"
],
"status": "partial",
"reason": "Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated.",
"reason": "Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": false,
"verification": [
"static archive",
@@ -9827,6 +9916,11 @@
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
"mode": "synthetic nonempty running/library replay; backend and network denied",
"frontendReady": true
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -10148,7 +10242,7 @@
"https://github.com/wtlnetwork/muon.git"
],
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service.",
"reason": "This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -10192,6 +10286,11 @@
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -11043,13 +11142,17 @@
"https://github.com/moyoez/decky-localsend",
"https://github.com/moyoez/decky-localsend.git"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core.",
"blockInstall": true,
"status": "partial",
"reason": "Exact managed 0.4.5 archive replaces its x86 helper with a pinned ARM64 build. Published Decky 3.3.0 ARM64 stopped status/configuration RPCs and a private real-Frame ARM64 helper marker transfer passed. The managed service, user-facing discovery/transfer, Steam routes and worn VR remain unverified.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"exact archive and source hashes",
"local Chromium initial panel",
"host managed transaction",
"fake Frame managed install/remove",
"private x86 backend transfer",
"published Decky 3.3.0 AMD64/ARM64 stopped status/configuration RPCs",
"private real Frame ARM64 helper marker transfer"
],
"browser": {
"id": 125,
@@ -11075,7 +11178,7 @@
},
"backend": "Python + native payload",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"nativePort": "MIT LocalSend-Go 66edc509a28b9f877e7374504892dcbc653a4da4 reproducibly cross-built with Go 1.25.7/CGO_ENABLED=0 for ARM64; SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Help/official backend CLI flags and protocol 2.0 info endpoint passed on real Frame in an isolated network namespace. No file transfer or full plugin/CEF integration tested; exact source match to shipped helper unproven. Official ZIP stays x86_64 and blocked.",
"nativePort": "Exact official x86 helper is reproducible from pinned MIT source; the managed installer replaces only the exact 0.4.5 digest with SHA-256 e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae on ARM64. The Frame rechecks original Python/helper bytes, replacement digest and ELF machine; x86 keeps the original helper. Local official x86 backend/proxy and real Frame rebuilt ARM64 helper each passed one private marker transfer. Published Decky 3.3.0 AMD64/ARM64 discovery and stopped status/configuration RPCs passed. The actual managed Decky service on Frame, network discovery, user-facing transfer, Steam routes and worn VR remain unverified (docs/evidence/framey-localsend-managed-2026-10-27.md).",
"uiSurfaces": [
"local/Steam routes",
"toasts",
@@ -11087,6 +11190,15 @@
"archiveValidation": "Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core",
"result": "blocked",
"reason": "ValueError: Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core."
},
"requiredShims": [
"localsend-digest-native"
],
"managedNative": {
"shim": "localsend-digest-native",
"helperSha256": "e20363237e606ee65eae857792176d21a0bb8ba863489952728eaf2b13277dae",
"sourceCommit": "ad28b8e9440e17e44b4735a6c4aa0512009cf333",
"verification": "Host transaction and fake Frame managed install/remove; local original x86 backend private marker transfer. Published Decky ARM64 runtime and real Frame transfer pending (docs/evidence/framey-localsend-managed-2026-10-27.md)."
}
},
{
@@ -11175,7 +11287,7 @@
"https://github.com/casey/just"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object.",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -11209,6 +11321,11 @@
"archiveValidation": "Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello",
"result": "blocked",
"reason": "ValueError: Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object."
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -11493,7 +11610,7 @@
"https://github.com/tikh-iv/ShadowDeck.git"
],
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service.",
"reason": "This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -11531,6 +11648,11 @@
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -12061,12 +12183,13 @@
"https://github.com/sponsors/santojon"
],
"status": "partial",
"reason": "The exact chunked frontend now initializes and renders Deck Shelves settings in an opaque local Chromium panel. Backend calls were denied; Home-screen shelf injection has no verified Steam UI target in Framey. Settings writes, real backend behavior and worn VR are unverified.",
"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.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"isolated Python backend"
],
"browser": {
"id": 135,
@@ -12101,6 +12224,13 @@
"mode": "exact archive in opaque local Chromium; backend and network denied",
"frontendReady": true,
"settingsRendered": true
},
"backendFollowUp": {
"evidence": "docs/evidence/framey-deck-shelves-browser-2026-10-26.md",
"mode": "exact archive Python with stub Decky module and temporary settings directory",
"defaultSettingsRead": true,
"shelfSaveReopen": true,
"actualDeckyRpc": false
}
},
{
+43 -13
View File
@@ -56,6 +56,7 @@
const pending = new Map();
const routes = new Map();
const routeListeners = new Set();
const routeHistory = [];
let currentRoute = null;
let currentParams = {};
let nextId = 0;
@@ -190,7 +191,7 @@
parent.postMessage({type:'framey:events:close', session:cacheSession}, '*');
});
addEventListener('pointerup', event => {
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
if (event.isTrusted) parent.postMessage({type:'framey:input', session:cacheSession}, '*');
}, {passive:true});
addEventListener('pointermove', event => {
if (event.isTrusted) lastScrollTarget = scrollTarget(event.target);
@@ -219,6 +220,11 @@
};
const Field = ({label, description, children}) => R.createElement('label', null,
R.createElement('strong', null, label), description && R.createElement('small', null, description), children);
// DFL/@decky/ui Toggle uses value; ToggleField uses checked. Keep the
// standalone control separate so persisted true settings render correctly.
const Toggle = ({value, disabled, onChange}) => R.createElement('input', {
type:'checkbox', checked:!!value, disabled:!!disabled,
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}});
const ToggleField = ({label, description, checked, disabled, onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:'checkbox', checked:!!checked, disabled:!!disabled,
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}));
@@ -278,8 +284,10 @@
title && R.createElement('h2', null, title), R.createElement(Tabs, {
tabs:pages.filter(page=>page.visible!==false).map((page,index)=>({
id:page.route || String(index), title:page.title, content:page.content}))}));
const ProgressBar = ({nProgress=0, value, max=100}) => R.createElement('progress', {
value:Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max});
const ProgressBar = ({nProgress=0, value, max=100, indeterminate=false}) => R.createElement('progress', {
value:indeterminate ? undefined : Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max});
const ProgressBarWithInfo = ({sOperationText, ...props}) => R.createElement('div', null,
ProgressBar(props), sOperationText == null ? null : R.createElement('small', {role:'status'}, sOperationText));
const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) =>
R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled),
onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children);
@@ -368,15 +376,31 @@
}
return null;
}
function navigate(path) {
if (path !== null && !localPath(path)) return false;
const match = path === null ? null : activeRoute(path);
if (path !== null && !match) return false;
function showRoute(path, match) {
currentRoute = path;
currentParams = match?.params || {};
for (const listener of routeListeners) listener(path);
return true;
}
function navigate(path) {
if (path !== null && !localPath(path)) return false;
const match = path === null ? null : activeRoute(path);
if (path !== null && !match) return false;
if (path === null) routeHistory.length = 0;
else if (path !== currentRoute) {
routeHistory.push(currentRoute);
if (routeHistory.length > 32) routeHistory.shift();
}
return showRoute(path, match);
}
function navigateBack() {
while (routeHistory.length) {
const previous = routeHistory.pop();
const match = previous === null ? null : activeRoute(previous);
if (previous === null || match) return showRoute(previous, match);
}
return navigate(null);
}
const routerHook = {
addRoute(path, component) {
if (!routePattern(path) ||
@@ -390,6 +414,7 @@
addPatch:()=>()=>{}, removePatch:()=>{}, addGlobalComponent:()=>{}, removeGlobalComponent:()=>{},
};
const noPatch = () => {const undo=()=>{};undo.unpatch=undo;return undo;};
class MissingSteamComponent {}
const showModal = element => {
let resolveClosed;
const ClosedPromise = new Promise(resolve=>{resolveClosed=resolve;});
@@ -475,7 +500,7 @@
Focusable, ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
Toggle, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
SuspensefulImage:({alt='', ...props})=>R.createElement('img', {...props, alt}),
Marquee:passthrough('span'), DialogBody:passthrough('div'), DialogBodyText:passthrough('p'),
DialogHeader:passthrough('h2'), DialogSubHeader:passthrough('h3'), DialogFooter:passthrough('footer'),
@@ -483,14 +508,14 @@
DialogControlsSectionHeader:passthrough('h3'), ModalRoot:passthrough('section'),
ModalPosition:passthrough('div'),
Menu:passthrough('div'), MenuGroup:passthrough('section'), MenuItem,
ProgressBar, ProgressBarWithInfo:ProgressBar,
ProgressBar, ProgressBarItem:ProgressBar, ProgressBarWithInfo,
gamepadDialogClasses:{}, quickAccessMenuClasses:{}, gamepadTabbedPageClasses:{},
gamepadSliderClasses:{}, gamepadContextMenuClasses:{}, scrollPanelClasses:{},
quickAccessControlsClasses:{}, mainMenuAppRunningClasses:{}, libraryAssetImageClasses:{},
joinClassNames:(...names)=>names.filter(Boolean).join(' '),
sleep:ms=>new Promise(resolve=>setTimeout(resolve,Math.max(0,Number(ms)||0))),
staticClasses: {Title:'framey-title'},
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), CloseSideMenus:()=>navigate(null),
Navigation: {Navigate:navigate, NavigateBack:navigateBack, CloseSideMenus:()=>navigate(null),
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
showModal, showContextMenu:showModal,
// Steam app, library, and store patches have no target inside this panel.
@@ -509,7 +534,11 @@
for(const classes of classModules.values())
for(const name of Object.keys(classes)) if(classes[name]===token)return name;
},
findSP:()=>null, fakeRenderComponent:()=>null,
findSP:()=>null,
// Some legacy panels dereference the type of a Steam-only component before
// installing a patch. Give them an inert local type; no Steam tree is
// rendered or patched in this isolated iframe.
fakeRenderComponent:()=>({type:MissingSteamComponent}),
appDetailsClasses: {}, appDetailsHeaderClasses: {},
useParams: () => currentParams,
};
@@ -824,10 +853,11 @@
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
R.createElement(PluginSurface, {plugin})));
parent.postMessage({type:'framey:ready'}, '*');
parent.postMessage({type:'framey:ready', session:cacheSession}, '*');
} catch (failure) {
error.textContent = `This Decky panel is not compatible with Framey: ${failure.message}`;
parent.postMessage({type:'framey:error', message:failure.message}, '*');
parent.postMessage({type:'framey:error', session:cacheSession,
message:failure.message}, '*');
}
}
start();
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more