mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-27 01:02:16 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3de6ecf71 | ||
|
|
38db6a2846 | ||
|
|
a17ebf96fe | ||
|
|
6247dfbf89 | ||
|
|
7ed59a9b25 | ||
|
|
14d677f527 | ||
|
|
72f8152525 | ||
|
|
62e27a2450 | ||
|
|
1cd1c07b7a | ||
|
|
55fb9226ba |
No files matched your search
@@ -6,7 +6,6 @@ ship other people's software, unchanged, each under its own licence:
|
||||
| What | Where | Licence | Details |
|
||||
|---|---|---|---|
|
||||
| KDE Connect 24.02.2 and five libraries, as Valve builds them for the Frame | Desktop apps and the iPhone app; copied to the Frame for the keyboard and trackpad | GPL and LGPL (per package) | [frame/kdeconnect/NOTICE.md](frame/kdeconnect/NOTICE.md), licence texts in [frame/kdeconnect/LICENSES](frame/kdeconnect/LICENSES), complete source in the [kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1) |
|
||||
| psutil 7.2.2, unchanged stable-ABI Linux amd64/ARM64 wheels | Desktop and headset app bundles; copied only into exact managed DeckySpy 0.6.7 installs | BSD-3-Clause | [pins and provenance](ui/vendor/psutil-7.2.2/README.md), [license](ui/vendor/psutil-7.2.2/LICENSE.txt) |
|
||||
| Python 3.12 ([python-build-standalone](https://github.com/astral-sh/python-build-standalone)) | Desktop apps | PSF License and others | Included with it, in the app's `python` folder |
|
||||
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
|
||||
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
|
||||
|
||||
@@ -13,9 +13,6 @@ const path = require("path");
|
||||
|
||||
const REQUIRED = [
|
||||
"ui/server.py",
|
||||
"ui/vendor/psutil-7.2.2/aarch64.whl",
|
||||
"ui/vendor/psutil-7.2.2/x86_64.whl",
|
||||
"ui/vendor/psutil-7.2.2/LICENSE.txt",
|
||||
"ui/frame_power.py",
|
||||
"ui/frame_status.py",
|
||||
"scripts/frame_power_sample.py",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.48",
|
||||
"version": "0.4.49",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.48",
|
||||
"version": "0.4.49",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-6
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.48",
|
||||
"version": "0.4.49",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -60,11 +60,6 @@
|
||||
"framey-compatibility.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../ui/vendor/psutil-7.2.2",
|
||||
"to": "ui/vendor/psutil-7.2.2",
|
||||
"filter": ["*.whl", "LICENSE.txt", "README.md"]
|
||||
},
|
||||
{
|
||||
"from": "../ui/apk_sources",
|
||||
"to": "ui/apk_sources",
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T22:28:36.276360+00:00",
|
||||
"storeId": 89,
|
||||
"version": "0.6.7",
|
||||
"officialSha256": "2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7",
|
||||
"runtime": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"architecture": "aarch64",
|
||||
"scope": "Read-only system monitoring, a silent local warning and private plugin settings; live ARM64 backend through local opaque Chromium. No worn VR.",
|
||||
"backend": {
|
||||
"successfulMethods": [
|
||||
"get_battery",
|
||||
"get_boottime",
|
||||
"get_cpu",
|
||||
"get_memory",
|
||||
"get_net_interface",
|
||||
"get_settings",
|
||||
"get_top_k_mem_procs",
|
||||
"get_version"
|
||||
],
|
||||
"memoryTotalMatchedProcMeminfo": true,
|
||||
"batteryPercent": 100,
|
||||
"noNetworkOrProcessStateChanged": true
|
||||
},
|
||||
"browser": {
|
||||
"scope": "local opaque Chromium with live actual Frame ARM64 Decky 3.3.0",
|
||||
"checks": {
|
||||
"cpuVisible": true,
|
||||
"memoryVisible": true,
|
||||
"battery100": true,
|
||||
"processVisible": true,
|
||||
"networkVisible": true,
|
||||
"version067": true,
|
||||
"silentWarning": true,
|
||||
"intervalChangedTo6": true,
|
||||
"reopenRetainedInterval6": true,
|
||||
"reopenedLiveArm64Metrics": true
|
||||
}
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"archiveAbsent": true
|
||||
},
|
||||
"independentCleanupAudit": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"archiveAbsent": true,
|
||||
"service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
|
||||
"lockAbsent": true
|
||||
},
|
||||
"batteryBefore": [
|
||||
100
|
||||
],
|
||||
"batteryAfter": [
|
||||
100
|
||||
],
|
||||
"originalServiceStatesPreserved": true,
|
||||
"lockReleased": true,
|
||||
"fakeFrame": "Pinned official install/remove transaction suite passed, including dependency receipt and files; backend execution stubbed.",
|
||||
"localRuntime": "Actual published Decky 3.3.0 amd64: all five cases passed, including historical bad-shim negative control.",
|
||||
"tests": {
|
||||
"python": {
|
||||
"run": 1649,
|
||||
"skipped": 16,
|
||||
"result": "passed"
|
||||
},
|
||||
"fakeFramePinnedTransactions": "passed",
|
||||
"node24": {
|
||||
"run": 46,
|
||||
"result": "passed"
|
||||
}
|
||||
},
|
||||
"unverified": [
|
||||
"Automatic low-battery/OOM alerts",
|
||||
"Resume callbacks and game-time warnings",
|
||||
"Sound",
|
||||
"Worn VR and physical-controller input"
|
||||
]
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
# DeckySpy system monitoring on Frame
|
||||
|
||||
Official ID 89, DeckySpy 0.6.7, SHA-256
|
||||
`2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7`.
|
||||
[Redacted results](framey-deckyspy-2026-10-26.json).
|
||||
|
||||
The official archive imports `psutil` but does not ship it. Published Decky
|
||||
3.3.0 reproduced `ModuleNotFoundError: No module named 'psutil'` during backend
|
||||
startup. The previous catalog's account/external-integration reason was wrong:
|
||||
this plugin monitors local system metrics; its token is a local reload guard.
|
||||
|
||||
Exact managed installs now include unmodified, SHA-256-pinned BSD-3-Clause
|
||||
psutil 7.2.2 stable-ABI wheels for amd64 and ARM64. The native architecture is
|
||||
selected remotely and its ELF header checked. Dependencies and their license
|
||||
stay in this plugin's `py_modules`; no pip, network download, system-library
|
||||
changes or other plugin imports are involved. [Provenance and license](../../ui/vendor/psutil-7.2.2/README.md).
|
||||
Existing copies need an explicit managed update or reinstall. Installed receipts
|
||||
without `deckyspy-psutil-and-battery` stay partial and display that requirement;
|
||||
they do not inherit the fixed catalog status.
|
||||
|
||||
On Frame OS 0.5.6, upstream psutil also misses `max1720x_bat_7-36`: its Linux
|
||||
battery discovery filters names for `BAT` or `battery`. The plugin-local
|
||||
fallback preserves native psutil results, then reads supplies whose `type` is
|
||||
`Battery`. It reads capacity/status and optional time-to-empty in Linux sysfs
|
||||
seconds, leaves unknown states unknown, and never writes sysfs.
|
||||
|
||||
Verification used the **published Decky 3.3.0 executable**, not a hand-built
|
||||
Plugin instance. Its actual legacy websocket route passed version, CPU,
|
||||
memory, battery, uptime, process and interface reads on the real ARM64 Frame.
|
||||
The memory total exactly matched `/proc/meminfo`. No process/network controls
|
||||
were run. Before starting, the test confirmed template migration sources were
|
||||
absent so upstream's template migrations could not move existing user data.
|
||||
|
||||
Local headless Chromium rendered the exact official frontend in Framey's
|
||||
opaque iframe, connected through a single trusted SSH RPC pipe to that live
|
||||
ARM64 backend. CPU/memory/swap/uptime, 100% battery, process rows and interfaces
|
||||
rendered. A test OOM warning produced a silent visible toast. Native Chromium
|
||||
keyboard input changed only the private plugin refresh interval from 5 to 6;
|
||||
backend setting/commit calls and a fresh panel reopening confirmed persistence.
|
||||
No real media, volume, power, network or process settings changed.
|
||||
|
||||
The shared `/tmp/frame-test.lock` protected all device execution. The private
|
||||
user service had a runtime limit and was stopped/removed, along with its base
|
||||
and upload. An independent SSH audit confirmed no unit/base/upload or running
|
||||
service remained. Battery stayed 100 → 100. Normal managed Decky was failed and
|
||||
the headset panel inactive as found; the lock was released. No headset wipe,
|
||||
normal service restart or installation outside the removable private user
|
||||
service occurred. Raw process names, addresses and local guard tokens were
|
||||
kept out of published evidence.
|
||||
|
||||
Local verification also passed all five actual-Decky amd64 regression cases,
|
||||
the focused fake-Frame pinned install/remove transaction (backend execution
|
||||
stubbed), 1,649 Python tests (16 skips), and 46 app tests with Node 24. The
|
||||
native ARM64 CI gate additionally runs discovery/public calls for this shim.
|
||||
|
||||
**Works** covers useful read-only system monitoring, a silent test warning and
|
||||
private settings while the panel is open. Automatic OOM/low-battery warnings,
|
||||
resume callbacks, game-time warnings, sound, worn VR and physical controllers
|
||||
remain unverified. No claim is made for persistent monitoring after closing
|
||||
Framey's isolated panel.
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T23:23:16.239735+00:00",
|
||||
"plugin": {
|
||||
"id": 96,
|
||||
"version": "1.0.0",
|
||||
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312"
|
||||
},
|
||||
"scope": "Silent private timer settings/events only; local Chromium with actual Frame ARM64 backend. No sound, physical power actions or worn VR.",
|
||||
"runtime": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"realFrameLifecycle": {
|
||||
"lifecycle": {
|
||||
"freshCancel": true,
|
||||
"replacementSuppressesOldCompletion": true,
|
||||
"completionClearsEnd": true,
|
||||
"activeRestartPreservesDeadlineAndRecents": true,
|
||||
"canceledRestartIdle": true,
|
||||
"expiredRestartIdle": true
|
||||
},
|
||||
"actualDecky330": true
|
||||
},
|
||||
"browser": {
|
||||
"uiBegin300": true,
|
||||
"uiCancel": true,
|
||||
"liveCountdown": true,
|
||||
"completionToast": true,
|
||||
"subtleMode": true
|
||||
},
|
||||
"iframeSandbox": "allow-scripts allow-popups; no allow-same-origin",
|
||||
"isolatedNativeAmd64": [
|
||||
{
|
||||
"discovered": [
|
||||
{
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"load_type": 1,
|
||||
"disabled": false
|
||||
}
|
||||
],
|
||||
"lifecycle": {
|
||||
"freshCancel": true,
|
||||
"replacementSuppressesOldCompletion": true,
|
||||
"completionClearsEnd": true,
|
||||
"lateExpiryEmitsWithoutSelfCancel": true,
|
||||
"activeRestartPreservesDeadlineAndRecents": true,
|
||||
"canceledRestartIdle": true,
|
||||
"expiredRestartIdle": true
|
||||
},
|
||||
"result": "discovery, cancel, replace, completion and persistent restart passed"
|
||||
},
|
||||
{
|
||||
"discovered": [
|
||||
{
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"load_type": 1,
|
||||
"disabled": false
|
||||
}
|
||||
],
|
||||
"lifecycle": {
|
||||
"freshCancelError": {
|
||||
"type": -1,
|
||||
"id": 2,
|
||||
"error": {
|
||||
"name": "Exception",
|
||||
"message": "'Plugin' object has no attribute 'timer_task'",
|
||||
"traceback": "Traceback (most recent call last):\n File \"decky_loader/wsrouter.py\", line 70, in _call_route\n File \"decky_loader/loader.py\", line 232, in handle_plugin_method_call\n File \"decky_loader/loader.py\", line 229, in handle_plugin_method_call\n File \"decky_loader/plugin/plugin.py\", line 137, in execute_method\n File \"decky_loader/plugin/messages.py\", line 44, in wait_for_result\nException: 'Plugin' object has no attribute 'timer_task'\n"
|
||||
}
|
||||
},
|
||||
"cancelRetainsEnd": true,
|
||||
"replacedTimerStillFires": true,
|
||||
"expiredRestartError": true
|
||||
},
|
||||
"result": "upstream timer lifecycle regressions reproduced"
|
||||
}
|
||||
],
|
||||
"fakeFrame": "Pinned official archive installed and removed through real HTTP/SSH harness; backend execution stubbed.",
|
||||
"before": {
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"battery": [
|
||||
99
|
||||
]
|
||||
},
|
||||
"after": {
|
||||
"arch": "aarch64",
|
||||
"decky": "failed",
|
||||
"panel": "inactive",
|
||||
"battery": [
|
||||
99
|
||||
]
|
||||
},
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"sharedLockAbsent": true,
|
||||
"service": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
|
||||
"testProcesses": []
|
||||
},
|
||||
"lockReleased": true,
|
||||
"limits": [
|
||||
"Sound and suspend unsupported",
|
||||
"No alert delivery while Framey panel is closed",
|
||||
"Worn VR, controller and keyboard unverified",
|
||||
"UI recents after asynchronous reopen not asserted; persisted recents verified through actual backend restart"
|
||||
],
|
||||
"localValidation": {
|
||||
"pythonTestsRun": 1648,
|
||||
"pythonSkipped": 16,
|
||||
"pythonResult": "OK",
|
||||
"actualDeckyNativeAmd64Cases": 6,
|
||||
"fakeFramePinnedInstallRemove": "PASS",
|
||||
"officialStoreEntriesRefreshed": 116,
|
||||
"officialStoreDigestsChanged": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Simple Timer lifecycle on Frame
|
||||
|
||||
The exact official Simple Timer 1.0.0 archive (store ID 96, SHA-256
|
||||
`973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312`)
|
||||
now qualifies as **works for silent in-panel session timers** after a managed
|
||||
update/reinstall. The upstream BSD license and API1 manifest remain unchanged.
|
||||
[Redacted observations](framey-simple-timer-2026-10-26.json) accompany this report.
|
||||
|
||||
## Reproduced failures and repair
|
||||
|
||||
The unmodified archive, discovered and called by the actual published Decky
|
||||
3.3.0 loader, fails cancel before its first timer and expired-deadline startup
|
||||
with an annotation-only `timer_task` attribute. Cancel leaves `timer_end` saved,
|
||||
so canceled timers resume after restart. Starting a replacement leaves the first
|
||||
task alive; it still sends its own completion event.
|
||||
|
||||
The managed installer repairs only this exact ID/digest/folder. Lifecycle state
|
||||
is initialized before startup cancellation. A lock and cancellation/join give one
|
||||
task ownership of the timer. Explicit cancel and completion clear the persisted
|
||||
deadline. Backend unload stops the task while preserving an active deadline;
|
||||
startup resumes that exact deadline without duplicating recent timers. Late
|
||||
expiry emits its silent notice without canceling itself midway. Older installed
|
||||
copies remain partial until their receipt includes `simple-timer-lifecycle`.
|
||||
|
||||
## Verification
|
||||
|
||||
- **Actual published Decky 3.3.0, isolated native amd64:** production managed
|
||||
install, API1 discovery and public instance RPC/events; fresh cancel,
|
||||
replacement suppression, one completion, clearing persisted end, active
|
||||
restart preserving deadline/recents, canceled restart and expired restart.
|
||||
A separate unmodified-archive case must reproduce the original failures.
|
||||
The same gate runs on native ARM64 CI; this report does not preclaim its result.
|
||||
- **Real Frame ARM64, Frame OS 0.5.6:** the same lifecycle assertions through
|
||||
Steam CEF's existing `DeckyBackend`, with a bounded private Decky 3.3.0 user
|
||||
service and private settings. No plugin class was manually instantiated.
|
||||
- **Local Chromium:** actual headset host and official frontend in a sandbox
|
||||
without `allow-same-origin`; method/event bridge relayed to that real ARM64
|
||||
backend. Begin created a 300-second timer; Cancel returned to idle. A safe
|
||||
five-second backend timer updated the panel and produced the visible silent
|
||||
completion toast. Backend tests verify persisted recents across restart;
|
||||
asynchronous UI recents after reopen were not asserted.
|
||||
- **Fake Frame:** exact pinned archive installed and removed through the real
|
||||
HTTP/SSH store harness; execution is stubbed, so this verifies packaging only.
|
||||
|
||||
Only private settings and silent timers changed. The shared `/tmp/frame-test.lock`
|
||||
was held for execution and then released. Successful run battery was 99 → 99;
|
||||
normal Decky remained failed and the headset panel remained inactive as found.
|
||||
An independent audit confirmed the private base/unit/archive absent, MainPID 0,
|
||||
unit not found and no test client processes. Job-owned CEF event listeners and
|
||||
private Chromium were removed. Two earlier test-client attempts were cleaned up:
|
||||
one competed with Steam's single Decky websocket; the other lacked a bounded
|
||||
quiet-event wait. Battery across those attempts was 100 → 99. These were test
|
||||
adapter failures; the final run used CEF and passed.
|
||||
|
||||
Sound and suspend are unsupported. Alerts require the plugin panel to remain
|
||||
open. Worn VR, physical controller/keyboard and sleep/wake notification delivery
|
||||
remain unverified. A resumed timer is persisted backend behavior, not a promise
|
||||
of notification delivery while the panel is closed.
|
||||
@@ -0,0 +1,37 @@
|
||||
# v0.4.49 candidate gate, 26 October 2026
|
||||
|
||||
The version bump at `6247dfb` follows the merged Music Control repair
|
||||
([#338](https://github.com/saphid/frame-control/pull/338)), the real Decky
|
||||
runtime gate ([#343](https://github.com/saphid/frame-control/pull/343)), and
|
||||
the fail-closed publisher ([#344](https://github.com/saphid/frame-control/pull/344)).
|
||||
The published v0.4.48 draft remains withdrawn. This candidate is not a
|
||||
published update.
|
||||
|
||||
## Locked real Frame smoke
|
||||
|
||||
At 22:48 UTC, `scripts/frame-smoke.sh` ran from exact version-bumped source
|
||||
commit `6247dfb` on SteamOS 0.5.6, build `20261022.6153909`. It acquired a
|
||||
unique `/tmp/frame-test.lock` owner before reading status. SteamVR activity was
|
||||
**3 (standby)**, proximity raw **3.0625**, and battery **99%**. The result was
|
||||
**12 passed / one optional x86_64 launch skipped / zero failed**. The x86_64
|
||||
title could not launch because Steam Linux Runtime 4 was not installed; no
|
||||
x86_64 launch is claimed. ARM64 and Windows/Proton test titles installed,
|
||||
launched and were removed. The script's owned cleanup passed and exited 0.
|
||||
The private result is
|
||||
`tests/smoke/results/2026-10-26T224816Z-20261022.6153909.json`.
|
||||
|
||||
A separate read-only audit acquired a fresh unique lock after the smoke. It
|
||||
found activity **3**, proximity raw **3.0**, battery **99%**, no `fc_smoke_*`
|
||||
or `fc-smoke-*` title folders and no matching test processes. The audit
|
||||
released only its own lock. Neither run woke the headset or ran the worn
|
||||
interaction script. No wearer was confirmed; official-plugin wearer
|
||||
visibility, physical controller input, VR keyboard and optical comfort remain
|
||||
unverified.
|
||||
|
||||
## Remaining publication gates
|
||||
|
||||
The final candidate head needs green PR CI, native amd64 and ARM64 Decky
|
||||
runtime checks, all four installer previews, post-merge exact-commit main CI,
|
||||
tagged builds of all ten installers, checksums, independent package
|
||||
verification, publisher dry run and public update-channel readback. A gate
|
||||
that fails, is cancelled or remains pending holds publication.
|
||||
@@ -29,7 +29,7 @@ A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates
|
||||
|
||||
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
|
||||
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional Line truncated
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. That initial pass left Simple Timer partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSSLine truncated
|
||||
|
||||
## Every current plugin
|
||||
|
||||
@@ -97,14 +97,14 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 85: Decky-Undervolt 1.0.8 | 1 / 2 | N/A | Not supported on Frame: RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. | static archive, local browser, fake-Frame |
|
||||
| 87: ScreenSaver 1.1.2 | legacy / legacy | partial | Initializer succeeds; global screen overlay and idle/suspend integration have no verified VR target. | static archive, local browser, fake-Frame |
|
||||
| 88: steamdeck-input-disabler 1.0.2 | legacy / legacy | N/A | Not supported on Frame: Targets the built-in Steam Deck USB gamepad; Frame has different controllers. | static archive, local browser, fake-Frame |
|
||||
| 89: DeckySpy 0.6.7 | legacy / legacy | works | Works for read-only system monitoring: live CPU, memory, uptime, processes, interfaces and Frame battery render in local Chromium with actual Decky 3.3.0 ARM64 on the Frame; silent warning and private settings save/reopen pass. Exact managed install adds pinned BSD psutil wheels and a battery-name fallback. Existing copies need update/reinstall. Automatic alerts, resume/game-time warnings, sound and worn VR remain unverified; monitoring requires an open panel. | static archive, fake-Frame, local browser, real Frame SSH |
|
||||
| 89: DeckySpy 0.6.7 | legacy / legacy | partial | Initializer succeeds; account tokens and external service integration were not exercised. | static archive, local browser, fake-Frame |
|
||||
| 90: Picture in Picture 1.0.0 | 1 / 2 | partial | Initializer succeeds; global picture-in-picture injection cannot escape the isolated panel. | static archive, local browser, fake-Frame |
|
||||
| 91: PlayCount 1.7 | 1 / 2 | partial | Initializer succeeds; Steam game-page play-count patch has no supported VR target. | static archive, local browser, fake-Frame |
|
||||
| 92: Crosshair 1.1.0 | 1 / 2 | partial | Initializer succeeds; global crosshair injection has no supported compositor target. | static archive, local browser, fake-Frame |
|
||||
| 93: Deck Settings 1.2.6 | 1 / 2 | partial | Game picker renders with detached real Frame library/install-folder reads; search modal opens in local Chromium. Snapshot refreshes on panel reopen. Remote reports/proxy fetch, settings changes and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 94: Decky DoT DNS 0.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 95: Decky-SpoofDPI 0.12.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-spoof-dpi/bin/spoofdpi. | static archive, local browser, fake-Frame |
|
||||
| 96: Simple Timer 1.0.0 | 1 / 2 | partial | Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 96: Simple Timer 1.0.0 | 1 / 2 | works | Verified for silent in-panel session timers: Chromium Begin/Cancel/countdown/toast with real ARM64 Decky 3.3.0; repaired cancel, replace, completion and active/canceled/expired restarts pass actual loader RPC/events. Update/reinstall required. Sound and suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 97: Decky-Framegen 0.11.15 | 1 / 2 | broken | Plugin archive size or SHA-256 did not match the store. | static archive, local browser, fake-Frame |
|
||||
| 98: Decky-Lookup 0.1.0 | 1 / 2 | partial | Initializer succeeds; game-page context actions and live lookups remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 99: Decky Zerotier 0.3.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
@@ -220,7 +220,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
|
||||
| Decky-Undervolt | GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend | ButtonItem, DialogButton, DropdownItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, TextField, ToggleField, definePlugin | Router, appStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python + native payload; x86_64 | RyzenAdj Curve Optimizer requires an AMD CPU; Frame is ARM64. |
|
||||
| ScreenSaver | Input.RegisterForControllerInputMessages, Input.RegisterForControllerStateChanges, Settings.SetSetting, System.RegisterForOnSuspendRequest, System.UpdateSettings, User.RegisterForPrepareForSystemSuspendProgress | PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| steamdeck-input-disabler | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | Targets the built-in Steam Deck USB gamepad; Frame has different controllers. |
|
||||
| DeckySpy | System.RegisterForOnResumeFromSuspend | ButtonItem, Field, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python; managed psutil 7.2.2 stable-ABI native amd64/ARM64 dependency | Read-only Linux /proc and network-interface metrics; Frame battery read by power_supply type=Battery. No Deck-specific hardware writes. |
|
||||
| DeckySpy | System.RegisterForOnResumeFromSuspend | ButtonItem, Field, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Picture in Picture | none detected | ButtonItem, ConfirmModal, DropdownItem, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, findModuleChild, getGamepadNavigationTrees, quickAccessMenuClasses, showModal | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| PlayCount | none detected | Button, ButtonItem, DialogBody, DialogButton, DialogControlsSection, DialogHeader, DropdownItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, Tabs, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Crosshair | none detected | ButtonItem, PanelSection, PanelSectionRow, staticClasses | window.SP_REACT, QAM/global tabs | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
|
||||
@@ -70,9 +70,13 @@ The [26 October live checks](evidence/framey-events-backend-2026-10-26.json)
|
||||
used the actual official Notebook and Simple Timer Python backends in a private
|
||||
removable ARM64 Decky service. Notebook's default notebook saved a pointer stroke
|
||||
and loaded it after a backend restart. Simple Timer's start/cancel controls,
|
||||
countdown, completion toast and persisted recents/subtle mode worked. Simple
|
||||
Timer remains partial: its own expired-timer startup raises a missing
|
||||
`timer_task` error, sound/suspend are unsupported, and worn VR was not checked.
|
||||
countdown, completion toast and persisted recents/subtle mode worked. The
|
||||
[Simple Timer lifecycle repair](evidence/framey-simple-timer-2026-10-26.md) now
|
||||
fixes cancellation, replacement and active/canceled/expired backend restarts,
|
||||
verified through actual Decky 3.3.0 on amd64 and the ARM64 Frame. Managed
|
||||
update/reinstall is required. Silent in-panel session timers qualify as works;
|
||||
sound/suspend are unsupported, alerts require an open panel, and worn VR remains
|
||||
unverified.
|
||||
|
||||
DFL module and class lookup searches a finite registry of Framey's own controls,
|
||||
CSS maps and an explicitly unsupported optional UI-audio player. It follows DFL's
|
||||
|
||||
+15
-1
@@ -34,13 +34,27 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
|
||||
4. Publish:
|
||||
|
||||
Confirm the exact tag commit's main CI and e2e, the locked real Frame smoke,
|
||||
release verification, independent package checks, and all four tagged
|
||||
installer builds have passed. Hold the release if any gate is failed,
|
||||
cancelled, skipped, still running, or unverified. The publisher also reads
|
||||
the exact commit's GitHub CI, native Decky runtime gate, and tagged build
|
||||
job records and refuses to
|
||||
publish unless every required job succeeded; it cannot prove physical
|
||||
headset or installer behavior on its own.
|
||||
|
||||
```sh
|
||||
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
|
||||
scripts/publish-release.sh v0.4.0
|
||||
```
|
||||
|
||||
The script checks that the tag is on GitHub and that all ten installers
|
||||
The script checks that the tag is on GitHub, that exact-commit main CI
|
||||
(including e2e), native amd64/ARM64 Decky runtime checks, and the tagged
|
||||
release workflow are green with no failed,
|
||||
cancelled, skipped or running jobs, 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
|
||||
|
||||
+1
-3
@@ -578,9 +578,7 @@ uncovered shim names. The runtime job verifies the managed receipt, loader
|
||||
plugin discovery, and a successful backend call; a discovery listing alone
|
||||
could conceal a crashed backend process.
|
||||
|
||||
Current cases cover both Music Control entries, Shotty and DeckySpy. DeckySpy
|
||||
verifies native psutil import plus read-only CPU, memory, uptime, battery and
|
||||
network metrics through actual legacy RPC calls. Music Control uses
|
||||
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
|
||||
|
||||
@@ -50,7 +50,7 @@ def render(data):
|
||||
'record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) '
|
||||
'verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, '
|
||||
'countdown/completion events, a visible toast and persisted recents/subtle mode. '
|
||||
'Only private test settings were written. Simple Timer remains partial because its own expired-timer startup '
|
||||
'Only private test settings were written. That initial pass left Simple Timer partial because its own expired-timer startup '
|
||||
'raises a missing `timer_task` error; sound/suspend are unsupported. '
|
||||
'A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer '
|
||||
'through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, '
|
||||
@@ -86,11 +86,13 @@ def render(data):
|
||||
'Both official entries pass transport/seek/volume, Info and reopen in local Chromium with actual Decky 3.3.0 amd64 and a private silent MPRIS service. '
|
||||
'Actual ARM64 Decky on the Frame passes read-only empty-player discovery for both. Existing copies need remove/reinstall or an explicit managed update. '
|
||||
'Actual media playback on ARM64, artwork and worn VR remain unverified; both stay partial. '
|
||||
'A [DeckySpy follow-up](evidence/framey-deckyspy-2026-10-26.md) repairs its omitted psutil dependency with pinned BSD stable-ABI amd64/ARM64 wheels and a plugin-local Frame battery-name fallback. '
|
||||
'Its actual Decky 3.3.0 ARM64 backend passed read-only metrics under the shared lock, with memory independently matched to /proc/meminfo. '
|
||||
'Local opaque Chromium used that live ARM64 backend to render CPU, memory, battery, processes and interfaces, display a silent warning toast and save/reopen a private refresh setting. '
|
||||
'DeckySpy qualifies as works for system monitoring while its panel is open. Automatic alerts, resume/game-time warnings, sound and worn VR remain unverified. '
|
||||
'Cleanup and an independent removal audit passed; battery stayed 100 → 100 and original service states were preserved. Existing copies need update/reinstall.', '',
|
||||
'A [Simple Timer lifecycle follow-up](evidence/framey-simple-timer-2026-10-26.md) repairs fresh/expired startup cancellation, '
|
||||
'canceled deadlines that resumed and replaced tasks that still notified. Exact managed installs retain the API1 instance convention. '
|
||||
'Published Decky 3.3.0 discovery/RPC/events verify cancel, replacement, completion and active/canceled/expired restarts in isolation; '
|
||||
'the real ARM64 Frame repeats those lifecycle checks under the shared lock. Local Chromium with that live backend verifies '
|
||||
'Begin/Cancel, countdown and a silent completion toast. Simple Timer qualifies as works for silent in-panel session timers. '
|
||||
'Update/reinstall is required; old receipts stay partial. Sound/suspend are unsupported, closed-panel alerts and worn VR unverified. '
|
||||
'The private service, files, test listeners and processes were removed; independent cleanup audit passed and battery stayed 99 → 99.', '',
|
||||
'## 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.', '',
|
||||
|
||||
@@ -82,7 +82,8 @@ def main():
|
||||
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)]
|
||||
probes = [(case['id'], None) for case in cases] + [
|
||||
(111, '--reproduce-bound-method-regression'), (96, '--reproduce-timer-regression')]
|
||||
for plugin_id, negative in probes:
|
||||
name = 'framey-decky-'+uuid.uuid4().hex[:12]
|
||||
command = ['docker', 'run', '--rm', '--name', name, '--network', 'none', '--read-only',
|
||||
@@ -94,7 +95,7 @@ def main():
|
||||
'--mount', f'type=bind,source={loader},target=/input/PluginLoader,readonly',
|
||||
tag, str(plugin_id)]
|
||||
if negative:
|
||||
command.append('--reproduce-bound-method-regression')
|
||||
command.append(negative)
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode:
|
||||
|
||||
@@ -31,6 +31,11 @@ trap 'rm -rf "$tmp"' EXIT
|
||||
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|
||||
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
|
||||
|
||||
# A complete asset list is insufficient: a cancelled installer build can leave
|
||||
# old uploaded bytes behind. Check the exact tagged commit's main/e2e jobs and
|
||||
# the latest exact-tag release attempt before any release mutation.
|
||||
python3 "$(dirname "$0")/verify-release-gates.py" "$tag"
|
||||
|
||||
# Every release, drafts included, one JSON object per line.
|
||||
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
|
||||
# The release whose tag_name is the tag; failing that, the one untagged-... draft
|
||||
@@ -56,6 +61,12 @@ json.dump(r, sys.stdout)
|
||||
EOF
|
||||
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
|
||||
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
|
||||
python3 - "$tmp/release.json" <<'EOF'
|
||||
import json, sys
|
||||
release = json.load(open(sys.argv[1]))
|
||||
if (release.get("body") or "").lstrip().startswith("> **WITHDRAWN"):
|
||||
sys.exit("not publishing: this release is marked WITHDRAWN")
|
||||
EOF
|
||||
|
||||
missing=""
|
||||
for name in $expected; do
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/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}")
|
||||
@@ -15,8 +15,8 @@
|
||||
"probe": "screenshots"
|
||||
},
|
||||
{
|
||||
"id": 89,
|
||||
"shim": "deckyspy-psutil-and-battery",
|
||||
"probe": "system-monitor"
|
||||
"id": 96,
|
||||
"shim": "simple-timer-lifecycle",
|
||||
"probe": "timer-lifecycle"
|
||||
}
|
||||
]
|
||||
@@ -11,6 +11,8 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import io
|
||||
|
||||
import websockets
|
||||
from websockets.exceptions import WebSocketException
|
||||
@@ -57,6 +59,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('id', type=int)
|
||||
parser.add_argument('--reproduce-bound-method-regression', action='store_true')
|
||||
parser.add_argument('--reproduce-timer-regression', action='store_true')
|
||||
args = parser.parse_args()
|
||||
assert os.getuid() == 1100 and ROOT.is_dir(), 'Run through framey-test-decky-runtime.py'
|
||||
record = next(p for p in PLUGINS if p['id'] == args.id)
|
||||
@@ -78,15 +81,20 @@ def main():
|
||||
env = dict(os.environ, PATH=str(tools)+':'+os.environ['PATH'])
|
||||
Path('/tmp/frame-control-decky-plugin-runtime-test').write_bytes(data)
|
||||
install = subprocess.run([sys.executable, '-', 'install', 'runtime-test', str(record['id']),
|
||||
record['sha256'], record['folder'], record['version'], '-'], input=frame_decky_store.apply_script(record['id'], record['sha256']),
|
||||
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
|
||||
if not args.reproduce_timer_regression:
|
||||
assert receipt['compatShims'] == [case['shim']], receipt
|
||||
else:
|
||||
assert record['id'] == 96
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as original:
|
||||
(plugin/'main.py').write_bytes(original.read(record['folder']+'/main.py'))
|
||||
# Exercise the production manifest's API convention, never import Plugin ourselves.
|
||||
manifest = json.loads((plugin/'plugin.json').read_text())
|
||||
assert 'api_version' not in manifest, manifest
|
||||
assert manifest.get('api_version', 'legacy') == record['api_version'], manifest
|
||||
if args.reproduce_bound_method_regression:
|
||||
assert record['id'] == 111
|
||||
source = (plugin/'main.py').read_text()
|
||||
@@ -131,7 +139,29 @@ def main():
|
||||
report['calls'].append({'method': method, 'result': response['result']})
|
||||
return response['result']
|
||||
|
||||
if args.reproduce_bound_method_regression:
|
||||
if case['probe'] == 'timer-lifecycle':
|
||||
from timer_lifecycle import probe
|
||||
|
||||
def restart(settings=None):
|
||||
nonlocal backend
|
||||
os.killpg(backend.pid, signal.SIGTERM)
|
||||
backend.wait(timeout=8)
|
||||
if settings is not None:
|
||||
path = base/'settings'/record['folder']/'settings.json'
|
||||
assert path.exists(), path
|
||||
path.write_text(json.dumps(settings))
|
||||
backend = subprocess.Popen([str(loader)], env=env, stdout=log, stderr=log,
|
||||
start_new_session=True)
|
||||
processes.append(backend)
|
||||
until_ready(lambda: rpc('loader/get_plugins'), 'restarted Decky discovery')
|
||||
until_ready(lambda: rpc('loader/call_plugin_method', record['name'],
|
||||
'settings_getSetting', 'subtle_mode', False), 'restarted instance RPC')
|
||||
|
||||
report['lifecycle'] = probe(record['name'], restart, home/'runtime.log',
|
||||
args.reproduce_timer_regression)
|
||||
report['result'] = ('upstream timer lifecycle regressions reproduced' if args.reproduce_timer_regression
|
||||
else 'discovery, cancel, replace, completion and persistent restart passed')
|
||||
elif args.reproduce_bound_method_regression:
|
||||
response = rpc('loader/call_legacy_plugin_method', record['name'], 'sp_list_media_players', {})
|
||||
assert response['success'] is False and 'self' in response['result'], response
|
||||
report['expectedRegression'] = response
|
||||
@@ -164,22 +194,6 @@ def main():
|
||||
assert state['next'] == state['previous'] == 1 and state['Position'] == 45000000, state
|
||||
report['independentState'] = state
|
||||
report['result'] = 'discovery, metadata, transport, seek and volume passed'
|
||||
elif case['probe'] == 'system-monitor':
|
||||
def metric(method):
|
||||
response = legacy(method)
|
||||
if response['code'] != 0:
|
||||
raise ValueError(response['data'])
|
||||
return json.loads(response['data'])
|
||||
assert legacy('get_version') == {'code': 0, 'data': record['version']}
|
||||
memory = until_ready(lambda: metric('get_memory'), 'DeckySpy stats thread')
|
||||
assert memory['vmem']['total'] > 0 and 0 <= memory['vmem']['percent'] <= 100, memory
|
||||
cpu = metric('get_cpu')
|
||||
assert 0 <= cpu <= 100, cpu
|
||||
assert metric('get_boottime') > 0
|
||||
assert isinstance(metric('get_battery')['battery'], bool)
|
||||
assert isinstance(metric('get_net_interface'), list)
|
||||
assert isinstance(legacy('get_settings', key='framey.runtime-test', default=True)['data'], bool)
|
||||
report['result'] = 'discovery, native psutil import and read-only system metrics passed'
|
||||
else:
|
||||
assert case['probe'] == 'screenshots'
|
||||
assert legacy('was_rescued') is False
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Simple Timer lifecycle through actual Decky instance dispatch, never Plugin()."""
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
import websockets
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.sequence = 0
|
||||
self.events = []
|
||||
|
||||
async def __aenter__(self):
|
||||
token = urllib.request.urlopen('http://127.0.0.1:1337/auth/token', timeout=2).read().decode()
|
||||
self.ws = await websockets.connect('ws://127.0.0.1:1337/ws?auth='+token)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
await self.ws.close()
|
||||
|
||||
async def receive(self, timeout=8):
|
||||
response = json.loads(await asyncio.wait_for(self.ws.recv(), timeout))
|
||||
if response.get('type') == 5 and response.get('event') == 'loader/plugin_event':
|
||||
event = response['args'][0]
|
||||
if event['plugin'] == self.name:
|
||||
self.events.append(event)
|
||||
return response
|
||||
|
||||
async def call(self, method, *args, success=True):
|
||||
self.sequence += 1
|
||||
await self.ws.send(json.dumps({'type': 0, 'id': self.sequence,
|
||||
'route': 'loader/call_plugin_method', 'args': [self.name, method, *args]}))
|
||||
while True:
|
||||
response = await self.receive()
|
||||
if response.get('id') != self.sequence:
|
||||
continue
|
||||
await self.ws.send(json.dumps({'type': 3, 'id': self.sequence}))
|
||||
if success:
|
||||
assert response['type'] == 1, response
|
||||
return response['result']
|
||||
return response
|
||||
|
||||
async def seconds(self):
|
||||
offset = len(self.events)
|
||||
await self.call('load_remaining_seconds')
|
||||
while not any(e['event'] == 'simple_timer_seconds_updated' for e in self.events[offset:]):
|
||||
await self.receive()
|
||||
return next(e['args'][0] for e in reversed(self.events)
|
||||
if e['event'] == 'simple_timer_seconds_updated')
|
||||
|
||||
async def drain(self, seconds):
|
||||
deadline = time.monotonic()+seconds
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
await self.receive(max(.01, deadline-time.monotonic()))
|
||||
except TimeoutError:
|
||||
break
|
||||
|
||||
|
||||
async def initial(name, negative):
|
||||
async with Client(name) as client:
|
||||
await client.call('set_subtle_mode', True) # No sound or physical actions.
|
||||
response = await client.call('cancel_timer', success=False)
|
||||
if negative:
|
||||
assert response['type'] == -1 and 'timer_task' in str(response), response
|
||||
else:
|
||||
assert response['type'] == 1, response
|
||||
await client.call('start_timer', 60)
|
||||
end = await client.call('settings_getSetting', 'timer_end', None)
|
||||
assert end > time.time()
|
||||
await client.call('cancel_timer')
|
||||
saved = await client.call('settings_getSetting', 'timer_end', None)
|
||||
assert (saved is not None) if negative else (saved is None), saved
|
||||
await client.call('start_timer', 1)
|
||||
await client.call('start_timer', 60)
|
||||
await client.drain(6.5) # Past upstream's 5-second tick and first timer's expiry.
|
||||
completed = [e for e in client.events if e['event'] == 'simple_timer_event']
|
||||
if negative:
|
||||
assert completed, client.events
|
||||
else:
|
||||
assert not completed, completed
|
||||
assert await client.seconds() > 45
|
||||
await client.call('cancel_timer')
|
||||
if negative:
|
||||
return {'freshCancelError': response, 'cancelRetainsEnd': True,
|
||||
'replacedTimerStillFires': True}
|
||||
await client.call('start_timer', 1)
|
||||
client.events.clear()
|
||||
await client.drain(5.5)
|
||||
completed = [e for e in client.events if e['event'] == 'simple_timer_event']
|
||||
assert len(completed) == 1 and completed[0]['args'] == ['Your session has ended!', True], completed
|
||||
assert await client.seconds() == 0
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) is None
|
||||
client.events.clear()
|
||||
await client.call('start_timer', -20) # An already expired, private silent deadline.
|
||||
await client.drain(.3)
|
||||
expired = [e for e in client.events if e['event'] == 'simple_timer_event']
|
||||
assert len(expired) == 1 and expired[0]['args'] == ['Your timer has expired!', True], expired
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) is None
|
||||
await client.call('start_timer', 60)
|
||||
return {'end': await client.call('settings_getSetting', 'timer_end', None),
|
||||
'recents': await client.call('settings_getSetting', 'recent_timers_seconds', [])}
|
||||
|
||||
|
||||
async def resumed(name, saved):
|
||||
async with Client(name) as client:
|
||||
assert 0 < await client.seconds() < 60
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) == saved['end']
|
||||
assert await client.call('settings_getSetting', 'recent_timers_seconds', []) == saved['recents']
|
||||
await client.call('cancel_timer')
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) is None
|
||||
|
||||
|
||||
async def idle(name):
|
||||
async with Client(name) as client:
|
||||
assert await client.seconds() == 0
|
||||
assert await client.call('settings_getSetting', 'timer_end', None) is None
|
||||
await client.call('cancel_timer') # Idempotent before any new timer.
|
||||
assert await client.call('settings_getSetting', 'subtle_mode', False) is True
|
||||
|
||||
|
||||
def probe(name, restart, log, negative=False):
|
||||
saved = asyncio.run(initial(name, negative))
|
||||
if negative:
|
||||
restart({'timer_end': time.time()-60, 'subtle_mode': True})
|
||||
asyncio.run(read_after_startup(name))
|
||||
assert "has no attribute 'timer_task'" in log.read_text(), log.read_text()[-6000:]
|
||||
return saved | {'expiredRestartError': True}
|
||||
restart()
|
||||
asyncio.run(resumed(name, saved))
|
||||
restart()
|
||||
asyncio.run(idle(name))
|
||||
restart({'timer_end': time.time()-60, 'subtle_mode': True})
|
||||
asyncio.run(idle(name))
|
||||
return {'freshCancel': True, 'replacementSuppressesOldCompletion': True,
|
||||
'completionClearsEnd': True, 'lateExpiryEmitsWithoutSelfCancel': True,
|
||||
'activeRestartPreservesDeadlineAndRecents': True,
|
||||
'canceledRestartIdle': True, 'expiredRestartIdle': True}
|
||||
|
||||
|
||||
async def read_after_startup(name):
|
||||
async with Client(name) as client:
|
||||
# _main is scheduled independently; allow it to finish before reading its journal.
|
||||
await client.call('settings_getSetting', 'subtle_mode', False)
|
||||
await client.drain(.3)
|
||||
@@ -185,6 +185,9 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
|
||||
expected = hashlib.sha256(bundle.read(plugin['folder']+'/dist/index.js')).hexdigest()
|
||||
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
||||
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json')).get('api_version', 'legacy'), plugin['api_version'])
|
||||
if plugin['id'] == 96:
|
||||
self.assertEqual(found[0]['compatShims'], ['simple-timer-lifecycle'])
|
||||
self.assertEqual(found[0]['compatibility']['status'], 'works')
|
||||
if plugin['id'] == 135:
|
||||
chunk = 'chunks/pluginApi-B8sV7970.js'
|
||||
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as bundle:
|
||||
@@ -196,10 +199,6 @@ 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'] == 89:
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['deckyspy-psutil-and-battery'])
|
||||
self.assertEqual(ssh(f'test -f {installed}/py_modules/psutil/_psutil_linux.abi3.so && test -f {installed}/py_modules/framey_battery.py && echo yes').strip(), 'yes')
|
||||
if plugin['id'] in (12, 111):
|
||||
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
|
||||
self.assertEqual(receipt['compatShims'], ['music-control-system-dbus-env'])
|
||||
|
||||
+37
-1
@@ -13,6 +13,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["FAKEGH_LOG"], "a") as f:
|
||||
@@ -49,6 +50,18 @@ while i < len(args):
|
||||
|
||||
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
|
||||
repo = "repos/saphid/frame-control/"
|
||||
gate = json.load(open(os.environ["FAKEGH_GATES"])) if os.environ.get("FAKEGH_GATES") else {}
|
||||
commit = gate.get("tag_commit", "a" * 40)
|
||||
tag_object = "b" * 40
|
||||
main_jobs = ("checks", "e2e", "ios", "wireless-windows",
|
||||
"server-tests (ubuntu-latest, 3.13)", "server-tests (macos-latest, 3.12)",
|
||||
"server-tests (windows-latest, 3.12)")
|
||||
release_jobs = ("draft", "checksums",
|
||||
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
|
||||
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
|
||||
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
|
||||
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)")
|
||||
decky_jobs = ("decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)")
|
||||
|
||||
|
||||
def answer(response, fallback):
|
||||
@@ -67,7 +80,30 @@ if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
|
||||
tag = endpoint.rsplit("/", 1)[1]
|
||||
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
|
||||
sys.exit("gh: Not Found (HTTP 404)")
|
||||
print(json.dumps({"ref": "refs/tags/" + tag}))
|
||||
print(json.dumps({"ref": "refs/tags/" + tag,
|
||||
"object": {"type": "tag", "sha": tag_object}}))
|
||||
elif method == "GET" and endpoint == repo + "git/tags/" + tag_object:
|
||||
print(json.dumps({"object": {"type": "commit", "sha": commit}}))
|
||||
elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"):
|
||||
path, _, query = endpoint.partition("?")
|
||||
workflow = path.split("/")[5]
|
||||
branch = parse_qs(query).get("branch", [""])[0]
|
||||
is_main = workflow != "release.yml"
|
||||
run_id = {"checks.yml": 501, "release.yml": 502, "decky-runtime.yml": 503}[workflow]
|
||||
run = {"id": run_id, "head_sha": gate.get("main_sha" if is_main else "release_sha", commit),
|
||||
"head_branch": branch, "event": "push", "run_attempt": 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}))
|
||||
elif method == "GET" and endpoint.startswith(repo + "releases?"):
|
||||
# The fallback is what --jq '.[]' prints.
|
||||
answer(releases, "\n".join(json.dumps(r) for r in releases))
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+9
-2
@@ -1,13 +1,14 @@
|
||||
# Pinned Decky store test archives
|
||||
|
||||
These are unmodified ZIPs downloaded from the official Decky store CDN on
|
||||
2026-10-25, except `111.zip` downloaded on 2026-10-26. Each ZIP includes its
|
||||
2026-10-25, except `96.zip` and `111.zip` downloaded on 2026-10-26. Each ZIP includes its
|
||||
upstream `LICENSE`. They are test data, not plugins shipped or installed by
|
||||
Frame Control. The fake Frame tests serve them
|
||||
from a local fixture so CI does not depend on the store being online.
|
||||
|
||||
| File | Official plugin | Version | Store SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `96.zip` | Simple Timer | 1.0.0 | `973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312` |
|
||||
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
|
||||
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
|
||||
| `111.zip` | Music Control | 1.1.7 | `3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9` |
|
||||
@@ -19,7 +20,7 @@ from a local fixture so CI does not depend on the store being online.
|
||||
| `36.zip` | SteamGridDB | 1.8.1 | `e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0` |
|
||||
| `135.zip` | Deck Shelves | 2.0.0 | `b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91` |
|
||||
|
||||
The matching source projects are
|
||||
The matching source projects include [Simple Timer](https://github.com/decktools-xyz/simple-timer) and
|
||||
[CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader),
|
||||
[MusicControl](https://github.com/nikurasu/MusicControl),
|
||||
[Music Control](https://github.com/martinpl/music-control),
|
||||
@@ -41,3 +42,9 @@ Bluetooth result and limits are in
|
||||
Deck Shelves' chunked frontend renders its settings in local Chromium with
|
||||
backend/network calls denied. Its Steam Home shelves cannot be verified in the
|
||||
isolated VR panel; no worn-headset test is claimed.
|
||||
|
||||
Simple Timer also runs through actual published Decky 3.3.0 discovery, instance
|
||||
RPC and events in the native isolated runtime gate. Cancel/replace/completion and
|
||||
active/canceled/expired backend restarts are regression checks; the unmodified
|
||||
archive must reproduce its historical lifecycle errors. Only private silent
|
||||
timers/settings are used. The upstream BSD license stays unchanged.
|
||||
@@ -4,8 +4,8 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent / 'fixtures/framey-official'
|
||||
PLUGINS = (
|
||||
{'id': 89, 'name': 'DeckySpy', 'version': '0.6.7', 'api_version': 'legacy',
|
||||
'folder': 'decky-spy', 'sha256': '2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7'},
|
||||
{'id': 96, 'name': 'Simple Timer', 'version': '1.0.0', 'api_version': 1,
|
||||
'folder': 'simple-timer', 'sha256': '973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312'},
|
||||
{'id': 12, 'name': 'MusicControl', 'version': '1.1.6', 'api_version': 'legacy', 'folder': 'MusicControl', 'sha256': '246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62'},
|
||||
{'id': 111, 'name': 'Music Control', 'version': '1.1.7', 'api_version': 'legacy',
|
||||
'folder': 'MusicControl', 'sha256': '3f56a3f56034b277ea74a2c37cc84133b69e382aa6bcb0b43876776aac737ed9'},
|
||||
|
||||
@@ -129,7 +129,7 @@ class StoreTransaction(unittest.TestCase):
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, str(plugin_id), digest, folder, version, expected],
|
||||
input=frame_decky_store.apply_script(plugin_id, digest), text=True, capture_output=True,
|
||||
input=frame_decky_store.APPLY, text=True, capture_output=True,
|
||||
env=self.env, timeout=15)
|
||||
finally:
|
||||
source.unlink(missing_ok=True)
|
||||
@@ -161,40 +161,36 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(external.exists())
|
||||
|
||||
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
|
||||
def test_exact_deckyspy_gets_native_dependency_and_license_only_in_its_folder(self):
|
||||
def test_exact_simple_timer_gets_lifecycle_repair_and_keeps_api1_and_license(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
import platform
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 89)
|
||||
result = self.apply('install', official_archive(plugin), plugin_id=89,
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 96)
|
||||
result = self.apply('install', official_archive(plugin), plugin_id=96,
|
||||
folder=plugin['folder'], version=plugin['version'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
folder = self.folder.parent / plugin['folder']
|
||||
receipt = json.loads((folder / '.frame-control-store.json').read_text())
|
||||
self.assertEqual(receipt['compatShims'], ['deckyspy-psutil-and-battery'])
|
||||
modules = folder / 'py_modules'
|
||||
native = (modules / 'psutil/_psutil_linux.abi3.so').read_bytes()
|
||||
self.assertEqual(native[:4], b'\x7fELF')
|
||||
self.assertEqual(int.from_bytes(native[18:20], 'little'),
|
||||
{'x86_64': 62, 'aarch64': 183}[platform.machine()])
|
||||
self.assertIn('BSD 3-Clause', (modules / 'psutil-7.2.2.dist-info/LICENSE').read_text())
|
||||
result = self.apply('remove', plugin_id=89, folder=plugin['folder'])
|
||||
self.assertEqual(json.loads((folder / '.frame-control-store.json').read_text())['compatShims'],
|
||||
['simple-timer-lifecycle'])
|
||||
self.assertEqual(json.loads((folder / 'plugin.json').read_text())['api_version'], 1)
|
||||
with zipfile.ZipFile(io.BytesIO(official_archive(plugin))) as original:
|
||||
self.assertEqual((folder / 'LICENSE').read_bytes(), original.read('simple-timer/LICENSE'))
|
||||
# Backend behavior is verified separately by the real Decky lifecycle gate.
|
||||
result = self.apply('remove', plugin_id=96, folder=plugin['folder'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
|
||||
def test_deckyspy_missing_or_corrupt_payload_leaves_no_installed_copy(self):
|
||||
def test_changed_simple_timer_digest_does_not_get_pinned_repair(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 89)
|
||||
for prefix, error in [('', 'Missing native psutil'),
|
||||
("FRAMEY_BACKEND_WHEELS = {'x86_64':'AAAA','aarch64':'AAAA'}\n",
|
||||
'SHA-256 mismatch')]:
|
||||
with patch.object(frame_decky_store, 'apply_script', return_value=prefix+frame_decky_store.APPLY):
|
||||
result = self.apply('install', official_archive(plugin), plugin_id=89,
|
||||
folder=plugin['folder'], version=plugin['version'])
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(error, result.stderr)
|
||||
self.assertFalse((self.folder.parent / plugin['folder']).exists())
|
||||
plugin = next(p for p in PLUGINS if p['id'] == 96)
|
||||
data = io.BytesIO(official_archive(plugin))
|
||||
with zipfile.ZipFile(data, 'a') as changed:
|
||||
original_main = changed.read('simple-timer/main.py')
|
||||
changed.writestr('simple-timer/new-version.txt', 'unrecognized new payload')
|
||||
result = self.apply('install', data.getvalue(), plugin_id=96,
|
||||
folder=plugin['folder'], version=plugin['version'])
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
folder = self.folder.parent / plugin['folder']
|
||||
self.assertEqual((folder / 'main.py').read_bytes(), original_main)
|
||||
self.assertEqual(json.loads((folder / '.frame-control-store.json').read_text()).get('compatShims', []), [])
|
||||
|
||||
def test_exact_shotty_archive_gets_isolated_click_import(self):
|
||||
from framey_official import PLUGINS, archive as official_archive
|
||||
|
||||
@@ -26,8 +26,6 @@ class HeadsetBundle(unittest.TestCase):
|
||||
self.assertIn('ui/headset.html', names)
|
||||
self.assertIn('ui/frame_headset_runtime.py', names)
|
||||
self.assertIn('ui/server.py', names)
|
||||
for name in ('aarch64.whl', 'x86_64.whl', 'LICENSE.txt'):
|
||||
self.assertIn('ui/vendor/psutil-7.2.2/'+name, names)
|
||||
self.assertFalse(any('\\' in name for name in names))
|
||||
self.assertFalse(any('__pycache__' in name for name in names))
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Frame battery names are not the BAT/battery names psutil searches."""
|
||||
import sandbox # noqa: F401
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'ui'))
|
||||
import framey_battery
|
||||
|
||||
|
||||
class FrameBattery(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
self.battery = self.root/'max1720x_bat_7-36'
|
||||
self.battery.mkdir()
|
||||
self.psutil = SimpleNamespace(sensors_battery=lambda: None,
|
||||
POWER_TIME_UNLIMITED=-2, POWER_TIME_UNKNOWN=-1)
|
||||
self.patch = patch.dict(sys.modules, {'psutil': self.psutil})
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def files(self, **values):
|
||||
for name, value in values.items():
|
||||
(self.battery/name).write_text(str(value))
|
||||
|
||||
def test_frame_name_uses_type_capacity_and_status_without_writes(self):
|
||||
self.files(type='Battery', capacity=99, status='Full')
|
||||
result = framey_battery.sensors_battery(self.root)
|
||||
self.assertEqual((result.percent, result.power_plugged, result.secsleft), (99, True, -2))
|
||||
self.assertEqual((self.battery/'capacity').read_text(), '99')
|
||||
|
||||
def test_time_to_empty_is_seconds_and_missing_time_stays_unknown(self):
|
||||
self.files(type='Battery', capacity=42, status='Discharging', time_to_empty_now=1234)
|
||||
self.assertEqual(framey_battery.sensors_battery(self.root).secsleft, 1234)
|
||||
(self.battery/'time_to_empty_now').unlink()
|
||||
self.assertEqual(framey_battery.sensors_battery(self.root).secsleft, -1)
|
||||
|
||||
def test_unknown_charge_state_is_not_claimed_as_plugged(self):
|
||||
self.files(type='Battery', capacity=42, status='Unknown')
|
||||
result = framey_battery.sensors_battery(self.root)
|
||||
self.assertIsNone(result.power_plugged)
|
||||
self.assertEqual(result.secsleft, -1)
|
||||
|
||||
def test_native_psutil_result_is_preserved_and_invalid_supplies_are_ignored(self):
|
||||
native = object()
|
||||
self.psutil.sensors_battery = lambda: native
|
||||
self.assertIs(framey_battery.sensors_battery(self.root), native)
|
||||
self.psutil.sensors_battery = lambda: None
|
||||
for values in [dict(type='USB', capacity=99, status='Charging'),
|
||||
dict(type='Battery', capacity=101, status='Full'),
|
||||
dict(type='Battery', capacity=50, status='Full', present=0)]:
|
||||
self.files(**values)
|
||||
self.assertIsNone(framey_battery.sensors_battery(self.root))
|
||||
self.assertIsNone(framey_battery.sensors_battery(self.root/'missing'))
|
||||
@@ -59,16 +59,15 @@ class Compatibility(unittest.TestCase):
|
||||
frame_decky_store.change(no_ssh, 'install', 42)
|
||||
download.assert_not_called()
|
||||
|
||||
def test_old_deckyspy_copy_does_not_inherit_fixed_install_status(self):
|
||||
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 89)
|
||||
installed = {'plugins': [dict(storeId=89, version=plugin['version'], sha256=plugin['sha256']),
|
||||
dict(storeId=89, version=plugin['version'], sha256=plugin['sha256'],
|
||||
compatShims=['deckyspy-psutil-and-battery'])]}
|
||||
def test_old_simple_timer_copy_requires_lifecycle_reinstall(self):
|
||||
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 96)
|
||||
installed = {'plugins': [dict(storeId=96, version=plugin['version'], sha256=plugin['sha256']),
|
||||
dict(storeId=96, version=plugin['version'], sha256=plugin['sha256'],
|
||||
compatShims=['simple-timer-lifecycle'])]}
|
||||
result = frame_decky.status(lambda *a, **k: json.dumps(installed))['plugins']
|
||||
self.assertEqual(result[0]['compatibility']['status'], 'partial')
|
||||
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
|
||||
self.assertEqual(result[1]['compatibility']['status'], 'works')
|
||||
self.assertEqual(compatibility.lookup(89, plugin['sha256'], plugin['version'])['status'], 'works')
|
||||
|
||||
def test_missing_audit_data_is_unverified(self):
|
||||
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
|
||||
|
||||
@@ -19,7 +19,7 @@ class RuntimeCoverage(unittest.TestCase):
|
||||
self.assertEqual(len(cases), len({case['id'] for case in cases}))
|
||||
for case in cases:
|
||||
self.assertIn(case['id'], official)
|
||||
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor'))
|
||||
self.assertIn(case['probe'], ('mpris', 'screenshots', 'timer-lifecycle'))
|
||||
|
||||
def test_new_shim_without_runtime_case_fails_coverage(self):
|
||||
import frame_decky_store
|
||||
|
||||
@@ -37,12 +37,14 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
|
||||
|
||||
|
||||
class PublishRelease(unittest.TestCase):
|
||||
def run_script(self, releases, *args, tags="v9.8.7"):
|
||||
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "releases.json").write_text(json.dumps(releases))
|
||||
(d / "gates.json").write_text(json.dumps(gates or {}))
|
||||
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
||||
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
|
||||
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
|
||||
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
|
||||
FAKEGH_GATES=str(d / "gates.json"))
|
||||
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
|
||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
||||
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
|
||||
@@ -120,6 +122,36 @@ 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()
|
||||
+96
-72
@@ -3,12 +3,10 @@
|
||||
Only Frame Control owned plugin folders can be updated or removed. The store
|
||||
digest validates transport, not plugin trust; store plugins run as the user.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import urllib.request
|
||||
@@ -133,31 +131,8 @@ def archive_info(data, expected_hash, expected_name):
|
||||
return folder
|
||||
|
||||
|
||||
DECKYSPY_DIGEST = '2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7'
|
||||
PSUTIL_DIGESTS = {
|
||||
'x86_64': '076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
|
||||
'aarch64': 'b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e',
|
||||
}
|
||||
|
||||
|
||||
def apply_script(plugin_id, digest):
|
||||
"""Send verified offline dependencies only for the exact managed plugin."""
|
||||
if plugin_id != 89 or digest != DECKYSPY_DIGEST:
|
||||
return APPLY
|
||||
wheels = {}
|
||||
for arch, expected in PSUTIL_DIGESTS.items():
|
||||
data = (Path(__file__).parent/'vendor/psutil-7.2.2'/f'{arch}.whl').read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != expected:
|
||||
raise ValueError('Bundled psutil dependency SHA-256 mismatch')
|
||||
wheels[arch] = base64.b64encode(data).decode('ascii')
|
||||
# stdin is Python source, never shell arguments or a shared remote temp file.
|
||||
battery = (Path(__file__).parent/'framey_battery.py').read_text(encoding='utf-8')
|
||||
return ('FRAMEY_BACKEND_WHEELS = '+repr(wheels)+'\n'
|
||||
+'FRAMEY_BACKEND_BATTERY_SOURCE = '+repr(battery)+'\n'+APPLY)
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import base64,fcntl,hashlib,io,json,os,pathlib,platform,shutil,subprocess,sys,zipfile
|
||||
import fcntl,hashlib,json,os,pathlib,shutil,subprocess,sys,zipfile
|
||||
action,token,plugin_id,digest,folder,version,expected_installed = sys.argv[1:]
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
unit=pathlib.Path.home()/'.config/systemd/user/frame-control-decky.service'
|
||||
@@ -234,6 +209,100 @@ try:
|
||||
unpacked=stage/folder
|
||||
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
|
||||
compat_shims=[]
|
||||
# Simple Timer 1.0.0 has annotation-only task state, orphaned
|
||||
# replacement tasks and canceled deadlines that resume on restart.
|
||||
# Preserve its API1 instance convention and upstream license.
|
||||
if int(plugin_id)==96 and digest=='973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312' and folder=='simple-timer':
|
||||
main=unpacked/'main.py'
|
||||
source=main.read_text(encoding='utf-8')
|
||||
start=' async def start_timer('; end=' async def load_recents('
|
||||
if source.count(start)!=1 or source.count(end)!=1: raise RuntimeError('Simple Timer compatibility source changed')
|
||||
a=source.index(start); b=source.index(end,a)
|
||||
source=source[:a]+""" async def _stop_timer(self):
|
||||
# Call only under timer_lock. Joining prevents a replaced task from
|
||||
# updating the new timer or sending a stale completion notification.
|
||||
task, self.timer_task = self.timer_task, None
|
||||
if task is not None and task is not asyncio.current_task():
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def start_timer(self, seconds: int):
|
||||
async with self.timer_lock:
|
||||
await self._stop_timer()
|
||||
recent = await self.settings_getSetting(settings_key_recent_timers, [])
|
||||
await self.settings_setSetting(settings_key_recent_timers, [seconds] + recent[:4])
|
||||
deadline = time.time() + seconds
|
||||
await self.settings_setSetting(settings_key_timer_end, deadline)
|
||||
await self.settings_commit()
|
||||
self.seconds_remaining = seconds
|
||||
self.timer_task = self.loop.create_task(self.timer_handler(datetime.fromtimestamp(deadline)))
|
||||
await self.load_recents()
|
||||
await self.load_remaining_seconds()
|
||||
|
||||
async def timer_handler(self, timer_end: datetime):
|
||||
while True:
|
||||
remaining = timer_end.timestamp() - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
await asyncio.sleep(min(5, remaining))
|
||||
self.seconds_remaining = max(0, timer_end.timestamp() - time.time())
|
||||
await self.load_remaining_seconds()
|
||||
async with self.timer_lock:
|
||||
if self.timer_task is not asyncio.current_task():
|
||||
return
|
||||
self.timer_task = None
|
||||
self.seconds_remaining = 0
|
||||
await self.settings_setSetting(settings_key_timer_end, None)
|
||||
await self.settings_commit()
|
||||
await self.load_remaining_seconds()
|
||||
subtle = await self.settings_getSetting(settings_key_subtle_mode, False)
|
||||
if remaining <= -10:
|
||||
await decky.emit("simple_timer_event", "Your timer has expired!", True)
|
||||
else:
|
||||
await decky.emit("simple_timer_event", "Your session has ended!", subtle)
|
||||
|
||||
async def cancel_timer(self):
|
||||
async with self.timer_lock:
|
||||
await self._stop_timer()
|
||||
self.seconds_remaining = 0
|
||||
await self.settings_setSetting(settings_key_timer_end, None)
|
||||
await self.settings_commit()
|
||||
await self.load_remaining_seconds()
|
||||
|
||||
"""+source[b:]
|
||||
start=' async def _main('; end=' async def _uninstall('
|
||||
if source.count(start)!=1 or source.count(end)!=1: raise RuntimeError('Simple Timer compatibility source changed')
|
||||
a=source.index(start); b=source.index(end,a)
|
||||
source=source[:a]+""" async def _main(self):
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.timer_task = None
|
||||
self.timer_lock = asyncio.Lock()
|
||||
self.seconds_remaining = 0
|
||||
await self.settings_read()
|
||||
deadline = await self.settings_getSetting(settings_key_timer_end, None)
|
||||
if deadline is not None:
|
||||
self.seconds_remaining = max(0, deadline - time.time())
|
||||
if self.seconds_remaining > 0:
|
||||
# Resume the exact deadline without adding another recent timer.
|
||||
self.timer_task = self.loop.create_task(self.timer_handler(datetime.fromtimestamp(deadline)))
|
||||
else:
|
||||
await self.cancel_timer()
|
||||
await self.load_recents()
|
||||
await self.load_subtle_mode()
|
||||
decky.logger.info("Simple Timer has been initialised.")
|
||||
|
||||
async def _unload(self):
|
||||
async with self.timer_lock:
|
||||
# Backend unload is a pause; only explicit cancel clears the deadline.
|
||||
await self._stop_timer()
|
||||
decky.logger.info("Simple Timer has been unloaded.")
|
||||
|
||||
"""+source[b:]
|
||||
main.write_text(source,encoding='utf-8')
|
||||
compat_shims.append('simple-timer-lifecycle')
|
||||
# Shotty 0.1.0 imports click.get_app_dir but never calls it. Its
|
||||
# official ZIP omits click and Decky's bundled Python lacks it.
|
||||
# Supply the Linux function only inside this exact plugin's
|
||||
@@ -253,51 +322,6 @@ 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')
|
||||
# DeckySpy's official archive omits its required psutil dependency.
|
||||
# The BSD wheels are packaged offline by Frame Control and selected
|
||||
# for the native loader; no pip, network or system path changes.
|
||||
if int(plugin_id)==89 and digest=='2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7' and folder=='decky-spy':
|
||||
arch=platform.machine()
|
||||
pins={
|
||||
'x86_64':'076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
|
||||
'aarch64':'b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e',
|
||||
}
|
||||
encoded=globals().get('FRAMEY_BACKEND_WHEELS',{}).get(arch)
|
||||
if arch not in pins or not encoded:
|
||||
raise RuntimeError('Missing native psutil dependency for DeckySpy')
|
||||
wheel_data=base64.b64decode(encoded,validate=True)
|
||||
if hashlib.sha256(wheel_data).hexdigest()!=pins[arch]:
|
||||
raise RuntimeError('DeckySpy psutil dependency SHA-256 mismatch')
|
||||
modules=unpacked/'py_modules'
|
||||
for name in ('psutil','psutil-7.2.2.dist-info'):
|
||||
if (modules/name).exists(): raise RuntimeError('DeckySpy dependency collides with archive')
|
||||
with zipfile.ZipFile(io.BytesIO(wheel_data)) as wheel:
|
||||
for member in wheel.infolist():
|
||||
path=pathlib.PurePosixPath(member.filename)
|
||||
if (path.is_absolute() or '..' in path.parts or not path.parts or
|
||||
path.parts[0] not in ('psutil','psutil-7.2.2.dist-info')):
|
||||
raise RuntimeError('Unsafe psutil dependency member')
|
||||
if member.is_dir(): continue
|
||||
payload=wheel.read(member)
|
||||
if payload[:4]==b'\x7fELF' and int.from_bytes(payload[18:20],'little')!=({'x86_64':62,'aarch64':183}[arch]):
|
||||
raise RuntimeError('Incorrect psutil native architecture')
|
||||
dest=modules/member.filename
|
||||
dest.parent.mkdir(parents=True,exist_ok=True)
|
||||
dest.write_bytes(payload)
|
||||
dest.chmod(0o600)
|
||||
compat=modules/'framey_battery.py'
|
||||
if compat.exists(): raise RuntimeError('DeckySpy battery module collides with archive')
|
||||
battery_source=globals().get('FRAMEY_BACKEND_BATTERY_SOURCE')
|
||||
if not isinstance(battery_source,str) or not battery_source:
|
||||
raise RuntimeError('Missing DeckySpy battery compatibility source')
|
||||
compat.write_text(battery_source,encoding='utf-8')
|
||||
main=unpacked/'main.py'
|
||||
source=main.read_text(encoding='utf-8')
|
||||
old=' battery = psutil.sensors_battery()\n'
|
||||
if source.count(old)!=1: raise RuntimeError('DeckySpy battery source changed')
|
||||
source=source.replace(old,' from framey_battery import sensors_battery\n battery = sensors_battery()\n')
|
||||
main.write_text(source,encoding='utf-8')
|
||||
compat_shims.append('deckyspy-psutil-and-battery')
|
||||
# These legacy backends launch system dbus-send (1.1.6 via /bin/sh).
|
||||
# Decky's PyInstaller LD_LIBRARY_PATH selects bundled libtinfo built
|
||||
# against newer glibc than Frame OS. Restore its saved system path
|
||||
@@ -413,7 +437,7 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
|
||||
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
|
||||
report({'install':'Installing', 'update':'Updating', 'remove':'Removing'}[action] + ' plugin on Frame', 80)
|
||||
return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]} {expected_installed}',
|
||||
stdin=apply_script(plugin_id, plugin["sha256"]), timeout=90))
|
||||
stdin=APPLY, timeout=90))
|
||||
finally:
|
||||
if data is not None:
|
||||
try: ssh('rm -f ' + remote, timeout=10)
|
||||
|
||||
+1
-4
@@ -163,10 +163,7 @@ def bundle():
|
||||
for path in sorted(folder.rglob('*')):
|
||||
if not path.is_file() or path.is_symlink() or '__pycache__' in path.parts:
|
||||
continue
|
||||
native_dependency = path.relative_to(ROOT).as_posix() in (
|
||||
'ui/vendor/psutil-7.2.2/aarch64.whl', 'ui/vendor/psutil-7.2.2/x86_64.whl')
|
||||
if (path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md')
|
||||
and path.name != 'ssh' and not native_dependency):
|
||||
if path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md') and path.name not in ('ssh',):
|
||||
continue
|
||||
data = path.read_bytes()
|
||||
info = tarfile.TarInfo(path.relative_to(ROOT).as_posix())
|
||||
|
||||
@@ -6985,14 +6985,13 @@
|
||||
"https://github.com/Seraphli/decky-spy",
|
||||
"https://github.com/Seraphli/decky-spy.git"
|
||||
],
|
||||
"status": "works",
|
||||
"reason": "Works for read-only system monitoring: live CPU, memory, uptime, processes, interfaces and Frame battery render in local Chromium with actual Decky 3.3.0 ARM64 on the Frame; silent warning and private settings save/reopen pass. Exact managed install adds pinned BSD psutil wheels and a battery-name fallback. Existing copies need update/reinstall. Automatic alerts, resume/game-time warnings, sound and worn VR remain unverified; monitoring requires an open panel.",
|
||||
"status": "partial",
|
||||
"reason": "Initializer succeeds; account tokens and external service integration were not exercised.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"fake-Frame",
|
||||
"local browser",
|
||||
"real Frame SSH"
|
||||
"fake-Frame"
|
||||
],
|
||||
"browser": {
|
||||
"id": 89,
|
||||
@@ -7012,8 +7011,8 @@
|
||||
"accepted": true,
|
||||
"folder": "decky-spy"
|
||||
},
|
||||
"backend": "Python; managed psutil 7.2.2 stable-ABI native amd64/ARM64 dependency",
|
||||
"hardware": "Read-only Linux /proc and network-interface metrics; Frame battery read by power_supply type=Battery. No Deck-specific hardware writes.",
|
||||
"backend": "Python",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
"uiSurfaces": [
|
||||
"toasts"
|
||||
],
|
||||
@@ -7027,18 +7026,7 @@
|
||||
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
|
||||
"mode": "synthetic nonempty running/library replay; backend and network denied",
|
||||
"frontendReady": true
|
||||
},
|
||||
"backendFollowUp": {
|
||||
"evidence": "docs/evidence/framey-deckyspy-2026-10-26.json",
|
||||
"mode": "actual Decky 3.3.0 ARM64 with private removable service under shared lock; local opaque Chromium through trusted SSH RPC pipe",
|
||||
"compatShims": [
|
||||
"deckyspy-psutil-and-battery"
|
||||
],
|
||||
"existingInstallRequires": "explicit managed update or reinstall"
|
||||
},
|
||||
"requiredShims": [
|
||||
"deckyspy-psutil-and-battery"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 90,
|
||||
@@ -7624,8 +7612,8 @@
|
||||
"https://github.com/decktools-xyz/simple-timer",
|
||||
"https://github.com/decktools-xyz/simple-timer.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified.",
|
||||
"status": "works",
|
||||
"reason": "Verified for silent in-panel session timers: Chromium Begin/Cancel/countdown/toast with real ARM64 Decky 3.3.0; repaired cancel, replace, completion and active/canceled/expired restarts pass actual loader RPC/events. Update/reinstall required. Sound and suspend unsupported; alerts require an open panel; worn VR unverified.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
@@ -7673,6 +7661,22 @@
|
||||
"subscriptionsAfterPanelClose": 0
|
||||
},
|
||||
"method": "local Chromium + real Frame SSH; private actual Decky 3.3.0 ARM64 service"
|
||||
},
|
||||
"requiredShims": [
|
||||
"simple-timer-lifecycle"
|
||||
],
|
||||
"lifecycle": {
|
||||
"evidence": "docs/evidence/framey-simple-timer-2026-10-26.md",
|
||||
"actualDeckyVersion": "3.3.0",
|
||||
"instanceApi": 1,
|
||||
"backendRestarts": [
|
||||
"active",
|
||||
"canceled",
|
||||
"expired"
|
||||
],
|
||||
"realFrame": true,
|
||||
"localBrowser": true,
|
||||
"fakeFrame": true
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Read Linux batteries psutil misses because their names lack BAT/battery.
|
||||
|
||||
Copied only into exact managed DeckySpy installs. No sysfs writes or estimates.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def sensors_battery(power_supply=Path('/sys/class/power_supply')):
|
||||
import psutil
|
||||
battery = psutil.sensors_battery()
|
||||
if battery is not None:
|
||||
return battery
|
||||
try:
|
||||
supplies = sorted(power_supply.iterdir())
|
||||
except OSError:
|
||||
return None
|
||||
for supply in supplies:
|
||||
try:
|
||||
if (supply/'type').read_text().strip() != 'Battery':
|
||||
continue
|
||||
present = supply/'present'
|
||||
if present.exists() and present.read_text().strip() == '0':
|
||||
continue
|
||||
percent = float((supply/'capacity').read_text().strip())
|
||||
if not 0 <= percent <= 100:
|
||||
continue
|
||||
status = (supply/'status').read_text().strip().lower()
|
||||
plugged = True if status in ('charging', 'full') else False if status == 'discharging' else None
|
||||
secsleft = psutil.POWER_TIME_UNLIMITED if plugged else psutil.POWER_TIME_UNKNOWN
|
||||
if plugged is False:
|
||||
try:
|
||||
# Linux power_supply time_to_empty_now is already seconds.
|
||||
value = int((supply/'time_to_empty_now').read_text())
|
||||
if value >= 0:
|
||||
secsleft = value
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return SimpleNamespace(percent=percent, secsleft=secsleft, power_plugged=plugged)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return None
|
||||
Vendored
-29
@@ -1,29 +0,0 @@
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2009, Jay Loden, Dave Daeschler, Giampaolo Rodola
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of the psutil authors nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Vendored
-20
@@ -1,20 +0,0 @@
|
||||
# psutil 7.2.2
|
||||
|
||||
Unmodified upstream PyPI wheels, retrieved 26 October 2026. BSD-3-Clause;
|
||||
see LICENSE.txt and the license retained in each wheel.
|
||||
Source: https://github.com/giampaolo/psutil/tree/release-7.2.2
|
||||
Release metadata: https://pypi.org/pypi/psutil/7.2.2/json
|
||||
|
||||
| Local file | Upstream wheel | SHA-256 |
|
||||
| --- | --- | --- |
|
||||
| x86_64.whl | psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl | 076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 |
|
||||
| aarch64.whl | psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl | b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e |
|
||||
|
||||
Only exact ID 89 / DeckySpy 0.6.7 receives this missing dependency, isolated
|
||||
inside its py_modules directory, alongside Framey’s battery-name fallback.
|
||||
The fallback reads only Linux power_supply files identified as type Battery;
|
||||
it leaves native psutil results intact and never writes sysfs. The host and remote installer both verify
|
||||
these digests, and the remote selects the native ELF architecture. No pip or
|
||||
runtime download runs on the Frame. The stable ABI is exercised with actual
|
||||
Decky 3.3.0 rather than assuming compatibility with its bundled Python.
|
||||
Existing plugin copies require an explicit managed update or reinstall.
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Reference in new issue
Block a user