Compare commits

..
40 changed files with 149 additions and 1375 deletions

No files matched your search

-32
View File
@@ -1,32 +0,0 @@
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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.49",
"version": "0.4.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.49",
"version": "0.4.48",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.49",
"version": "0.4.48",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -1,119 +0,0 @@
{
"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
}
}
@@ -1,58 +0,0 @@
# 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.
-85
View File
@@ -1,85 +0,0 @@
# 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.
@@ -1,37 +0,0 @@
# 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.
+3 -3
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: 5 · Partial: 51 · Broken: 49 · N/A: 11.**
**Works: 4 · Partial: 52 · Broken: 49 · 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. 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
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional Line truncated
## Every current plugin
@@ -104,7 +104,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 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 | 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 |
| 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 |
| 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 |
+3 -7
View File
@@ -70,13 +70,9 @@ 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. 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.
countdown, completion toast and persisted recents/subtle mode worked. Simple
Timer remains partial: its own expired-timer startup raises a missing
`timer_task` error, sound/suspend are unsupported, and worn VR was not checked.
DFL module and class lookup searches a finite registry of Framey's own controls,
CSS maps and an explicitly unsupported optional UI-audio player. It follows DFL's
+1 -15
View File
@@ -34,27 +34,13 @@ 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, 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, and that all ten installers
The script checks that the tag is on GitHub 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
-38
View File
@@ -549,41 +549,3 @@ 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 and Shotty. 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>;
+2 -9
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. That initial pass left Simple Timer partial because its own expired-timer startup '
'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, '
@@ -85,14 +85,7 @@ def render(data):
'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 [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.', '',
'Actual media playback on ARM64, artwork and worn VR remain unverified; both stay partial.', '',
'## 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.', '',
-115
View File
@@ -1,115 +0,0 @@
#!/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())
if shims != {case['shim'] for case in cases}:
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')]
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()
-11
View File
@@ -31,11 +31,6 @@ 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
@@ -61,12 +56,6 @@ 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
-109
View File
@@ -1,109 +0,0 @@
#!/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")
data = api(f"actions/runs/{run['id']}/attempts/{attempt}/jobs?per_page=100")
jobs = data.get("jobs") or []
if data.get("total_count") != len(jobs):
raise GateError(f"run {run['id']} has more jobs than the gate inspected")
names = [job.get("name") for job in jobs]
if len(names) != len(set(names)) or not required.issubset(names):
missing = sorted(required.difference(names))
raise GateError(f"run {run['id']} has missing or duplicate jobs: {missing}")
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']} has unfinished, failed, cancelled, or skipped jobs: {bad}")
print(f"gate passed: {run.get('html_url')} ({len(jobs)} jobs)")
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
@@ -1,12 +0,0 @@
# 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"]
-22
View File
@@ -1,22 +0,0 @@
[
{
"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": 96,
"shim": "simple-timer-lifecycle",
"probe": "timer-lifecycle"
}
]
-229
View File
@@ -1,229 +0,0 @@
"""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 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-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,
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['compatShims'] == [case['shim']], 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
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']
if 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'
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
@@ -1,148 +0,0 @@
"""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)
-3
View File
@@ -185,9 +185,6 @@ 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:
+1 -37
View File
@@ -13,7 +13,6 @@ 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:
@@ -50,18 +49,6 @@ 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):
@@ -80,30 +67,7 @@ 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,
"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": 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/"):
run_id = endpoint[len(repo + "actions/runs/"):].split("/")[0]
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("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}))
print(json.dumps({"ref": "refs/tags/" + tag}))
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))
Binary file not shown.
+2 -9
View File
@@ -1,14 +1,13 @@
# Pinned Decky store test archives
These are unmodified ZIPs downloaded from the official Decky store CDN on
2026-10-25, except `96.zip` and `111.zip` downloaded on 2026-10-26. Each ZIP includes its
2026-10-25, except `111.zip` downloaded on 2026-10-26. 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 |
| --- | --- | --- | --- |
| `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` |
@@ -20,7 +19,7 @@ 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 include [Simple Timer](https://github.com/decktools-xyz/simple-timer) and
The matching source projects are
[CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader),
[MusicControl](https://github.com/nikurasu/MusicControl),
[Music Control](https://github.com/martinpl/music-control),
@@ -42,9 +41,3 @@ 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.
-2
View File
@@ -4,8 +4,6 @@ from pathlib import Path
ROOT = Path(__file__).parent / 'fixtures/framey-official'
PLUGINS = (
{'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'},
+23
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 {
@@ -71,6 +72,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);
+6 -23
View File
@@ -276,29 +276,12 @@ class ArtworkTests(unittest.TestCase):
import threading
from apk_sources import _images
gate = threading.Event()
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 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()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
-31
View File
@@ -161,37 +161,6 @@ class StoreTransaction(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertTrue(external.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)
-10
View File
@@ -59,16 +59,6 @@ class Compatibility(unittest.TestCase):
frame_decky_store.change(no_ssh, 'install', 42)
download.assert_not_called()
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')
-36
View File
@@ -1,36 +0,0 @@
"""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', 'timer-lifecycle'))
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()
+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()
+2 -34
View File
@@ -37,14 +37,12 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
def run_script(self, releases, *args, tags="v9.8.7"):
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_GATES=str(d / "gates.json"))
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.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
@@ -122,36 +120,6 @@ 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_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()
+2 -5
View File
@@ -40,9 +40,7 @@ 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],
'compatShims': [s[:100] for s in owned.get('compatShims', []) if isinstance(s, str)][:16]
if isinstance(owned.get('compatShims'), list) else []})
'sha256': str(owned.get('sha256', ''))[:64]})
except (ValueError, OSError, UnicodeError): pass
return found
os_release = {}
@@ -168,8 +166,7 @@ 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'),
installed_shims=plugin.get('compatShims', []))
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'))
return result
-94
View File
@@ -209,100 +209,6 @@ try:
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
compat_shims=[]
# 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
+2 -18
View File
@@ -7612,8 +7612,8 @@
"https://github.com/decktools-xyz/simple-timer",
"https://github.com/decktools-xyz/simple-timer.git"
],
"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.",
"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.",
"blockInstall": false,
"verification": [
"static archive",
@@ -7661,22 +7661,6 @@
"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
}
},
{
+2 -10
View File
@@ -12,7 +12,7 @@ def _records(path):
return json.loads(path.read_text())['plugins']
def lookup(plugin_id, digest, version, installed_shims=None):
def lookup(plugin_id, digest, version):
unknown = {'status': 'unverified', 'reason': 'This exact store version has not been audited on Frame.',
'verification': [], 'blockInstall': False}
if type(plugin_id) is not int or not isinstance(digest, str) or not isinstance(version, str):
@@ -23,14 +23,6 @@ def lookup(plugin_id, digest, version, installed_shims=None):
if len(matches) != 1:
return unknown
record = matches[0]
result = {key: record[key] for key in ('status', 'reason', 'verification', 'blockInstall')}
required = record.get('requiredShims', [])
if result['status'] in ('works', 'partial') and required and installed_shims is not None and (
not isinstance(installed_shims, list) or
any(shim not in installed_shims for shim in required)):
result['status'] = 'partial'
result['reason'] = ('Update or reinstall this managed plugin to add its missing Frame compatibility fix. '
+ result['reason'])
return result
return {key: record[key] for key in ('status', 'reason', 'verification', 'blockInstall')}
except (OSError, ValueError, KeyError, TypeError):
return unknown