Compare commits

..
37 changed files with 345 additions and 524 deletions

No files matched your search

-1
View File
@@ -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 |
-3
View File
@@ -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",
+2 -2
View File
@@ -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
View File
@@ -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,37 @@
# v0.4.49 candidate gate, 26 October 2026
The version bump at `6247dfb` follows the merged Music Control repair
([#338](https://github.com/saphid/frame-control/pull/338)), the real Decky
runtime gate ([#343](https://github.com/saphid/frame-control/pull/343)), and
the fail-closed publisher ([#344](https://github.com/saphid/frame-control/pull/344)).
The published v0.4.48 draft remains withdrawn. This candidate is not a
published update.
## Locked real Frame smoke
At 22:48 UTC, `scripts/frame-smoke.sh` ran from exact version-bumped source
commit `6247dfb` on SteamOS 0.5.6, build `20261022.6153909`. It acquired a
unique `/tmp/frame-test.lock` owner before reading status. SteamVR activity was
**3 (standby)**, proximity raw **3.0625**, and battery **99%**. The result was
**12 passed / one optional x86_64 launch skipped / zero failed**. The x86_64
title could not launch because Steam Linux Runtime 4 was not installed; no
x86_64 launch is claimed. ARM64 and Windows/Proton test titles installed,
launched and were removed. The script's owned cleanup passed and exited 0.
The private result is
`tests/smoke/results/2026-10-26T224816Z-20261022.6153909.json`.
A separate read-only audit acquired a fresh unique lock after the smoke. It
found activity **3**, proximity raw **3.0**, battery **99%**, no `fc_smoke_*`
or `fc-smoke-*` title folders and no matching test processes. The audit
released only its own lock. Neither run woke the headset or ran the worn
interaction script. No wearer was confirmed; official-plugin wearer
visibility, physical controller input, VR keyboard and optical comfort remain
unverified.
## Remaining publication gates
The final candidate head needs green PR CI, native amd64 and ARM64 Decky
runtime checks, all four installer previews, post-merge exact-commit main CI,
tagged builds of all ten installers, checksums, independent package
verification, publisher dry run and public update-channel readback. A gate
that fails, is cancelled or remains pending holds publication.
+59
View File
@@ -0,0 +1,59 @@
# v0.4.49 tagged release verification, 26 October 2026
The annotated `v0.4.49` tag dereferences to merged main commit
`38db6a284696c3bb86bf97f6b853af7e97681701`. This is the same tree as
the reviewed [candidate PR #348](https://github.com/saphid/frame-control/pull/348).
Its [main checks run](https://github.com/saphid/frame-control/actions/runs/40417275314)
passed all seven jobs, including e2e, iOS, macOS, Windows and Linux.
The [native Decky 3.3.0 run](https://github.com/saphid/frame-control/actions/runs/40417275256)
passed on AMD64 and ARM64. The regression gate includes an expected failure
for v0.4.48's bad legacy calling convention.
The exact-tag [release workflow](https://github.com/saphid/frame-control/actions/runs/40418177330)
passed all six jobs on its first attempt: draft, Linux, Windows, Apple Silicon
Mac, Intel Mac, and checksums. No tagged build failed, was cancelled, or was
skipped. The draft contained exactly ten installers and `SHA256SUMS` before
publication. Every asset had a GitHub SHA-256 digest. A fresh download of
`SHA256SUMS` had SHA-256
`8a04f81f3b1efea9903e9acbba2c61b67f275869c160e0c6fa1d388a4803b42a`;
it matched a list regenerated from the ten GitHub installer digests byte for
byte. All ten installers were independently downloaded and passed
`sha256sum -c SHA256SUMS`.
## Independent package checks
| Package | Check |
| --- | --- |
| Linux x86_64 AppImage | SHA-256 `0ef192e6f13aed39469392c22b971b311d911a0bebae16cbbabbfa729b95ced0`; isolated Xvfb launch returned HTTP 200 with `FrameControl/1`, then its server stopped. The first local launch attempt hit the workspace sandbox's `Operation not permitted` restriction; the same downloaded bytes passed outside that sandbox. Tagged Linux CI also launched the AppImage. |
| Linux arm64 DEB | SHA-256 `a8e448b8b8f10b2813546cb9d8fea6bbe424e14dc3a739df86daa69bfd4023c1`; declares version 0.4.49 and arm64. |
| Linux amd64 DEB | SHA-256 `10bb554cc761c61c99d059ae3601f4889187d7f18bdc8f9eb3cf768822b5765d`; declares version 0.4.49 and amd64. |
| Mac arm64 ZIP | SHA-256 `c43c1017b3d244fcf59ab8f5bd35dbad17af4562b2aca3baf65908814f2ae4c3`; ZIP CRC, bundle version 0.4.49 and main Mach-O arm64 CPU passed. |
| Mac x64 ZIP | SHA-256 `0dd838a1322107928893ff5efce817719867a5db1af2fa09124b6bb03018a19d`; ZIP CRC, bundle version 0.4.49 and main Mach-O x86_64 CPU passed. |
| Windows x64 Setup | SHA-256 `386d99fd15314e1caef577f7cdf21e9e9f572d1c0dbaa65c369e5a5b0c047d31`; downloaded file is a PE/Nullsoft installer. Tagged Windows CI installed it and checked its packaged resources and Python libraries. |
| Windows x64 ZIP | SHA-256 `2c201a8d1115dd1a07ba7b9293f7e05c319bd802f574a4b2f9565268a7f5cf8b`; ZIP CRC passed. |
The remaining three packages (both Mac DMGs and the ARM64 AppImage) also
matched `SHA256SUMS`; tagged CI built and checked their platform bundles.
Physical launch of these exact Mac and Windows installers was not performed.
## Publication and real Frame scope
The [candidate evidence](v0449-candidate-2026-10-26.md) records a unique-lock
real Frame smoke on the version-bumped source: **12 passed, one optional
x86_64 launch skipped, zero failed**, followed by an independent locked audit
with no test-title folders or processes. The optional runtime was absent; no
x86_64 launch is claimed. SteamVR stayed in standby, no wearer was confirmed,
and neither a worn test nor physical controller check was run.
`scripts/publish-release.sh --dry-run v0.4.49` passed the exact main, native
Decky and tagged release job gates, ten installer hashes, regenerated
`SHA256SUMS`, draft notes and update manifest. The real publisher repeated
those gates, attached `update.json`, and published
[v0.4.49](https://github.com/saphid/frame-control/releases/tag/v0.4.49) at
**23:25:45 UTC**. GitHub's latest-release API returned `draft=false`,
`prerelease=false`, ten installers, `SHA256SUMS` and `update.json`. A fresh
public latest-download of `update.json` reported version 0.4.49, the correct
page, ten hashed assets, the v0.4.48 repair note and the worn-VR limit.
The v0.4.48 release remains a withdrawn draft; its notice points to v0.4.49.
Existing v0.4.48 installations do not auto-downgrade, and affected installed
Music Control plugins need managed Update or Remove and reinstall.
+15 -1
View File
@@ -74,10 +74,13 @@ none of these frontend controls has passed a worn-headset check yet.
| API v1/v2 `addEventListener` / `removeEventListener` | Plugin-scoped real backend events while panel is open; bounded queue, about one-second polling, subscription expiry | Real ARM64 Simple Timer countdown, start/cancel and completion toast via local Chromium; scope/bounds/reconnect tests. No closed-panel notifications. |
| `toaster.toast` | Visible toast inside the plugin panel; plain text or React nodes for title, body, icon, logo and subtext; `dismiss` and optional `onClick` | Host unit test; no Steam system notification or worn VR check |
| `localStorage` | Synchronous, per-plugin cache backed by the Frame Control panel origin. Up to 64 keys, 64 KiB per value and 128 KiB total; `clear()` removes only this plugin's cache. Falls back to memory if panel storage is denied. | Parent/iframe round-trip and reopen in the local host test; real headset persistence remains unverified. |
| `SteamClient.Storage.GetJSON` / `SetObject` | Uses the same bounded per-plugin cache; a missing key rejects with `Not found`. It cannot read Steam account or global storage. | Host contract tests and local Chromium settings views; headset persistence after reopen remains unverified. |
| IndexedDB / localForage | IndexedDB is deliberately absent inside the opaque iframe so feature detection chooses the bounded localStorage driver. Plugins requiring IndexedDB directly remain unsupported. | HLTB official frontend in local Chromium: its previous `IDBFactory.open` SecurityError disappeared. No live HLTB data request was run. |
| `routerHook.addRoute` / `removeRoute`, `Navigation.Navigate` / `NavigateBack`, `useParams` | Local routes inside this plugin panel, with named segments such as `/plugin/game/:appid/:view?`; `useParams()` reads the current segment values | Host unit test; VR navigation unverified |
| `routerHook.addPatch`, global components | No-op; cannot patch Steam routes | Not applicable |
| `findModuleChild`, `Navigation.CloseSideMenus` | Steam module lookup returns `null`; close returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 entry point and shallow settings view in local host test. Steam library patch and live HLTB search remain unverified. |
| `findModule`, `findModuleChild`, `findModuleExport`, class lookup and module mapping | Searches a finite registry of Framey controls and CSS maps. Unknown Steam modules return `undefined` or an empty mapping. The optional UI-audio export cannot play sound. | Host contract tests; pinned TabMaster 2.15.1 QAM and documentation render in local Chromium. Steam library editing and worn VR remain unverified. |
| `Navigation.CloseSideMenus` | Returns to this plugin's main panel | Pinned HLTB for Deck 2.0.12 shallow settings view in local host test. Live HLTB search remains unverified. |
| `Focusable.onActivate`, `showModal`, `ConfirmModal` | Pointer and Enter/Space activation, disabled state, and local confirmation dialogs with cancel/close and nested-dialog focus | Host tests and official AlarMe 1.6.3 timer/preset controls with a real ARM64 backend in local Chromium. Sound, suspend and worn VR remain unverified. |
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
| `fetchNoCors`, file picker, root calls | Unavailable | Not supported by Framey |
@@ -88,6 +91,17 @@ Optional route segments must be at the end. Framey accepts local path segments
only; query strings, external URLs, Steam library routes and duplicate parameter
names are rejected. Removing an active route returns to the plugin panel.
Toasts stay inside the panel, so keep essential errors in the plugin UI too.
Treat module lookup results as Framey-local exports. Do not depend on Steam
webpack IDs, function-source signatures, or Steam-only CSS class names; a
lookup that works in the Steam Deck QAM can still return `undefined` here.
Use a local control or a visible unsupported message when it does. For a
confirmation, call `showModal` with a `ConfirmModal` and keep any irreversible
operation behind its OK action. `Focusable.onActivate` accepts pointer and
Enter/Space input in Framey, but only a worn-headset test can establish how a
physical controller reaches it. See the
[module lookup evidence](evidence/framey-module-lookup-2026-10-26.md) and
[dialog control evidence](evidence/framey-dialog-controls-2026-10-26.md) for
the exact tested official versions.
An API v1 manifest does not guarantee a working Framey panel. Test the exact
store archive and do not assume SteamClient app details, library patches, or
destructive actions work in VR. The
+4 -4
View File
@@ -4,7 +4,7 @@ Snapshot: 2026-10-26T06:10:58.537029+00:00; 116 current visible official plugins
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
**Works: 5 · Partial: 51 · Broken: 49 · N/A: 11.**
**Works: 4 · Partial: 52 · Broken: 49 · N/A: 11.**
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
@@ -29,7 +29,7 @@ A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional Line truncated
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSS, the optional Line truncated
## Every current plugin
@@ -97,7 +97,7 @@ 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 |
@@ -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. |
+15 -1
View File
@@ -34,13 +34,27 @@ count. So a build reaches people only when you publish it, after testing it.
4. Publish:
Confirm the exact tag commit's main CI and e2e, the locked real Frame smoke,
release verification, independent package checks, and all four tagged
installer builds have passed. Hold the release if any gate is failed,
cancelled, skipped, still running, or unverified. The publisher also reads
the exact commit's GitHub CI, native Decky runtime gate, and tagged build
job records and refuses to
publish unless every required job succeeded; it cannot prove physical
headset or installer behavior on its own.
```sh
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
scripts/publish-release.sh v0.4.0
```
The script checks that the tag is on GitHub and that all ten installers
The script checks that the tag is on GitHub, that exact-commit main CI
(including e2e), native amd64/ARM64 Decky runtime checks, and the tagged
release workflow are green with no failed,
cancelled, skipped or running jobs, 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
View File
@@ -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
+1 -6
View File
@@ -85,12 +85,7 @@ def render(data):
'Exact managed installs also restore the system library path for D-Bus subprocesses, repairing a real Frame OS libtinfo/glibc failure in MusicControl 1.1.6. '
'Both official entries pass transport/seek/volume, Info and reopen in local Chromium with actual Decky 3.3.0 amd64 and a private silent MPRIS service. '
'Actual ARM64 Decky on the Frame passes read-only empty-player discovery for both. Existing copies need remove/reinstall or an explicit managed update. '
'Actual media playback on ARM64, artwork and worn VR remain unverified; both stay partial. '
'A [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.', '',
'Actual media playback on ARM64, artwork and worn VR remain unverified; both stay partial.', '',
'## Every current plugin', '',
'The API column shows manifest / actual bundled frontend API. Many manifests say v1 while their bundle connects API2. '
'Missing manifest API is labelled legacy; an unrecognized bundle is unknown. Globals and hardware are static signals, not exhaustive dependencies.', '',
+11
View File
@@ -31,6 +31,11 @@ trap 'rm -rf "$tmp"' EXIT
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
# A complete asset list is insufficient: a cancelled installer build can leave
# old uploaded bytes behind. Check the exact tagged commit's main/e2e jobs and
# the latest exact-tag release attempt before any release mutation.
python3 "$(dirname "$0")/verify-release-gates.py" "$tag"
# Every release, drafts included, one JSON object per line.
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
# The release whose tag_name is the tag; failing that, the one untagged-... draft
@@ -56,6 +61,12 @@ json.dump(r, sys.stdout)
EOF
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
python3 - "$tmp/release.json" <<'EOF'
import json, sys
release = json.load(open(sys.argv[1]))
if (release.get("body") or "").lstrip().startswith("> **WITHDRAWN"):
sys.exit("not publishing: this release is marked WITHDRAWN")
EOF
missing=""
for name in $expected; do
+109
View File
@@ -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}")
+3 -20
View File
@@ -1,22 +1,5 @@
[
{
"id": 12,
"shim": "music-control-system-dbus-env",
"probe": "mpris"
},
{
"id": 111,
"shim": "music-control-system-dbus-env",
"probe": "mpris"
},
{
"id": 53,
"shim": "shotty-click-app-dir",
"probe": "screenshots"
},
{
"id": 89,
"shim": "deckyspy-psutil-and-battery",
"probe": "system-monitor"
}
{"id": 12, "shim": "music-control-system-dbus-env", "probe": "mpris"},
{"id": 111, "shim": "music-control-system-dbus-env", "probe": "mpris"},
{"id": 53, "shim": "shotty-click-app-dir", "probe": "screenshots"}
]
+1 -17
View File
@@ -78,7 +78,7 @@ 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']
@@ -164,22 +164,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
-4
View File
@@ -196,10 +196,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
View File
@@ -13,6 +13,7 @@ import os
import shutil
import subprocess
import sys
from urllib.parse import parse_qs
args = sys.argv[1:]
with open(os.environ["FAKEGH_LOG"], "a") as f:
@@ -49,6 +50,18 @@ while i < len(args):
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
gate = json.load(open(os.environ["FAKEGH_GATES"])) if os.environ.get("FAKEGH_GATES") else {}
commit = gate.get("tag_commit", "a" * 40)
tag_object = "b" * 40
main_jobs = ("checks", "e2e", "ios", "wireless-windows",
"server-tests (ubuntu-latest, 3.13)", "server-tests (macos-latest, 3.12)",
"server-tests (windows-latest, 3.12)")
release_jobs = ("draft", "checksums",
"build (windows-latest, dist:win, app/dist/*.exe app/dist/*.zip)",
"build (ubuntu-latest, dist:linux, app/dist/*.AppImage app/dist/*.deb)",
"build (macos-15-intel, dist:mac:x64, app/dist/*.dmg app/dist/*.zip)",
"build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)")
decky_jobs = ("decky-runtime (ubuntu-latest)", "decky-runtime (ubuntu-24.04-arm)")
def answer(response, fallback):
@@ -67,7 +80,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))
Binary file not shown.
-2
View File
@@ -4,8 +4,6 @@ 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': 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'},
+1 -36
View File
@@ -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,41 +161,6 @@ 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):
from framey_official import PLUGINS, archive as official_archive
import platform
plugin = next(p for p in PLUGINS if p['id'] == 89)
result = self.apply('install', official_archive(plugin), plugin_id=89,
folder=plugin['folder'], version=plugin['version'])
self.assertEqual(result.returncode, 0, result.stderr)
folder = self.folder.parent / plugin['folder']
receipt = json.loads((folder / '.frame-control-store.json').read_text())
self.assertEqual(receipt['compatShims'], ['deckyspy-psutil-and-battery'])
modules = folder / 'py_modules'
native = (modules / 'psutil/_psutil_linux.abi3.so').read_bytes()
self.assertEqual(native[:4], b'\x7fELF')
self.assertEqual(int.from_bytes(native[18:20], 'little'),
{'x86_64': 62, 'aarch64': 183}[platform.machine()])
self.assertIn('BSD 3-Clause', (modules / 'psutil-7.2.2.dist-info/LICENSE').read_text())
result = self.apply('remove', plugin_id=89, folder=plugin['folder'])
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(folder.exists())
@unittest.skipUnless(__import__('platform').machine() in ('x86_64', 'aarch64'), 'native Linux wheel architecture')
def test_deckyspy_missing_or_corrupt_payload_leaves_no_installed_copy(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 89)
for prefix, error in [('', 'Missing native psutil'),
("FRAMEY_BACKEND_WHEELS = {'x86_64':'AAAA','aarch64':'AAAA'}\n",
'SHA-256 mismatch')]:
with patch.object(frame_decky_store, 'apply_script', return_value=prefix+frame_decky_store.APPLY):
result = self.apply('install', official_archive(plugin), plugin_id=89,
folder=plugin['folder'], version=plugin['version'])
self.assertNotEqual(result.returncode, 0)
self.assertIn(error, result.stderr)
self.assertFalse((self.folder.parent / plugin['folder']).exists())
def test_exact_shotty_archive_gets_isolated_click_import(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 53)
-2
View File
@@ -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))
-59
View File
@@ -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'))
-11
View File
@@ -59,17 +59,6 @@ class Compatibility(unittest.TestCase):
frame_decky_store.change(no_ssh, 'install', 42)
download.assert_not_called()
def test_old_deckyspy_copy_does_not_inherit_fixed_install_status(self):
plugin = next(p for p in json.loads(compatibility.DATA.read_text())['plugins'] if p['id'] == 89)
installed = {'plugins': [dict(storeId=89, version=plugin['version'], sha256=plugin['sha256']),
dict(storeId=89, version=plugin['version'], sha256=plugin['sha256'],
compatShims=['deckyspy-psutil-and-battery'])]}
result = frame_decky.status(lambda *a, **k: json.dumps(installed))['plugins']
self.assertEqual(result[0]['compatibility']['status'], 'partial')
self.assertIn('Update or reinstall', result[0]['compatibility']['reason'])
self.assertEqual(result[1]['compatibility']['status'], 'works')
self.assertEqual(compatibility.lookup(89, plugin['sha256'], plugin['version'])['status'], 'works')
def test_missing_audit_data_is_unverified(self):
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
self.assertEqual(compatibility.lookup(12, 'a'*64, '1.0')['status'], 'unverified')
+1 -1
View File
@@ -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'))
def test_new_shim_without_runtime_case_fails_coverage(self):
import frame_decky_store
+34 -2
View File
@@ -37,12 +37,14 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
class PublishRelease(unittest.TestCase):
def run_script(self, releases, *args, tags="v9.8.7"):
def run_script(self, releases, *args, tags="v9.8.7", gates=None):
d = Path(tempfile.mkdtemp())
(d / "releases.json").write_text(json.dumps(releases))
(d / "gates.json").write_text(json.dumps(gates or {}))
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"),
FAKEGH_GATES=str(d / "gates.json"))
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
@@ -120,6 +122,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()
+2 -5
View File
@@ -40,9 +40,7 @@ def plugins(folder):
'author': str(data.get('author', ''))[:80],
'storeId': owned.get('storeId') if isinstance(owned.get('storeId'), int) else None,
'version': str(owned.get('version', ''))[:40],
'sha256': str(owned.get('sha256', ''))[:64],
'compatShims': [s[:100] for s in owned.get('compatShims', []) if isinstance(s, str)][:16]
if isinstance(owned.get('compatShims'), list) else []})
'sha256': str(owned.get('sha256', ''))[:64]})
except (ValueError, OSError, UnicodeError): pass
return found
os_release = {}
@@ -168,8 +166,7 @@ def status(ssh):
result = json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
for plugin in result.get('plugins', []):
plugin['compatibility'] = framey_compatibility.lookup(
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'),
installed_shims=plugin.get('compatShims', []))
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'))
return result
+2 -72
View File
@@ -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'
@@ -253,51 +228,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 +343,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
View File
@@ -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())
+6 -18
View File
@@ -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,
-42
View File
@@ -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
+2 -10
View File
@@ -12,7 +12,7 @@ def _records(path):
return json.loads(path.read_text())['plugins']
def lookup(plugin_id, digest, version, installed_shims=None):
def lookup(plugin_id, digest, version):
unknown = {'status': 'unverified', 'reason': 'This exact store version has not been audited on Frame.',
'verification': [], 'blockInstall': False}
if type(plugin_id) is not int or not isinstance(digest, str) or not isinstance(version, str):
@@ -23,14 +23,6 @@ def lookup(plugin_id, digest, version, installed_shims=None):
if len(matches) != 1:
return unknown
record = matches[0]
result = {key: record[key] for key in ('status', 'reason', 'verification', 'blockInstall')}
required = record.get('requiredShims', [])
if result['status'] in ('works', 'partial') and required and installed_shims is not None and (
not isinstance(installed_shims, list) or
any(shim not in installed_shims for shim in required)):
result['status'] = 'partial'
result['reason'] = ('Update or reinstall this managed plugin to add its missing Frame compatibility fix. '
+ result['reason'])
return result
return {key: record[key] for key in ('status', 'reason', 'verification', 'blockInstall')}
except (OSError, ValueError, KeyError, TypeError):
return unknown
-29
View File
@@ -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.
-20
View File
@@ -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.
Binary file not shown.
Binary file not shown.