Compare commits

..
Author SHA1 Message Date
SAPHID de3d7d4fbc Document standing stability goal and coordinator takeover gates 2026-10-26 22:01:06 +00:00
26 changed files with 223 additions and 1817 deletions

No files matched your search

-32
View File
@@ -1,32 +0,0 @@
name: Decky backend runtime
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
decky-runtime:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Actual Decky 3.3.0 discovery and safe backend probes
run: python3 scripts/framey-test-decky-runtime.py --output "$RUNNER_TEMP/decky-runtime.json"
- uses: actions/upload-artifact@v4
if: always()
with:
name: decky-runtime-${{ matrix.os }}
path: ${{ runner.temp }}/decky-runtime.json
if-no-files-found: warn
@@ -1,807 +0,0 @@
{
"timeUtc": "2026-10-26T21:15:13.918421+00:00",
"plugins": [
{
"id": 12,
"name": "MusicControl",
"version": "1.1.6",
"sha256": "246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62",
"backend": "Python",
"frontendApi": "legacy"
},
{
"id": 111,
"name": "Music Control",
"version": "1.1.7",
"sha256": "3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9",
"backend": "Python",
"frontendApi": "legacy"
}
],
"regressions": {
"removedBoundMethodShim": {
"result": "Plugin._get_dbus_env() missing 1 required positional argument: 'self'",
"success": false
},
"realFrameSubprocessDiagnostic": {
"uid": 1000,
"ldLibrary": "<Decky PyInstaller extraction directory>",
"results": [
{
"shell": false,
"code": 0,
"hasArray": true,
"stderr": ""
},
{
"shell": true,
"code": 1,
"hasArray": false,
"stderr": "/bin/sh: /usr/lib/libc.so.6: version `GLIBC_2.42' not found (required by <Decky PyInstaller extraction directory>/libtinfo.so.6)\n"
}
]
}
},
"realFrame": {
"scope": "real Frame ARM64 Decky3.3; only discovery and selected-player read, no player controls",
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"before": {
"arch": "aarch64",
"uid": 1000,
"decky": "failed",
"panel": "inactive",
"artLinkPresent": false,
"imagesParentPresent": true,
"battery": [
100
]
},
"installs": [
{
"id": 12,
"receipt": {
"storeId": 12,
"sha256": "246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62",
"version": "1.1.6",
"compatShims": [
"music-control-system-dbus-env"
]
}
},
{
"id": 111,
"receipt": {
"storeId": 111,
"sha256": "3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9",
"version": "1.1.7",
"compatShims": [
"music-control-system-dbus-env"
]
}
}
],
"calls": [
{
"id": 12,
"method": "sp_list_media_players",
"success": true,
"playerCount": 0
},
{
"id": 12,
"method": "sp_get_media_player",
"response": {
"result": "org.mpris.MediaPlayer2.spotify",
"success": true
}
},
{
"id": 111,
"method": "sp_list_media_players",
"success": true,
"playerCount": 0
},
{
"id": 111,
"method": "sp_get_media_player",
"response": {
"result": "org.mpris.MediaPlayer2.spotify",
"success": true
}
}
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"artLinkAbsent": true
},
"after": {
"arch": "aarch64",
"uid": 1000,
"decky": "failed",
"panel": "inactive",
"artLinkPresent": false,
"imagesParentPresent": true,
"battery": [
100
]
},
"lockReleased": true
},
"isolatedRuntime": {
"version": "v3.3.0",
"architecture": "amd64",
"sha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"isolation": "Docker network none; no host audio/D-Bus/home/device mounts; dropped capabilities; private dbus-daemon and silent synthetic MPRIS service; archives and scripts read-only"
},
"backendFixtureChecks": [
{
"id": 12,
"version": "1.1.6",
"calls": [
{
"method": "sp_list_media_players",
"kwargs": {},
"response": {
"result": "org.mpris.MediaPlayer2.FrameySilent",
"success": true
}
},
{
"method": "sp_set_media_player",
"kwargs": {
"player": "org.mpris.MediaPlayer2.FrameySilent"
},
"response": {
"result": null,
"success": true
}
},
{
"method": "sp_get_media_player",
"kwargs": {},
"response": {
"result": "org.mpris.MediaPlayer2.FrameySilent",
"success": true
}
},
{
"method": "sp_identity",
"kwargs": {
"orgPath": "org.mpris.MediaPlayer2.FrameySilent"
},
"response": {
"result": "Player",
"success": true
}
},
{
"method": "get_meta_data",
"kwargs": {},
"response": {
"result": "trackid|/org/framey/TestTrack/1\nlength|180000000\ntitle|Silent Test Track\nartist|Fixture Artist\nalbum|Framey Test Album\n",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Paused",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "12000000",
"success": true
}
},
{
"method": "sp_can_seek",
"kwargs": {},
"response": {
"result": "true",
"success": true
}
},
{
"method": "sp_get_volume",
"kwargs": {},
"response": {
"result": "0.4",
"success": true
}
},
{
"method": "sp_test_volume_control",
"kwargs": {},
"response": {
"result": "true",
"success": true
}
},
{
"method": "sp_play",
"kwargs": {},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Playing",
"success": true
}
},
{
"method": "sp_pause",
"kwargs": {},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Paused",
"success": true
}
},
{
"method": "sp_next",
"kwargs": {},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_previous",
"kwargs": {},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_seek",
"kwargs": {
"amount": 2000000
},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "14000000",
"success": true
}
},
{
"method": "sp_set_position",
"kwargs": {
"position": 45000000,
"trackid": "/org/framey/TestTrack/1"
},
"response": {
"result": "",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "45000000",
"success": true
}
},
{
"method": "sp_set_volume",
"kwargs": {
"volume": 0.25
},
"response": {
"result": "method return time=1793049278.417118 sender=:1.0 -> destination=:1.19 serial=20 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_get_volume",
"kwargs": {},
"response": {
"result": "0.25",
"success": true
}
}
],
"independentFixtureState": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 1,
"previous": 1,
"writes": [
[
"PlayPause"
],
[
"Pause"
],
[
"Next"
],
[
"Previous"
],
[
"Seek",
2000000
],
[
"SetPosition",
45000000
],
[
"Volume",
0.25
]
]
}
},
{
"id": 111,
"version": "1.1.7",
"calls": [
{
"method": "sp_list_media_players",
"kwargs": {},
"response": {
"result": "org.mpris.MediaPlayer2.FrameySilent",
"success": true
}
},
{
"method": "sp_set_media_player",
"kwargs": {
"player": "org.mpris.MediaPlayer2.FrameySilent"
},
"response": {
"result": null,
"success": true
}
},
{
"method": "sp_get_media_player",
"kwargs": {},
"response": {
"result": "org.mpris.MediaPlayer2.FrameySilent",
"success": true
}
},
{
"method": "sp_identity",
"kwargs": {
"orgPath": "org.mpris.MediaPlayer2.FrameySilent"
},
"response": {
"result": "Player",
"success": true
}
},
{
"method": "get_meta_data",
"kwargs": {},
"response": {
"result": "trackid|/org/framey/TestTrack/1\nlength|180000000\ntitle|Silent Test Track\nartist|Fixture Artist\nalbum|Framey Test Album",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Paused",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "12000000",
"success": true
}
},
{
"method": "sp_can_seek",
"kwargs": {},
"response": {
"result": "true",
"success": true
}
},
{
"method": "sp_get_volume",
"kwargs": {},
"response": {
"result": "0.4",
"success": true
}
},
{
"method": "sp_test_volume_control",
"kwargs": {},
"response": {
"result": "true",
"success": true
}
},
{
"method": "sp_play",
"kwargs": {},
"response": {
"result": "method return time=1793049473.137015 sender=:1.0 -> destination=:1.12 serial=13 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Playing",
"success": true
}
},
{
"method": "sp_pause",
"kwargs": {},
"response": {
"result": "method return time=1793049473.340963 sender=:1.0 -> destination=:1.14 serial=15 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_track_status",
"kwargs": {},
"response": {
"result": "Paused",
"success": true
}
},
{
"method": "sp_next",
"kwargs": {},
"response": {
"result": "method return time=1793049473.547273 sender=:1.0 -> destination=:1.16 serial=17 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_previous",
"kwargs": {},
"response": {
"result": "method return time=1793049473.649558 sender=:1.0 -> destination=:1.17 serial=18 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_seek",
"kwargs": {
"amount": 2000000
},
"response": {
"result": "method return time=1793049473.752790 sender=:1.0 -> destination=:1.18 serial=19 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "14000000",
"success": true
}
},
{
"method": "sp_set_position",
"kwargs": {
"position": 45000000,
"trackid": "/org/framey/TestTrack/1"
},
"response": {
"result": "method return time=1793049473.965468 sender=:1.0 -> destination=:1.20 serial=21 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_track_progress",
"kwargs": {},
"response": {
"result": "45000000",
"success": true
}
},
{
"method": "sp_set_volume",
"kwargs": {
"volume": 0.25
},
"response": {
"result": "method return time=1793049474.175137 sender=:1.0 -> destination=:1.22 serial=23 reply_serial=2\n",
"success": true
}
},
{
"method": "sp_get_volume",
"kwargs": {},
"response": {
"result": "0.25",
"success": true
}
}
],
"independentFixtureState": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 1,
"previous": 1,
"writes": [
[
"Volume",
0.01
],
[
"Volume",
0.4
],
[
"PlayPause"
],
[
"Pause"
],
[
"Next"
],
[
"Previous"
],
[
"Seek",
2000000
],
[
"SetPosition",
45000000
],
[
"Volume",
0.25
]
]
}
}
],
"browserChecks": [
{
"id": 12,
"scope": "opaque local Chromium + real amd64 Decky3.3 + private silent MPRIS",
"checks": [
{
"rendered": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayback Volume\nPlayer\nInfo"
},
{
"clickedButton": 0,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 1,
"previous": 2
}
},
{
"clickedButton": 2,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"clickedButton": 1,
"independentReadback": {
"PlaybackStatus": "Playing",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"clickedButton": 1,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"slider": 0,
"value": 0.5,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"slider": 1,
"value": 60,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.6,
"next": 2,
"previous": 2
}
},
{
"infoRoute": "Music Control\nBack to plugin\nMusicControl\nInfoUsageTroubleshooting\nMusicControl allows you to control any media playback that is currently running that implements the MPRIS dbus interface.\nExamples of application that are supported:\nSpotify\nCider\nChrome / Firefox\nStrawberry\nOnly the flatpak versions of these applications have been tested, and not all of them might support the full feature set, so it is recommended to use the discover store to install the applications."
},
{
"reopened": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayer\nInfo",
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.6,
"next": 2,
"previous": 2
}
}
]
},
{
"id": 111,
"scope": "opaque local Chromium + real amd64 Decky3.3 + private silent MPRIS",
"checks": [
{
"rendered": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayback Volume\nPlayer\nInfo"
},
{
"clickedButton": 0,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 1,
"previous": 2
}
},
{
"clickedButton": 2,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"clickedButton": 1,
"independentReadback": {
"PlaybackStatus": "Playing",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"clickedButton": 1,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 45000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"slider": 0,
"value": 0.5,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.25,
"next": 2,
"previous": 2
}
},
{
"slider": 1,
"value": 60,
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.6,
"next": 2,
"previous": 2
}
},
{
"infoRoute": "Music Control\nBack to plugin\nMusicControl\nInfoUsageTroubleshooting\nMusicControl allows you to control any media playback that is currently running that implements the MPRIS dbus interface.\nExamples of application that are supported:\nSpotify\nCider\nChrome / Firefox\nStrawberry\nOnly the flatpak versions of these applications have been tested, and not all of them might support the full feature set, so it is recommended to use the discover store to install the applications."
},
{
"reopened": "Music Control\nCurrently Playing\nSilent Test Track\nFixture Artist\nPlayer\nInfo",
"independentReadback": {
"PlaybackStatus": "Paused",
"Position": 90000000,
"Volume": 0.6,
"next": 2,
"previous": 2
}
}
]
}
],
"limitations": [
"Real Frame had no running MPRIS players. No real media transport or volume writes ran.",
"The private fixture emits no audio and does not prove playback in a supported media app.",
"Album art, physical controller, VR keyboard and worn VR were not tested.",
"Both upstream backends truncate multiword Identity to its final word.",
"Music Control 1.1.7 automatically probes player volume by writing 0.01 and restoring it; this ran only against the private fixture.",
"Existing installed copies require remove/reinstall or an explicit managed update."
],
"validation": {
"pythonSuite": {
"run": 1639,
"skipped": 16,
"passed": true
},
"fakeFrame": {
"total": 116,
"results": {
"blocked": 55,
"installed-removed": 61
},
"backendExecution": false
},
"currentStore": {
"checkedAtUtc": "2026-10-26T21:17:39.609721+00:00",
"plugins": 116,
"allPinnedDigestsUnchanged": true
},
"independentCleanupAudit": "MPRIS_CLEANUP_AUDIT_PASS: shared lock, test services/runtime directories and owned cache link absent",
"pinnedHttpSshFlow": {
"command": "scripts/e2e.sh test_framey",
"passed": 2,
"seconds": 32.59,
"scope": "pinned official archive receipt assertions and sample install/update/remove safeguards"
}
}
}
@@ -1,76 +0,0 @@
# MusicControl / Music Control runtime checks, 26 October 2026
Both official entries remain **partial**. Their pure-Python backends need a
user-session MPRIS player and `dbus-send`, rather than Deck-specific hardware or
an x86 binary. [Results](framey-mpris-runtime-2026-10-26.json) pin both archives
and the actual Decky 3.3.0 executables used.
Two runtime failures were reproduced and repaired:
- The #335 `music-control-bound-methods` transformation was incorrect. Neither
manifest declares `api_version`, so Decky uses `module.Plugin` and explicitly
supplies the class as `self`. The earlier test used `module.Plugin()` and
therefore diagnosed a fixture error as an upstream bug. In actual Decky,
the transformation breaks discovery with `_get_dbus_env() missing ... self`
and transport with `_sp_player_dbus() missing ... parameters`. Managed installs
now retain the original class calls. This supersedes the earlier
[bound-method evidence](framey-music-control-bound-methods-2026-10-26.md).
- On Frame OS 0.5.6, MusicControl 1.1.6's `/bin/sh` subprocess inherits Decky's
PyInstaller library path. Its bundled `libtinfo.so.6` needs `GLIBC_2.42`, which
the Frame's libc lacks. A private diagnostic backend confirmed the same
read-only D-Bus command fails with a shell and succeeds without one. Both
exact-archive backends now restore `LD_LIBRARY_PATH_ORIG` (or an empty path)
only in their copied D-Bus subprocess environment. The Decky process keeps
its original environment. The receipt records `music-control-system-dbus-env`.
The patch is gated by store ID, SHA-256 and folder, and checks the expected
source before editing. Existing installed copies need **remove/reinstall** or
an explicit managed update; a desktop update alone does not rewrite them.
The store still shows partial, with the same reason as the docs table.
Actual Decky 3.3.0 amd64 ran each repaired production-managed install separately
in a container with no network, host audio, host D-Bus, home or device access.
A private `dbus-daemon` hosted a silent synthetic MPRIS service. Discovery,
selection, identity, metadata, status, progress, volume/CanSeek reads,
play/pause, next/previous, seek, track position and volume writes passed through
the real legacy websocket RPC envelope. The service's own state independently
confirmed each write. The shared plugin folder prevents co-installation; each
entry was tested in a fresh container.
Local headless Chromium loaded the shipped `headset.html` and exact official
frontends inside their ordinary opaque `allow-scripts` iframe. The relay used
actual Decky backends and allowed only the private fixture. Track/artist
rendering, previous/next, play/pause, seek to 90 seconds, volume to 60%, Info
navigation and close/reopen passed for both entries. Chromium retained its
normal process isolation and sandboxed-origin restrictions. No external media
or artwork request was permitted.
Under the shared `/tmp/frame-test.lock`, a removable private Decky 3.3.0 ARM64
user service loaded each corrected managed install on the real Frame.
Read-only `sp_list_media_players` returned an empty string successfully for
both, and `sp_get_media_player` returned their default Spotify bus name.
These final checks used Decky's direct loopback websocket through SSH; they
do not claim the deployed Framey HTTP/CEF route. Earlier CEF attempts encountered
transient reinjection failures. No player controls ran on the real Frame.
The private units, runtimes and owned Steam image-cache link were removed;
independent follow-up reads confirmed cleanup and lock release. Battery was
100 → 100. Existing managed Decky remained failed and the headset panel inactive,
as found. Power settings were not changed.
Remaining gaps: actual media-app playback/volume on ARM64, local-file album-art
routing, physical controls and worn VR. Both backends truncate multiword player
names to their last word. Music Control 1.1.7 automatically probes volume by
writing 0.01 and restoring it; this was exercised only on the silent fixture.
Validation: full Python suite **1,639 tests, 16 skipped**; focused managed-store
suite **23/23**; shared frontend action checks; all **116** store digests unchanged.
The complete fake-Frame audit passed: **61 installed/removed, 55 rejected**
for documented safeguards; official backends are stubbed in that harness. The
regression test uses Decky's actual legacy class convention for both entries,
checks original/absent library-path restoration without changing the parent
environment, and rejects transferring the patch to a different archive digest.
The pinned HTTP/SSH flow also passed `scripts/e2e.sh test_framey`: **2/2**
tests in 32.590 seconds. Its receipt assertion covers the replacement shim for
both Music Control entries; the old assertion caused the first hosted e2e
failure and left a plugin installed, cascading into the following test.
@@ -1,15 +1,34 @@
# Superseded: Music Control bound-method fixture
# Music Control 1.1.7 bound-method compatibility, 26 October 2026
The #335 result was incorrect. Its test instantiated `module.Plugin()`, while
actual Decky 3.3.0 uses the class `module.Plugin` for this legacy manifest and
supplies the class as `self`. The published backend's explicit-self calls are
correct in that runtime. Removing those arguments breaks actual Decky discovery
and transport.
The official Decky store archive for ID 111, version 1.1.7, is
`3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9`
(SHA-256). The pinned `tests/fixtures/framey-official/111.zip` matches that
digest and includes the upstream license.
The follow-up [real runtime checks](framey-mpris-runtime-2026-10-26.md) reproduce
the regression, remove the incorrect transformation and replace the test with
the actual legacy calling convention. They also identify and repair a separate
Frame OS system-command library-path failure. Existing copies installed with
`music-control-bound-methods` must be removed/reinstalled or explicitly updated.
Both official Music Control entries remain partial; the follow-up describes
exactly what passed and what remains unverified.
The unchanged backend fails before it reaches D-Bus: `sp_list_media_players`
raises `TypeError: Plugin._get_dbus_env() takes 1 positional argument but 2
were given`, and `sp_play` raises the corresponding `_sp_player_dbus` argument
error. The source passes `self` explicitly to methods already bound by Python.
For this exact store ID, archive digest and folder, a managed install removes
the redundant argument at the five affected call sites. It checks the number
of occurrences before writing `main.py` and records
`music-control-bound-methods` in the managed receipt. The original ZIP remains
the verified download; the older ID 12 archive is installed without this
change.
`test_exact_music_control_backend_calls_work_after_managed_install` installs
the official ZIP through the production transaction script into a disposable
home. It imports the patched backend and exercises player enumeration,
selection, play/pause, next/previous, volume and playback-status calls against
a private fake `dbus-send` executable that records the actual command
arguments. It then removes the plugin through the receipt-bound transaction.
The same test confirms ID 12 retains its original backend source. The focused
store suite passed **23/23** tests locally. The pinned frontend/fake-Frame
install/remove tests are part of the hosted gate.
This proves that the Python argument mismatch no longer prevents backend
command dispatch in the isolated fixture. The fixture is not an MPRIS player.
Actual Decky 3.3.0 ARM64 execution, live player discovery, media transport,
volume effects, album-art routing, and wearer-visible VR controls have not
been verified. Music Control remains **partial**.
-85
View File
@@ -1,85 +0,0 @@
# v0.4.48 release verification and withdrawal, 26 October 2026
The annotated [`v0.4.48` tag](https://github.com/saphid/frame-control/releases/tag/v0.4.48)
dereferences to main commit `5438e1d9d05ca472f200dcde50799c8ae882fafb`,
which contains the version bump and [candidate evidence](v0448-candidate-2026-10-26.md).
The exact-commit [main checks run](https://github.com/saphid/frame-control/actions/runs/40356645911)
finished successfully: all seven jobs passed, including e2e, iOS, Windows,
macOS and Linux tests. Its first two attempts had an iOS job stuck in the
runner queue and were cancelled; attempt 3 ran that job and ended with all
seven required checks successful. The later documentation-only main commit
`1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6` also passed all seven
[main checks](https://github.com/saphid/frame-control/actions/runs/40406851705).
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40406781383)
finished successfully. Its draft, Linux, Windows, Apple Silicon Mac, Intel
Mac and checksum jobs all passed; no tagged installer build failed or was
cancelled. Tagged CI launched the Linux x86_64 AppImage on Xvfb, checked both
Mac bundles against their runner architecture, installed the Windows Setup
and compared its installed resources with the unpacked build, and tested its
packaged Python and native PC libraries.
An earlier [release-PR preview run](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/1)
was not a passing gate: its Apple Silicon Mac build was cancelled and its
checksum job was skipped. [Attempt 2](https://github.com/saphid/frame-control/actions/runs/40352797572/attempts/2)
completed all four preview installer builds successfully. Publication used the
separate exact-tag workflow above, whose six jobs all succeeded.
The draft had exactly ten installers and `SHA256SUMS`, each with a GitHub
SHA-256 digest. An independently downloaded `SHA256SUMS` matched a fresh
ten-line list generated from GitHub's asset metadata byte for byte. Both
its downloaded SHA-256 and GitHub's own asset digest were
`8cf7d8808f0b0b9da08e2590461e83ef557412a0ce2cc780c8e1d02e34ce1a60`.
Independent downloads on lxso1 matched `SHA256SUMS` for these packages:
| Package | SHA-256 and independent check |
| --- | --- |
| Linux x86_64 AppImage | `7c0cbef7c0330c48ecb69f6fcee027a8b45fe84b0458985db31d16b09fc45cbe`; isolated Xvfb launch served HTTP 200 with `FrameControl/1`, then its server stopped. |
| Linux arm64 DEB | `d3c2cdc4310fc3fed11460ad970a3ff6da8b80a6ec566acf1bcaa5a9570efc3e`; package declares version 0.4.48 and arm64 architecture. |
| Mac arm64 ZIP | `3c5b0ad0877e4c1e1adbac17a9eb4f1114294a25576330050ac9ce338e0b1c00`; ZIP CRC, bundle version 0.4.48 and main Mach-O arm64 CPU passed. |
| Mac x64 ZIP | `3bb90246794f0082fd4f34d86113e292ae543671ff8bea4096d888a9273a529c`; ZIP CRC, bundle version 0.4.48 and main Mach-O x86_64 CPU passed. |
| Windows x64 Setup | `def654c238575778120c6f2a4df1af319b0cd169f64c2fceebe26ddc5e3c68ff`; downloaded file is a PE/Nullsoft installer; tagged Windows CI installed and tested its contents. |
After attaching the final release notes,
`scripts/publish-release.sh --dry-run v0.4.48` passed the exact tag, ten
installer digests, checksum asset and update-manifest checks.
`scripts/publish-release.sh v0.4.48` published the release at
**21:53:29 UTC**. GitHub's latest-release API read back tag `v0.4.48`,
`draft=false`, `prerelease=false`, ten installers, `SHA256SUMS` and
`update.json`. Immediately after publication, a fresh public latest-download
of `update.json` reported version 0.4.48, the release page, ten hashed
installers and the worn VR limit in its notes. This is historical evidence,
not the current update-channel state.
## Withdrawn after a real Decky regression was confirmed
The all-green release gate did not include Music Control's actual Decky 3.3.0
legacy class calling convention. [PR #338](https://github.com/saphid/frame-control/pull/338)
reproduced a regression in the #335 digest-scoped Music Control 1.1.7 install
shim: it removes explicit `self` from calls that the real legacy runtime needs,
so player discovery fails with `_get_dbus_env() missing 1 required positional
argument: 'self'`. The earlier fixture had instantiated `Plugin()` and missed
this behavior. A passing CI run therefore did not prove this plugin path worked.
At **22:01:20 UTC**, v0.4.48 was returned to draft and the previously green
v0.4.47 was marked latest. The v0.4.48 tag and build artifacts were preserved;
no tag or installer was deleted. The preserved draft's notes now begin with a
withdrawal notice. GitHub's latest-release API and a fresh public
`releases/latest/download/update.json` now both point to **v0.4.47** with ten
hashed installers. Existing v0.4.48 installations do not auto-downgrade.
The repair in #338 merged at `04cde721c862c80a18dad17c2aab0c86ca8d3082`.
It requires green post-merge main checks, a new release candidate and all
release gates before any replacement version is offered. An app update alone
does not rewrite the bad installed plugin shim: affected users need an explicit
managed plugin update or remove/reinstall after the repair ships.
The [candidate smoke](v0448-candidate-2026-10-26.md) on the real Frame passed
**12 / one optional x86_64 launch skipped / zero failed**. The optional launch
needed Steam Linux Runtime 4, which was absent; no x86_64 headset launch was
claimed. Owned test titles and processes were removed, the lock was released,
and a fresh locked check found standby and no test-title leftovers. A later
read-only locked check found SteamVR activity 3 (standby), proximity raw
3.0625 and battery 100%, then released its own lock. No wearer was confirmed,
so neither the worn-check script nor physical controller input was run.
Official-plugin wearer visibility, controller use, VR keyboard, optical comfort
and physical launch of these exact Mac/Windows installers remain unverified.
+5 -5
View File
@@ -29,7 +29,7 @@ A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional Line truncated
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. 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
@@ -41,7 +41,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 7: CSS Loader 2.1.3 | legacy / legacy | partial | Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. | static archive, local browser, fake-Frame |
| 10: vibrantDeck 2.0.1 | legacy / legacy | N/A | Not supported on Frame: Changes X11 gamescope color properties, not the Frame SteamVR compositor. | static archive, local browser, fake-Frame |
| 11: Fantastic 0.5.1 | legacy / legacy | N/A | Not supported on Frame: Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. | static archive, local browser, fake-Frame |
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply. | static archive, local browser, fake-Frame, isolated Decky 3.3.0 + MPRIS fixture, real Frame SSH |
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Legacy media controls initialize; player discovery and playback are unverified. | static archive, local browser, fake-Frame |
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied. | static archive, local browser, fake-Frame |
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel. | static archive, local browser, fake-Frame |
@@ -119,7 +119,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 108: HueSync 3.8.0 | 1 / 2 | N/A | Not supported on Frame: Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. | static archive, local browser, fake-Frame |
| 109: MuraDeck 2.1.4 | 1 / 2 | N/A | Not supported on Frame: Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. | static archive, local browser, fake-Frame |
| 110: Dimmer Deck 0.0.4 | 1 / 2 | N/A | Not supported on Frame: Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. | static archive, local browser, fake-Frame |
| 111: Music Control 1.1.7 | legacy / legacy | partial | Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply. | static archive, local browser, fake-Frame, isolated Python backend, isolated Decky 3.3.0 + MPRIS fixture, real Frame SSH |
| 111: Music Control 1.1.7 | legacy / legacy | partial | Exact official backend bound-method calls are repaired in managed installs. An isolated fake dbus-send target accepted player discovery, transport and volume commands after the patch; real MPRIS players, actual Decky ARM64, album art and worn VR remain unverified. | static archive, local browser, fake-Frame, isolated Python backend |
| 112: Better Keyboard 0.3.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: window.SteamClient.UI.GetUIMode is not a function. | static archive, local browser, fake-Frame |
| 113: Decky LSFG-VK 0.12.2 | 1 / 2 | partial | Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. | static archive, local browser, fake-Frame |
| 114: Network Sentinel 1.0.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
@@ -164,7 +164,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
| CSS Loader | none detected | ButtonItem, ColorPickerModal, ConfirmModal, DialogButton, DialogCheckbox, Dropdown, DropdownItem, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SidebarNavigation, SliderField, Tabs, TextField, Toggle, ToggleField, classMap, definePlugin, findModuleChild, gamepadDialogClasses, gamepadSliderClasses, getGamepadNavigationTrees, replacePatch, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| vibrantDeck | none detected | PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, window.SP_REACT | Python | Changes X11 gamescope color properties, not the Frame SteamVR compositor. |
| Fantastic | none detected | Field, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT, QAM/global tabs | Python + native payload; x86_64 | Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. |
| MusicControl | none detected | ButtonItem, DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | User-session MPRIS player and dbus-send required; no Deck-specific hardware. |
| MusicControl | none detected | ButtonItem, DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Pause Games | Apps, Apps.RegisterForGameActionTaskChange, GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, System.UI.RegisterForFocusChangeEvents, System.UI.RegisterForSystemKeyEvents | Button, Field, Focusable, Marquee, PanelSection, PanelSectionRow, Router, Toggle, ToggleField, beforePatch, sleep | Router, window.SP_REACT, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| ProtonDB Badges | none detected | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, Navigation, PanelSection, PanelSectionRow, ScrollPanel, ScrollPanelGroup, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleExport, showModal, staticClasses, useParams | SteamUIStore, appStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Audio Loader | GameSessions.RegisterForAppLifetimeNotifications, d.ts | ButtonItem, DialogButton, Dropdown, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, afterPatch, beforePatch, definePlugin, findModuleChild, gamepadDialogClasses, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
@@ -242,7 +242,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
| HueSync | Settings.GetCurrentLanguage, System.GetSystemInfo, System.RegisterForBatteryStateChanges, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | DialogButton, DropdownItem, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, definePlugin, findModuleChild, gamepadSliderClasses, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. |
| MuraDeck | System.Display.RegisterForBrightnessChanges | ButtonItem, ConfirmModal, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SideMenu, SidebarNavigation, SliderField, ToggleField, quickAccessControlsClasses, showModal | SteamUIStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python | Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. |
| Dimmer Deck | Settings.GetCurrentLanguage, System.Display.RegisterForBrightnessChanges, System.Display.SetBrightness | PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts, Steam stores | Python | Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. |
| Music Control | none detected | ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | User-session MPRIS player and dbus-send required; no Deck-specific hardware. |
| Music Control | none detected | ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
| Better Keyboard | Settings.GetCurrentLanguage, UI.GetUIMode, UI.RegisterForUIModeChanged | ButtonItem, EUIMode, PanelSection, PanelSectionRow, ToggleField, staticClasses | SteamUIStore, window.SP_REACT, Steam stores | Python | /dev/input |
| Decky LSFG-VK | none detected | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, Spinner, TextField, Toggle, ToggleField, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | flatpak; x86_64 |
| Network Sentinel | none detected | ButtonItem, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
+124
View File
@@ -0,0 +1,124 @@
# Frame Control standing goal and takeover handoff
Owner: Alex. This records the 27 October 2026 AEDT recovery handoff; status
checks below use UTC. It does not grant authority to publish a release or to
start a second coordinator.
## Alex's top priority
> Make Frame Control's released version always stable and running cleanly. Never release something that isn't working correctly. I don't want to break things for people.
Keep the released app stable and main green. Investigate failures before
shipping; a version number or an uploaded artifact is not proof of readiness.
## Standing roadmap
Roadmap round 2: test gaps, fixes and next features. The existing owner is T3
job `mppkd9qczt` on lxso2. Preserve that job and its work. The recovery task did
not send it messages, change its thread, or start replacement coding/release
work. A human must settle ownership before a takeover starts.
## Mandatory release gates
- CI must be green on the **exact release commit**, not merely an earlier
commit, another branch, or a previous successful workflow attempt.
- End-to-end tests and release verification must pass for that candidate.
- Every required installer build must succeed, including Linux, Windows,
macOS Intel and macOS Apple Silicon.
- Never publish a release with failed or cancelled required builds. A retry
must actually finish successfully; a cancelled build is not a pass.
- Keep main green. Resolve required failures before merging or releasing.
- Preserve evidence linking the release tag/commit, successful workflow run,
installer assets/checksums, e2e results and real-device verification.
- Use `scripts/publish-release.sh --dry-run TAG` for inspection. A real publish
or tag needs its own authorized release task; this handoff does not authorize it.
## Incident snapshot supplied by Alex
At the time of the recovery request, latest published was **v0.4.47** with a
passed release run; **v0.4.48 RC was not published**, after a failed run with a
cancelled macOS build. PRs **#337 and #338 were open**. Preserve this as the
historical incident snapshot, not a claim that remote state stays unchanged.
## Verified update during recovery
Checked 26 October 2026 around 21:58 UTC:
- v0.4.47 was published at 15:07:28 UTC.
- A subsequent v0.4.48 release run
[40406781383](https://github.com/saphid/frame-control/actions/runs/40406781383)
completed successfully on commit
`5438e1d9d05ca472f200dcde50799c8ae882fafb`. Its Linux, Windows, Intel macOS
and Apple Silicon macOS installer jobs, draft and checksum jobs all succeeded.
- GitHub now reports **v0.4.48 published at 21:53:29 UTC**. This publication
happened outside the recovery task. Recheck the exact-commit checks and
release evidence before drawing conclusions about all release gates.
- [#337](https://github.com/saphid/frame-control/pull/337) is merged; lxso1's
clean main was fast-forwarded to
`1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6`.
- [#338](https://github.com/saphid/frame-control/pull/338) remains open.
- lxso2 T3 recovered at **21:42:54 UTC**; port 3773/local HTTP and existing
HTTPS returned 200 by 21:43:58 UTC. Root disk capacity remains critical;
recovery is not a capacity fix.
- Fleet guards, probe units, safety tests and incident evidence are proposed
in [fleet #38](https://github.com/saphid/fleet/pull/38); units were installed
under the owner's incident authorization. Disk critical alerts use the
existing fleet Prometheus/watchdog pipeline.
## lxso1 readiness
Main checkout: `/home/saphid/projects/steam-frame` (GitHub repository
`saphid/frame-control`). Shell default Node may be older; use
`~/.nvm/versions/node/v24.22.1/bin` first in PATH for Node 24 CI commands.
Python 3.9.25 and 3.13.14 were installed through uv for the CI matrix; system
Python 3.12.3 already existed. Current workflows require no Rust/Go toolchain.
Readiness commands, per-gate logs and exact-commit JSON results live at
`/tmp/frame-control-readiness-20261027/` on lxso1. These are local readiness
checks, not a substitute for GitHub CI on a future release commit. The Linux
installer command uses `--publish never`; no release was published by these
checks. Native ARM64, Windows/macOS platform gates, Xcode/iOS and real-headset
checks still require their proper runners/hardware.
Completed on main commit `1ddb6278b8ae0a98ecc79a181bba4d3cc67a52b6`,
26 October 2026 at 22:00 UTC:
| Gate | Result on lxso1 |
| --- | --- |
| Shell syntax, Python compile | Passed |
| Python 3.9 / 3.12 / 3.13 server suites | 1,639 tests each; passed with platform/optional skips |
| Linux wireless doctor, app syntax/tests, website tests/syntax | Passed |
| `npm ci`, `npm run dist:linux` | Passed; AppImage and deb built without publishing |
| AppImage launch verification | Passed |
| `scripts/e2e.sh` on x86_64 Docker | Passed: 50 tests, one optional catalog audit skipped; not native ARM64 hardware proof |
| Publish dry runs for v0.4.47 and v0.4.48 | Passed, including all ten installer digests and SHA256SUMS; no mutation |
| Native PC host build and required-native tests | Blocked/failed: GStreamer/GLib development packages absent; required native adapter unavailable |
The workflows name system GStreamer packages but do not pin package versions.
They were left uninstalled under the owner's pinned-versions-only constraint.
Resolve that dependency policy before claiming full Linux native readiness.
Main was clean and matched origin/main on the final 21:59 UTC fetch.
## How a takeover should resume
1. Read this file, `docs/testing.md`, the current roadmap/evidence and the
existing coordinator's recorded work through supported read-only tooling.
Verify remote releases, PR states and workflow conclusions again.
2. Check lxso2 health and disk headroom. Disk-full recovery requires capacity
decisions; do not delete protected data or alter T3 state databases.
3. Have Alex settle coordinator ownership. Preserve uncommitted changes and
active worktrees; do not run competing coordinator/release jobs.
4. Fetch origin and inspect local status before updating main. Resume the
roadmap from recorded gaps and existing PRs, not from assumptions about
what an interrupted worker completed.
5. Work on an isolated branch and use PRs. Validate the actual candidate
commit with every release gate above; record hardware/platform blockers.
6. Publish only under an explicit release task after all required gates pass.
The lxso1 five-minute `fleet-t3-lxso2-health.timer` only checks and alerts; it
does not start takeover threads. Automatic stand-in launch is not configured
or proven by this recovery deployment. It would need supported gateway
capability and authorized credentials; do not copy or mint gateway secrets.
An identical local handoff copy is maintained at
`/home/saphid/frame-control-handoff/STANDING_GOAL.md` on lxso1.
+1 -15
View File
@@ -34,27 +34,13 @@ count. So a build reaches people only when you publish it, after testing it.
4. Publish:
Confirm the exact tag commit's main CI and e2e, the locked real Frame smoke,
release verification, independent package checks, and all four tagged
installer builds have passed. Hold the release if any gate is failed,
cancelled, skipped, still running, or unverified. The publisher also reads
the exact commit's GitHub CI, native Decky runtime gate, and tagged build
job records and refuses to
publish unless every required job succeeded; it cannot prove physical
headset or installer behavior on its own.
```sh
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
scripts/publish-release.sh v0.4.0
```
The script checks that the tag is on GitHub, that exact-commit main CI
(including e2e), native amd64/ARM64 Decky runtime checks, and the tagged
release workflow are green with no failed,
cancelled, skipped or running jobs, and that all ten installers
The script checks that the tag is on GitHub and that all ten installers
are attached, each with the SHA-256 digest GitHub records. It also checks
that the draft does not begin with a `WITHDRAWN` notice, so the preserved
v0.4.48 draft cannot accidentally be offered again. It checks
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
installer digests. It attaches
`update.json` (the version, the notes, the release page and each
-38
View File
@@ -549,41 +549,3 @@ Fixtures cover duplicate instance identity, unknown states, GUID matching,
localised/denied channel data and 6 GHz ambiguity. No tests contact a real Frame.
A Windows CI runner/VM cannot prove physical Steam dongle behaviour or audio RF
interference. See [sources and limits](windows-wireless-doctor.md).
### Actual Decky backend shim gate
Fake-Frame tests stub plugin execution, so they cannot prove Decky's Python
calling convention. `Decky backend runtime` CI separately runs the published,
SHA-256-pinned **Decky 3.3.0** executable on native amd64 and ARM64 Linux.
The loader discovers production-managed official archives and calls their public
methods over its actual websocket API. No test imports or instantiates `Plugin`.
```sh
python3 scripts/framey-test-decky-runtime.py --output /tmp/decky-runtime.json
# Offline loader cache (still checked against its pinned native digest):
python3 scripts/framey-test-decky-runtime.py --loader /path/to/PluginLoader --output /tmp/decky-runtime.json
```
Run on Linux with Docker. Image build and the pinned loader download need network;
plugin execution has no network, published ports, host home/audio/D-Bus/device
mounts, capabilities or writable repository. Each case has its own unprivileged
container, private tmpfs home and temporary files. Nothing contacts the headset
or its shared lock. Failed/timed-out containers and the private image tag are
removed by the runner.
Every `compat_shims.append` in the managed installer must have a case in
`tests/decky-runtime/cases.json`. Add each affected pinned official archive and
a safe public-method probe when introducing a shim. Ordinary unit tests reject
uncovered shim names. The runtime job verifies the managed receipt, loader
plugin discovery, and a successful backend call; a discovery listing alone
could conceal a crashed backend process.
Current cases cover both Music Control entries and Shotty. Music Control uses
the private silent MPRIS fixture to check discovery, metadata, play/pause,
next/previous, seek and volume, with independent service-state readbacks.
Shotty checks backend import, rescuer state and empty screenshot aggregation
inside its disposable home. A fourth negative-control case deliberately
reintroduces #335's incorrect bound-method transformation and must reproduce
the real loader's legacy backend-call failure. The gate fails if that regression goes
undetected. These checks verify backend behavior in a container; they do not
upgrade any plugin's Frame, actual media playback or worn-VR compatibility claim.
+3 -5
View File
@@ -81,11 +81,9 @@ def render(data):
'[Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), '
'[Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and '
'[legacy MusicControl](evidence/framey-legacy-2026-10-25.md). '
'A [shared Music Control runtime follow-up](evidence/framey-mpris-runtime-2026-10-26.md) corrects the earlier bound-method fixture error: actual Decky uses the legacy class calling convention, so explicit-self calls must stay. '
'Exact managed installs also restore the system library path for D-Bus subprocesses, repairing a real Frame OS libtinfo/glibc failure in MusicControl 1.1.6. '
'Both official entries pass transport/seek/volume, Info and reopen in local Chromium with actual Decky 3.3.0 amd64 and a private silent MPRIS service. '
'Actual ARM64 Decky on the Frame passes read-only empty-player discovery for both. Existing copies need remove/reinstall or an explicit managed update. '
'Actual media playback on ARM64, artwork and worn VR remain unverified; both stay partial.', '',
'A [Music Control 1.1.7 backend follow-up](evidence/framey-music-control-bound-methods-2026-10-26.md) '
'repairs exact-archive bound-method calls and verifies transport/volume command dispatch against an isolated dbus-send fixture. '
'A real MPRIS player and actual ARM64 Decky backend remain unverified.', '',
'## 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.', '',
-114
View File
@@ -1,114 +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'], False) for case in cases] + [(111, True)]
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('--reproduce-bound-method-regression')
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
if result.returncode:
raise RuntimeError(f'Decky runtime case {plugin_id} failed:\n{result.stdout}\n{result.stderr}')
observation = json.loads(result.stdout)
report['cases'].append(observation)
print(f"PASS {plugin_id}: {observation['result']}", flush=True)
finally:
subprocess.run(['docker', 'rm', '-f', name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
finally:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2)+'\n')
subprocess.run(['docker', 'image', 'rm', tag], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if __name__ == '__main__':
main()
-11
View File
@@ -31,11 +31,6 @@ trap 'rm -rf "$tmp"' EXIT
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
# A complete asset list is insufficient: a cancelled installer build can leave
# old uploaded bytes behind. Check the exact tagged commit's main/e2e jobs and
# the latest exact-tag release attempt before any release mutation.
python3 "$(dirname "$0")/verify-release-gates.py" "$tag"
# Every release, drafts included, one JSON object per line.
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
# The release whose tag_name is the tag; failing that, the one untagged-... draft
@@ -61,12 +56,6 @@ json.dump(r, sys.stdout)
EOF
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
python3 - "$tmp/release.json" <<'EOF'
import json, sys
release = json.load(open(sys.argv[1]))
if (release.get("body") or "").lstrip().startswith("> **WITHDRAWN"):
sys.exit("not publishing: this release is marked WITHDRAWN")
EOF
missing=""
for name in $expected; do
-109
View File
@@ -1,109 +0,0 @@
#!/usr/bin/env python3
"""Refuse publication unless the exact tag commit and tagged installers passed CI.
This checks GitHub's current run and job records, not a prior PR's check marks.
The real Frame smoke and independent package checks remain release-procedure
gates documented in docs/releasing.md.
"""
import json
import subprocess
import sys
from urllib.parse import quote
REPO = "saphid/frame-control"
MAIN_JOBS = {
"checks", "e2e", "ios", "wireless-windows",
"server-tests (ubuntu-latest, 3.13)",
"server-tests (macos-latest, 3.12)",
"server-tests (windows-latest, 3.12)",
}
RELEASE_JOBS = {
"draft", "checksums",
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
}
DECKY_JOBS = {"decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)"}
class GateError(Exception):
pass
def api(path):
# publish-release.sh already requires sh. Resolve gh through that same
# shell: on Git Bash, Python's Windows CreateProcess skips an extensionless
# test gh shim in PATH and accidentally launches the real gh.exe instead.
command = ["sh", "-c", 'gh api "$1"', "sh", f"repos/{REPO}/{path}"]
result = subprocess.run(command, text=True, capture_output=True, check=False)
if result.returncode:
raise GateError(f"GitHub gate read failed for {path}: {result.stderr.strip()}")
try:
return json.loads(result.stdout)
except json.JSONDecodeError as error:
raise GateError(f"invalid GitHub gate response for {path}: {error}") from error
def tag_commit(tag):
target = api(f"git/ref/tags/{quote(tag, safe='')}").get("object") or {}
for _ in range(5):
kind, sha = target.get("type"), target.get("sha")
if not isinstance(sha, str) or len(sha) != 40:
raise GateError(f"{tag} has no valid tag target")
if kind == "commit":
return sha
if kind != "tag":
raise GateError(f"{tag} points to unsupported Git object {kind!r}")
target = api(f"git/tags/{sha}").get("object") or {}
raise GateError(f"{tag} has too many nested tag objects")
def exact_run(workflow, branch, sha, event):
query = f"actions/workflows/{workflow}/runs?branch={quote(branch, safe='')}&head_sha={sha}&per_page=100"
runs = api(query).get("workflow_runs") or []
matching = [run for run in runs if run.get("head_sha") == sha
and run.get("head_branch") == branch and run.get("event") == event]
if not matching:
raise GateError(f"no exact {workflow} {event} run for {branch} at {sha}")
run = max(matching, key=lambda item: item["id"])
if run.get("status") != "completed" or run.get("conclusion") != "success":
raise GateError(f"{workflow} run {run['id']} is {run.get('status')}/{run.get('conclusion')}")
return run
def successful_jobs(run, required):
attempt = run.get("run_attempt")
if not isinstance(attempt, int) or attempt < 1:
raise GateError(f"run {run['id']} has no valid attempt number")
data = api(f"actions/runs/{run['id']}/attempts/{attempt}/jobs?per_page=100")
jobs = data.get("jobs") or []
if data.get("total_count") != len(jobs):
raise GateError(f"run {run['id']} has more jobs than the gate inspected")
names = [job.get("name") for job in jobs]
if len(names) != len(set(names)) or not required.issubset(names):
missing = sorted(required.difference(names))
raise GateError(f"run {run['id']} has missing or duplicate jobs: {missing}")
bad = [f"{job.get('name')}: {job.get('status')}/{job.get('conclusion')}"
for job in jobs if job.get("status") != "completed" or job.get("conclusion") != "success"]
if bad:
raise GateError(f"run {run['id']} has unfinished, failed, cancelled, or skipped jobs: {bad}")
print(f"gate passed: {run.get('html_url')} ({len(jobs)} jobs)")
def main(tag):
if not tag.startswith("v") or not tag[1:]:
raise GateError("expected a version tag beginning with v")
sha = tag_commit(tag)
successful_jobs(exact_run("checks.yml", "main", sha, "push"), MAIN_JOBS)
successful_jobs(exact_run("decky-runtime.yml", "main", sha, "push"), DECKY_JOBS)
successful_jobs(exact_run("release.yml", tag, sha, "push"), RELEASE_JOBS)
print(f"exact release commit: {sha}")
if __name__ == "__main__":
try:
main(sys.argv[1])
except (IndexError, GateError) as error:
sys.exit(f"release gate failed: {error}")
-12
View File
@@ -1,12 +0,0 @@
# Native amd64 or ARM64; no emulation, host session bus, audio, devices or home.
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-dbus python3-gi python3-websockets dbus-bin dbus-daemon \
ca-certificates zlib1g \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -u 1100 -s /bin/sh tester \
&& mkdir -p /run/user/1100 && chown tester:tester /run/user/1100
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 XDG_RUNTIME_DIR=/run/user/1100
USER tester
WORKDIR /repo
ENTRYPOINT ["python3", "/repo/tests/decky-runtime/check.py"]
-5
View File
@@ -1,5 +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"}
]
-199
View File
@@ -1,199 +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 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')
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())
assert receipt['compatShims'] == [case['shim']], receipt
# Exercise the production manifest's API convention, never import Plugin ourselves.
manifest = json.loads((plugin/'plugin.json').read_text())
assert 'api_version' not in manifest, 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 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()
+2 -2
View File
@@ -196,9 +196,9 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertEqual(ssh(f'test -f {installed}/py_modules/click.py && echo yes').strip(), 'yes')
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['shotty-click-app-dir'])
if plugin['id'] in (12, 111):
if plugin['id'] == 111:
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
self.assertEqual(receipt['compatShims'], ['music-control-bound-methods'])
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':plugin['id'],'acknowledged':True}))
self.assertFalse(job.get('error'), job)
+1 -37
View File
@@ -13,7 +13,6 @@ import os
import shutil
import subprocess
import sys
from urllib.parse import parse_qs
args = sys.argv[1:]
with open(os.environ["FAKEGH_LOG"], "a") as f:
@@ -50,18 +49,6 @@ while i < len(args):
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
gate = json.load(open(os.environ["FAKEGH_GATES"])) if os.environ.get("FAKEGH_GATES") else {}
commit = gate.get("tag_commit", "a" * 40)
tag_object = "b" * 40
main_jobs = ("checks", "e2e", "ios", "wireless-windows",
"server-tests (ubuntu-latest, 3.13)", "server-tests (macos-latest, 3.12)",
"server-tests (windows-latest, 3.12)")
release_jobs = ("draft", "checksums",
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)")
decky_jobs = ("decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)")
def answer(response, fallback):
@@ -80,30 +67,7 @@ if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
tag = endpoint.rsplit("/", 1)[1]
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
sys.exit("gh: Not Found (HTTP 404)")
print(json.dumps({"ref": "refs/tags/" + tag,
"object": {"type": "tag", "sha": tag_object}}))
elif method == "GET" and endpoint == repo + "git/tags/" + tag_object:
print(json.dumps({"object": {"type": "commit", "sha": commit}}))
elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"):
path, _, query = endpoint.partition("?")
workflow = path.split("/")[5]
branch = parse_qs(query).get("branch", [""])[0]
is_main = workflow != "release.yml"
run_id = {"checks.yml": 501, "release.yml": 502, "decky-runtime.yml": 503}[workflow]
run = {"id": run_id, "head_sha": gate.get("main_sha" if is_main else "release_sha", commit),
"head_branch": branch, "event": "push", "run_attempt": 1,
"status": gate.get("decky_status" if run_id == 503 else ("main_status" if is_main else "release_status"), "completed"),
"conclusion": gate.get("decky_conclusion" if run_id == 503 else ("main_conclusion" if is_main else "release_conclusion"), "success"),
"html_url": "https://github.com/saphid/frame-control/actions/runs/%s" % run_id}
print(json.dumps({"workflow_runs": [run]}))
elif method == "GET" and endpoint.startswith(repo + "actions/runs/"):
run_id = endpoint[len(repo + "actions/runs/"):].split("/")[0]
names = {"501": main_jobs, "502": release_jobs, "503": decky_jobs}[run_id]
names = [name for name in names if name != gate.get("omit_job")]
jobs = [{"name": name, "status": "completed",
"conclusion": gate.get("bad_job_conclusion", "success") if name == gate.get("bad_job") else "success"}
for name in names]
print(json.dumps({"total_count": len(jobs), "jobs": jobs}))
print(json.dumps({"ref": "refs/tags/" + tag}))
elif method == "GET" and endpoint.startswith(repo + "releases?"):
# The fallback is what --jq '.[]' prints.
answer(releases, "\n".join(json.dumps(r) for r in releases))
-21
View File
@@ -1,21 +0,0 @@
# Silent MPRIS runtime fixture
`player.py` implements the synthetic target used by the
[Music Control runtime evidence](../../../docs/evidence/framey-mpris-runtime-2026-10-26.md).
It produces no audio. It requires system Python `dbus`, `gi` and GLib, and writes
its independent control state to `/tmp/mpris-state.json`.
Run it only in a disposable container with **no network, devices, host home,
audio or D-Bus mounts**. Create a private session bus listening at
`/run/user/<container UID>/bus`: both official backends hard-code that path.
Set `DBUS_SESSION_BUS_ADDRESS` to that private socket for the fixture. Use a
private home including `.local/share/Steam/steamui/images`, because the upstream
plugins create an album-art symlink on startup. Never run these plugins against
a real user's player during this test: ID111 probes volume automatically.
Install each pinned archive separately through the managed transaction, then
start the published Decky 3.3.0 loader with private `UNPRIVILEGED_PATH` and
`PRIVILEGED_PATH`. Do not instantiate the legacy Plugin class: invoke methods
through Decky's `loader/call_legacy_plugin_method` route with its keyword object.
The two official entries share the `MusicControl` folder, so cannot coexist.
This fixture is opt-in and is not imported by ordinary unit tests.
-53
View File
@@ -1,53 +0,0 @@
"""Silent MPRIS target. Run only inside the private network/device-isolated container."""
import json
from pathlib import Path
import dbus, dbus.service, dbus.mainloop.glib
from gi.repository import GLib
P='org.mpris.MediaPlayer2.Player'
R='org.mpris.MediaPlayer2'
D='org.freedesktop.DBus.Properties'
TRACK='/org/framey/TestTrack/1'
class Player(dbus.service.Object):
def __init__(self,bus):
self.name=dbus.service.BusName('org.mpris.MediaPlayer2.FrameySilent',bus=bus)
super().__init__(bus,'/org/mpris/MediaPlayer2')
self.state={'PlaybackStatus':'Paused','Position':12000000,'Volume':0.4,'next':0,'previous':0,'writes':[]}
self.save()
def save(self):Path('/tmp/mpris-state.json').write_text(json.dumps(self.state))
@dbus.service.method(D,in_signature='ss',out_signature='v')
def Get(self,interface,prop):
if interface==R and prop=='Identity':return dbus.String('Framey Silent Player')
if interface==P:
if prop=='Metadata':return dbus.Dictionary({'mpris:trackid':dbus.ObjectPath(TRACK),'mpris:length':dbus.Int64(180000000),'xesam:title':dbus.String('Silent Test Track'),'xesam:artist':dbus.Array(['Fixture Artist'],signature='s'),'xesam:album':dbus.String('Framey Test Album')},signature='sv')
if prop=='PlaybackStatus':return dbus.String(self.state[prop])
if prop=='Position':return dbus.Int64(self.state[prop])
if prop=='Volume':return dbus.Double(self.state[prop])
if prop=='CanSeek':return dbus.Boolean(True)
raise dbus.exceptions.DBusException('Unknown property')
@dbus.service.method(D,in_signature='ssv',out_signature='')
def Set(self,interface,prop,value):
assert interface==P and prop=='Volume' and 0<=value<=1
self.state['Volume']=float(value);self.state['writes'].append(['Volume',float(value)]);self.save()
@dbus.service.method(P,in_signature='',out_signature='')
def PlayPause(self):
self.state['PlaybackStatus']='Playing' if self.state['PlaybackStatus']=='Paused' else 'Paused';self.state['writes'].append(['PlayPause']);self.save()
@dbus.service.method(P,in_signature='',out_signature='')
def Pause(self):
self.state['PlaybackStatus']='Paused';self.state['writes'].append(['Pause']);self.save()
@dbus.service.method(P,in_signature='',out_signature='')
def Next(self):
self.state['next']+=1;self.state['writes'].append(['Next']);self.save()
@dbus.service.method(P,in_signature='',out_signature='')
def Previous(self):
self.state['previous']+=1;self.state['writes'].append(['Previous']);self.save()
@dbus.service.method(P,in_signature='x',out_signature='')
def Seek(self,offset):
self.state['Position']+=int(offset);self.state['writes'].append(['Seek',int(offset)]);self.save()
@dbus.service.method(P,in_signature='ox',out_signature='')
def SetPosition(self,track,pos):
assert str(track)==TRACK and 0<=pos<=180000000
self.state['Position']=int(pos);self.state['writes'].append(['SetPosition',int(pos)]);self.save()
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
player=Player(dbus.SessionBus())
print('READY silent MPRIS fixture',flush=True)
GLib.MainLoop().run()
+6 -23
View File
@@ -276,29 +276,12 @@ class ArtworkTests(unittest.TestCase):
import threading
from apk_sources import _images
gate = threading.Event()
real_join = threading.Thread.join
waits = []
def bounded_join(worker, timeout=None):
waits.append(timeout)
return real_join(worker, timeout)
try:
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []), \
patch.object(_images.threading.Thread, 'join', bounded_join):
start = time.monotonic()
deadline = start + 0.1
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=deadline)
# Check the requested wait, not elapsed runner time: a loaded CI
# host can deschedule this test after the bounded join returns.
self.assertEqual(len(waits), 1)
self.assertIsNotNone(waits[0])
self.assertGreaterEqual(waits[0], 0)
self.assertLessEqual(waits[0], deadline - start)
finally:
gate.set()
wait_for_idle_resolvers()
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
start = time.monotonic()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=start + 0.1)
self.assertLess(time.monotonic() - start, 0.4)
gate.set()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
+19 -54
View File
@@ -214,7 +214,7 @@ assert copied.read_bytes() == b'local screenshot fixture'
self.assertFalse((shotty / 'py_modules/click.py').exists())
self.assertNotIn('compatShims', json.loads((shotty / '.frame-control-store.json').read_text()))
def test_legacy_music_control_keeps_decky_class_calling_convention(self):
def test_exact_music_control_backend_calls_work_after_managed_install(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 111)
data = official_archive(plugin)
@@ -225,16 +225,16 @@ assert copied.read_bytes() == b'local screenshot fixture'
self.assertEqual(result.returncode, 0, result.stderr)
receipt = json.loads((folder / '.frame-control-store.json').read_text())
self.assertEqual(receipt['sha256'], plugin['sha256'])
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
self.assertNotIn('api_version', json.loads((folder / 'plugin.json').read_text()))
self.assertIn('self._get_dbus_env(self)', (folder / 'main.py').read_text())
self.assertEqual(receipt['compatShims'], ['music-control-bound-methods'])
with zipfile.ZipFile(io.BytesIO(data)) as bundle:
original = bundle.read('MusicControl/main.py').decode()
self.assertNotEqual((folder / 'main.py').read_text(), original)
dbus = Path(self.tmp.name) / 'dbus-send'
dbus.write_text('''#!/usr/bin/env python3
import os, sys
from pathlib import Path
args = ' '.join(sys.argv[1:])
assert os.environ['LD_LIBRARY_PATH'] == os.environ.get('LD_LIBRARY_PATH_ORIG', '')
with Path(os.environ['MPRIS_CALLS']).open('a') as log: log.write(args + '\\n')
if 'ListNames' in args:
print('method return\\n array [\\n string "org.freedesktop.DBus"\\n string "org.mpris.MediaPlayer2.fixture"\\n ]')
@@ -245,32 +245,29 @@ else:
''')
dbus.chmod(0o700)
calls = Path(self.tmp.name) / 'mpris-calls'
source = '''import asyncio, importlib.util, os, sys, types
source = '''import asyncio, importlib.util, sys, types
from pathlib import Path
decky = types.ModuleType('decky_plugin')
sys.modules['decky_plugin'] = decky
spec = importlib.util.spec_from_file_location('music_control', Path(sys.argv[1])/'main.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Decky 3.3 uses the class for manifests with no api_version.
plugin = module.Plugin
plugin = module.Plugin()
async def check():
assert await plugin.sp_list_media_players(plugin) == 'org.mpris.MediaPlayer2.fixture'
await plugin.sp_set_media_player(plugin, 'org.mpris.MediaPlayer2.fixture')
assert await plugin.sp_get_media_player(plugin) == 'org.mpris.MediaPlayer2.fixture'
await plugin.sp_play(plugin)
await plugin.sp_pause(plugin)
await plugin.sp_next(plugin)
await plugin.sp_previous(plugin)
await plugin.sp_set_volume(plugin, 0.25)
assert await plugin.sp_track_status(plugin) == 'Paused'
assert await plugin.sp_list_media_players() == 'org.mpris.MediaPlayer2.fixture'
await plugin.sp_set_media_player('org.mpris.MediaPlayer2.fixture')
assert await plugin.sp_get_media_player() == 'org.mpris.MediaPlayer2.fixture'
await plugin.sp_play()
await plugin.sp_pause()
await plugin.sp_next()
await plugin.sp_previous()
await plugin.sp_set_volume(0.25)
assert await plugin.sp_track_status() == 'Paused'
asyncio.run(check())
assert os.environ['LD_LIBRARY_PATH'] == 'FrameyBundledLibraries'
'''
called = subprocess.run([sys.executable, '-c', source, str(folder)],
capture_output=True, text=True,
env={**self.env, 'MPRIS_CALLS': str(calls), 'LD_LIBRARY_PATH': 'FrameyBundledLibraries',
'LD_LIBRARY_PATH_ORIG': 'FrameySystemLibraries'}, timeout=10)
env={**self.env, 'MPRIS_CALLS': str(calls)}, timeout=10)
self.assertEqual(called.returncode, 0, called.stderr)
logged = calls.read_text()
self.assertIn('org.freedesktop.DBus.ListNames', logged)
@@ -278,18 +275,6 @@ assert os.environ['LD_LIBRARY_PATH'] == 'FrameyBundledLibraries'
self.assertIn('org.mpris.MediaPlayer2.Player.Pause', logged)
self.assertIn('string:Volume variant:double:0.25', logged)
self.assertIn('org.mpris.MediaPlayer2.fixture', logged)
# An explicit same-version update replaces the previously shipped bad shim.
main = folder / 'main.py'
main.write_text(main.read_text().replace('self._get_dbus_env(self)', 'self._get_dbus_env()'))
receipt['compatShims'] = ['music-control-bound-methods']
(folder / '.frame-control-store.json').write_text(json.dumps(receipt))
result = self.apply('update', data, expected=plugin['sha256'], **args)
self.assertEqual(result.returncode, 0, result.stderr)
called = subprocess.run([sys.executable, '-c', source, str(folder)],
capture_output=True, text=True,
env={**self.env, 'MPRIS_CALLS': str(calls),
'LD_LIBRARY_PATH': 'FrameyBundledLibraries'}, timeout=10)
self.assertEqual(called.returncode, 0, called.stderr)
result = self.apply('remove', expected=plugin['sha256'], **args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(folder.exists())
@@ -299,29 +284,9 @@ assert os.environ['LD_LIBRARY_PATH'] == 'FrameyBundledLibraries'
old_args = {'plugin_id': older['id'], 'folder': older['folder'], 'version': older['version']}
result = self.apply('install', old_data, **old_args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads((folder / '.frame-control-store.json').read_text())['compatShims'],
['music-control-system-dbus-env'])
self.assertNotIn('api_version', json.loads((folder / 'plugin.json').read_text()))
self.assertIn('self._get_dbus_env(self)', (folder / 'main.py').read_text())
old_env = {**self.env, 'MPRIS_CALLS': str(calls), 'LD_LIBRARY_PATH': 'FrameyBundledLibraries'}
old_env.pop('LD_LIBRARY_PATH_ORIG', None)
called = subprocess.run([sys.executable, '-c', source, str(folder)],
capture_output=True, text=True, env=old_env, timeout=10)
self.assertEqual(called.returncode, 0, called.stderr)
result = self.apply('remove', expected=older['sha256'], **old_args)
self.assertEqual(result.returncode, 0, result.stderr)
# A future archive at the same store ID must retain its own source.
other = io.BytesIO()
untouched = 'class Plugin: pass\n'
with zipfile.ZipFile(other, 'w') as bundle:
bundle.writestr('MusicControl/plugin.json', json.dumps({'name': 'Music Control', 'flags': []}))
bundle.writestr('MusicControl/dist/index.js', 'export default {};' + ' ' * 1200)
bundle.writestr('MusicControl/main.py', untouched)
result = self.apply('install', other.getvalue(), **args)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual((folder / 'main.py').read_text(), untouched)
self.assertNotIn('compatShims', json.loads((folder / '.frame-control-store.json').read_text()))
with zipfile.ZipFile(io.BytesIO(old_data)) as bundle:
self.assertEqual((folder / 'main.py').read_bytes(), bundle.read('MusicControl/main.py'))
def test_installed_script_entrypoints_execute_without_zip_exec_bits(self):
data, _ = archive(extra=('Example/scripts/start.py',
-36
View File
@@ -1,36 +0,0 @@
"""Managed backend shims must stay covered by the actual-Decky CI gate."""
import sandbox # noqa: F401
import importlib.util
from pathlib import Path
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('decky_runtime_gate', ROOT/'scripts/framey-test-decky-runtime.py')
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
class RuntimeCoverage(unittest.TestCase):
def test_current_shims_have_unique_official_archive_probes(self):
from framey_official import PLUGINS
cases = gate.check_coverage()
official = {record['id'] for record in PLUGINS}
self.assertEqual(len(cases), len({case['id'] for case in cases}))
for case in cases:
self.assertIn(case['id'], official)
self.assertIn(case['probe'], ('mpris', 'screenshots'))
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()
+2 -34
View File
@@ -37,14 +37,12 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
def run_script(self, releases, *args, tags="v9.8.7"):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
(d / "gates.json").write_text(json.dumps(gates or {}))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
FAKEGH_GATES=str(d / "gates.json"))
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
@@ -122,36 +120,6 @@ class PublishRelease(unittest.TestCase):
self.assertIsNone(upload)
self.assertTrue(all("-X" not in c for c in log))
def test_refuses_failed_e2e_or_cancelled_installer_before_mutation(self):
for gates in ({"bad_job": "e2e", "bad_job_conclusion": "failure"},
{"bad_job": "decky-runtime (ubuntu-24.04-arm)",
"bad_job_conclusion": "failure"},
{"bad_job": "build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
"bad_job_conclusion": "cancelled"}):
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
self.assertNotEqual(p.returncode, 0)
self.assertIn("release gate failed", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_missing_or_nonexact_gate_results(self):
for gates in ({"omit_job": "e2e"}, {"main_sha": "c" * 40},
{"decky_conclusion": "failure"}, {"release_conclusion": "failure"},
{"release_status": "in_progress"}):
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
self.assertNotEqual(p.returncode, 0)
self.assertIn("release gate failed", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_a_withdrawn_release_even_when_ci_is_green(self):
rel = dict(draft(), body="> **WITHDRAWN 26 October 2026.** Do not install.\n\n# Notes")
p, log, upload = self.run_script([rel], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("marked WITHDRAWN", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
if __name__ == "__main__":
unittest.main()
+17 -14
View File
@@ -228,23 +228,26 @@ try:
' base = os.path.join(os.path.expanduser("~"), ".config")\n'
' return os.path.join(base, app_name)\n',encoding='utf-8')
compat_shims.append('shotty-click-app-dir')
# These legacy backends launch system dbus-send (1.1.6 via /bin/sh).
# Decky's PyInstaller LD_LIBRARY_PATH selects bundled libtinfo built
# against newer glibc than Frame OS. Restore its saved system path
# only in the copied subprocess environment; retain legacy self calls.
music_digests={
12:'246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62',
111:'3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9',
}
if digest==music_digests.get(int(plugin_id)) and folder=='MusicControl':
# Music Control 1.1.7 passes `self` to its own bound methods.
# Python has already bound that argument, so even read-only player
# discovery raises TypeError. Repair only the SHA-pinned official
# source, and fail the transaction if its expected calls change.
if int(plugin_id)==111 and digest=='3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9' and folder=='MusicControl':
main=unpacked/'main.py'
source=main.read_text(encoding='utf-8')
old=' env = os.environ.copy()\n'
if source.count(old)!=1:
raise RuntimeError('Music Control compatibility source changed')
source=source.replace(old,old+' env["LD_LIBRARY_PATH"] = env.get("LD_LIBRARY_PATH_ORIG", "")\n')
fixes={
'self._get_dbus_env(self)':('self._get_dbus_env()',10),
'self._sp_player_dbus(self,':('self._sp_player_dbus(',7),
'self._sp_dbus_set(self,':('self._sp_dbus_set(',1),
'self.sp_get_volume(self)':('self.sp_get_volume()',2),
'self.sp_set_volume(self,':('self.sp_set_volume(',2),
}
for old,(new,count) in fixes.items():
if source.count(old)!=count:
raise RuntimeError('Music Control compatibility source changed')
source=source.replace(old,new)
main.write_text(source,encoding='utf-8')
compat_shims.append('music-control-system-dbus-env')
compat_shims.append('music-control-bound-methods')
# Recheck under the transaction lock. Another store operation may
# have installed a colliding name while this archive downloaded.
proposed_name=json.loads((unpacked/'plugin.json').read_text()).get('name')
+11 -17
View File
@@ -474,14 +474,12 @@
"https://github.com/mirobouma/MusicControl.git"
],
"status": "partial",
"reason": "Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply.",
"reason": "Legacy media controls initialize; player discovery and playback are unverified.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame",
"isolated Decky 3.3.0 + MPRIS fixture",
"real Frame SSH"
"fake-Frame"
],
"browser": {
"id": 12,
@@ -508,7 +506,7 @@
"folder": "MusicControl"
},
"backend": "Python",
"hardware": "User-session MPRIS player and dbus-send required; no Deck-specific hardware.",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"uiSurfaces": [
"local/Steam routes"
],
@@ -517,9 +515,7 @@
"sha256": "246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"compatShim": "music-control-system-dbus-env",
"evidence": "docs/evidence/framey-mpris-runtime-2026-10-26.md"
}
},
{
"id": 13,
@@ -931,7 +927,7 @@
"logs": [
{
"kind": "error",
"message": "[Bluetooth] !! get_bluetooth_status → Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
"message": "[Bluetooth] !! get_bluetooth_status \u2192 Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
@@ -939,7 +935,7 @@
},
{
"kind": "error",
"message": "[Bluetooth] !! get_paired_devices_with_info → Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
"message": "[Bluetooth] !! get_paired_devices_with_info \u2192 Error: All retries failed. Last error: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
@@ -9586,15 +9582,13 @@
"https://github.com/martinpl/music-control"
],
"status": "partial",
"reason": "Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply.",
"reason": "Exact official backend bound-method calls are repaired in managed installs. An isolated fake dbus-send target accepted player discovery, transport and volume commands after the patch; real MPRIS players, actual Decky ARM64, album art and worn VR remain unverified.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame",
"isolated Python backend",
"isolated Decky 3.3.0 + MPRIS fixture",
"real Frame SSH"
"isolated Python backend"
],
"browser": {
"id": 111,
@@ -9621,7 +9615,7 @@
"folder": "MusicControl"
},
"backend": "Python",
"hardware": "User-session MPRIS player and dbus-send required; no Deck-specific hardware.",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"uiSurfaces": [
"local/Steam routes"
],
@@ -9631,8 +9625,8 @@
"archiveValidation": "accepted",
"result": "installed-removed"
},
"compatShim": "music-control-system-dbus-env",
"evidence": "docs/evidence/framey-mpris-runtime-2026-10-26.md"
"compatShim": "music-control-bound-methods",
"evidence": "docs/evidence/framey-music-control-bound-methods-2026-10-26.md"
},
{
"id": 112,