mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-27 05:01:44 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4967b13dca |
No files matched your search
@@ -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
|
||||
Generated
+2
-2
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -14,6 +14,12 @@ 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.', '',
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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))
|
||||
|
||||
BIN
Binary file not shown.
+2
-9
@@ -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.
|
||||
@@ -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'},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user