Compare commits

..
Author SHA1 Message Date
Alex Southwell 2797212d8c Merge pull request #360 from saphid/release/v0.4.50
Prepare Frame Control v0.4.50 candidate
2026-10-27 02:51:32 +00:00
SAPHID dab13fa876 Reject release jobs failed in earlier run attempts 2026-10-27 02:39:01 +00:00
SAPHID 36cef1096a Record v0.4.50 locked Frame smoke 2026-10-27 02:32:12 +00:00
SAPHID fcae5d66de Record v0.4.50 candidate gates and smoke limit 2026-10-27 02:06:18 +00:00
SAPHID a160043c63 Prepare Frame Control v0.4.50 candidate 2026-10-27 02:04:37 +00:00
Alex Southwell 64938443c8 Merge pull request #359 from saphid/feat/framey-store-request-recovery
Recover Framey store jobs after lost responses
2026-10-27 02:01:07 +00:00
Alex Southwell 1911923afa Merge pull request #358 from saphid/fix/framey-toggle-value
fix(framey): show saved values in standalone Decky toggles
2026-10-27 01:53:33 +00:00
Alex Southwell 2419279a97 Merge pull request #357 from saphid/feat/framey-progress-controls
Show indeterminate Framey plugin progress
2026-10-27 01:46:24 +00:00
Alex Southwell d958148c73 Merge pull request #354 from saphid/feat/framey-local-route-history
Return through local Framey panel routes
2026-10-27 01:39:05 +00:00
SAPHID a27e09e72c Merge remote-tracking branch 'origin/main' into feat/framey-store-request-recovery 2026-10-27 01:35:15 +00:00
Alex Southwell b3618690c2 Merge pull request #353 from saphid/feat/framey-sample-reset-modal
Show a reset confirmation in the Framey sample
2026-10-27 01:29:42 +00:00
SAPHID 030873703e Merge main and preserve Toggle and file-server runtime coverage 2026-10-27 01:28:29 +00:00
SAPHID c7979a862d Recover Framey store jobs after lost responses 2026-10-27 01:27:22 +00:00
Alex Southwell b963937227 Merge pull request #355 from saphid/feat/framey-fileserver-arm64
feat(framey): run DeckyFileServer backend on ARM64 without root
2026-10-27 01:23:04 +00:00
Alex Southwell 7053c40334 Merge pull request #356 from saphid/fix/panels-windows-oversize-response
Return reliable errors for oversized panel requests on Windows
2026-10-27 01:16:36 +00:00
SAPHID f8aff5496b fix(framey): honor saved values in standalone Decky toggles 2026-10-27 01:15:17 +00:00
SAPHID c0d8cb9dc4 Clarify Framey progress text placement 2026-10-27 00:49:03 +00:00
SAPHID 2888a45a30 Show indeterminate Framey plugin progress 2026-10-27 00:48:29 +00:00
SAPHID 92a84ea683 Return reliable errors for oversized panel requests on Windows 2026-10-27 00:35:50 +00:00
SAPHID 4bc5e3cab2 docs(framey): record combined Decky compatibility validation 2026-10-27 00:35:14 +00:00
SAPHID 86541446cf Merge remote-tracking branch 'origin/main' into feat/framey-fileserver-arm64
# Conflicts:
#	app/build/check-resources.js
#	app/package.json
#	docs/framey-compatibility.md
#	tests/framey_official.py
#	tests/test_frame_decky_store.py
#	tests/test_frame_headset.py
#	tests/test_framey_decky_runtime.py
#	ui/frame_decky_store.py
2026-10-27 00:31:11 +00:00
SAPHID af81e46c35 test(framey): verify packaged native assets and managed frontend policy 2026-10-27 00:29:24 +00:00
SAPHID 39aa8d56fb Return through local Framey panel routes 2026-10-27 00:28:47 +00:00
Alex Southwell f31c6871e4 Merge pull request #346 from saphid/feat/framey-deckyspy-backend
Make DeckySpy system monitoring work on ARM64 Frame
2026-10-27 00:28:47 +00:00
SAPHID f586214cb4 Merge remote-tracking branch 'origin/main' into feat/framey-fileserver-arm64
# Conflicts:
#	docs/framey-compatibility.md
#	scripts/framey-test-decky-runtime.py
#	tests/decky-runtime/cases.json
#	tests/decky-runtime/check.py
#	tests/framey_official.py
#	tests/test_frame_decky_store.py
#	tests/test_framey_decky_runtime.py
#	ui/frame_decky_store.py
2026-10-27 00:24:54 +00:00
SAPHID d20ff7f9aa feat(framey): port DeckyFileServer backend to ARM64 user service 2026-10-27 00:24:19 +00:00
Alex Southwell efc55919b6 Merge pull request #342 from saphid/feat/framey-sample-settings-lifecycle
Version Framey sample settings without losing old data
2026-10-27 00:20:09 +00:00
SAPHID 285b92573a Merge main and reconcile DeckySpy and Simple Timer compatibility 2026-10-27 00:18:25 +00:00
Alex Southwell 80d3244b05 Merge pull request #351 from saphid/fix/framey-simple-timer-lifecycle
Fix Simple Timer cancellation and backend restarts
2026-10-27 00:11:45 +00:00
SAPHID 5097a9f999 Show a reset confirmation in the Framey sample 2026-10-27 00:08:04 +00:00
Alex Southwell e9844473ea Merge pull request #347 from saphid/fix/framey-store-memory-job
Keep Framey store job recoverable without session storage
2026-10-27 00:03:01 +00:00
Alex Southwell 33838da03a Merge pull request #352 from saphid/docs/framey-author-api-current
Document current Framey module and dialog APIs
2026-10-26 23:56:45 +00:00
Alex Southwell 2970f5da61 Merge pull request #349 from saphid/feat/framey-scroll-hold-on-345
Keep Framey plugin scrolling while a button is held
2026-10-26 23:49:03 +00:00
Alex Southwell 5741ae6243 Merge pull request #345 from saphid/fix/framey-panel-status-session
Scope Framey panel status and input to active session
2026-10-26 23:42:21 +00:00
SAPHID 6cfaabbe8f Document current Framey module and dialog APIs 2026-10-26 23:37:12 +00:00
Alex Southwell 0905842ded Merge pull request #350 from saphid/docs/v0449-release-evidence
Record v0.4.49 release verification
2026-10-26 23:34:13 +00:00
SAPHID f4cc9ce0a1 Record v0.4.49 release verification 2026-10-26 23:26:59 +00:00
SAPHID 617661b7a2 Keep Framey plugin scrolling while a button is held 2026-10-26 23:24:42 +00:00
SAPHID 02fec484d6 Keep Framey store job recoverable without session storage 2026-10-26 22:46:50 +00:00
SAPHID 847509fa7e Merge remote-tracking branch 'origin/main' into feat/framey-deckyspy-backend 2026-10-26 22:39:48 +00:00
SAPHID 175b814e1b Record final DeckySpy validation results 2026-10-26 22:39:12 +00:00
SAPHID 134ceea2b7 Merge remote-tracking branch 'origin/main' into fix/framey-panel-status-session 2026-10-26 22:38:10 +00:00
SAPHID 1824b54140 Scope Framey panel status and input to active session 2026-10-26 22:38:05 +00:00
SAPHID c6fec613d6 Make DeckySpy system monitoring work on ARM64 Frame 2026-10-26 22:36:31 +00:00
SAPHID 4967b13dca Version Framey sample settings without losing old data 2026-10-26 22:10:25 +00:00
69 changed files with 3126 additions and 146 deletions

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ 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 |
+10
View File
@@ -13,6 +13,16 @@ const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/vendor/deckyfileserver-1.2.0/aarch64.gz",
"ui/vendor/deckyfileserver-1.2.0/x86_64.gz",
"ui/vendor/deckyfileserver-1.2.0/source.tar.gz",
"ui/vendor/deckyfileserver-1.2.0/LICENSE.txt",
"ui/vendor/deckyfileserver-1.2.0/GO-LICENSE.txt",
"ui/vendor/deckyfileserver-1.2.0/README.md",
"scripts/build-framey-fileserver.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.49",
"version": "0.4.50",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.49",
"version": "0.4.50",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+13 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.49",
"version": "0.4.50",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -60,6 +60,16 @@
"framey-compatibility.json"
]
},
{
"from": "../ui/vendor/deckyfileserver-1.2.0",
"to": "ui/vendor/deckyfileserver-1.2.0",
"filter": ["*.gz", "*.txt", "README.md"]
},
{
"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",
@@ -75,7 +85,8 @@
"*.sh",
"obs-headset-source.py",
"frame_power_sample.py",
"frame_power_report.py"
"frame_power_report.py",
"build-framey-fileserver.py"
]
},
{
@@ -0,0 +1,82 @@
{
"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"
]
}
@@ -0,0 +1,60 @@
# 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,128 @@
{
"plugin": {
"id": 60,
"name": "DeckyFileServer",
"version": "1.2.0",
"sha256": "7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0"
},
"status": "partial",
"runtime": {
"decky": "v3.3.0",
"amd64LoaderSha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"amd64Probes": [
{
"id": 60,
"result": "discovery, API1 settings, native HTTPS listing/download, thumbnail policy and stop passed; uploads disabled",
"discovered": [
{
"name": "DeckyFileServer",
"version": "1.2.0",
"load_type": 1,
"disabled": false
}
]
},
{
"id": 60,
"result": "original empty argument bug reproduced: thumbnail policy ignored",
"discovered": [
{
"name": "DeckyFileServer",
"version": "1.2.0",
"load_type": 1,
"disabled": false
}
]
}
]
},
"realFrame": {
"timeUtc": "2026-10-27T00:21:17.975213+00:00",
"arch": "aarch64",
"uid": 1000,
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"publicFixtureOnly": true,
"uploadsEnabled": false,
"actions": {
"discoveryAndApi1Rpc": true,
"settingsInitialized": true,
"nativeHttpsListing": true,
"nativeHttpsDownload": true,
"uploadsDenied401": true,
"disabledThumbnailsRespected": true,
"serverStop": true
},
"normalServicesBefore": {
"decky": "failed",
"panel": "inactive"
},
"normalServicesAfter": {
"decky": "failed",
"panel": "inactive"
},
"batteryBefore": [
99
],
"batteryAfter": [
100
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"archiveAbsent": true
},
"lockReleased": true,
"independentAudit": {
"baseAbsent": true,
"unitAbsent": true,
"process": "MainPID=0\nLoadState=not-found\n",
"lockAbsent": true
}
},
"localChromium": {
"opaqueOrigin": "null",
"warningVisible": true,
"uiStart": true,
"separateLanHostDownload": true,
"uiStop": true,
"settingsModal": true,
"reopenPersistedDirectory": true,
"passed": true
},
"fakeFrame": "Pinned official archive install/remove; native helper hash, manifest flags and shim receipt asserted; plugin code not executed by fake Frame.",
"limitations": [
"Frontend prints HTTP and steamdeck URLs although helper uses HTTPS.",
"Steam directory picker unavailable; a preconfigured directory was used.",
"Settings thumbnail toggle visually showed unchecked despite backend true; no settings save asserted.",
"Uploads and generated thumbnails unverified; all test requests read-only.",
"Timeout expiry, protected-file permissions and worn VR unverified."
],
"builds": [
{
"arch": "x86_64",
"sha256": "5e90efd1bf5163bef1686045f4714ad0e4fd0f54edb037fce8cdb06c58177bcb",
"gzipSha256": "84e49f049bbc475bc553ef52adc05dcd323e36c5f5ec75008558ae87f3a0e6d7",
"bytes": 14815380
},
{
"arch": "aarch64",
"sha256": "86b80021ebd62ed619eba065757c1122defa5a74b2c045aa44a0c6b4403a508a",
"gzipSha256": "7ca1b8aebd4e8d2c8ce382ca561e1413ad8e581f85f419a1da777c87d86c4a19",
"bytes": 13893780
}
],
"startupRetries": "Two private test-adapter failures (CEF reload, script syntax) and one overly early LAN readiness assertion preceded the successful run. Each run removed its private service/base/upload and released the lock; battery stayed 100.",
"rebuildMatchedBothAssets": true,
"automatedChecks": {
"pythonTests": 1662,
"pythonSkipped": 16,
"nodeAppTests": 46,
"publishedDecky330NativeAmd64Probes": 9,
"fakeFramePinnedInstallRemove": "passed",
"combinedDesktopResources": "passed",
"combinedHeadsetBundleBytes": 12485251
}
}
@@ -0,0 +1,84 @@
# DeckyFileServer ARM64 backend — 27 October 2026
DeckyFileServer **1.2.0**, official store ID **60**, moves **broken → partial**
only for the exact managed archive
`7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0`.
[Redacted observations](framey-fileserver-arm64-2026-10-27.json).
## Confirmed blockers and adaptation
The published ZIP contains a 19,779,282-byte x86_64 Go helper and requests `_root`.
Its actual operations use ordinary file permissions and an unprivileged port.
The managed installer replaces only this digest's original helper, checks the
replacement hash and ELF machine on the Frame, and removes only this manifest's
root request. Unknown versions, other binaries and root plugins remain blocked;
the Frame repeats archive/root/binary checks before extraction.
Pinned upstream commit `dcf3f3ce5104d05811fa8c156c4f58632b84b313` has Python,
manifest and package metadata byte-identical to the store archive. The published
Go executable lacks a VCS revision, so that binary's source revision cannot be
independently established. The rebuilt static Linux amd64/ARM64 helpers use
Go 1.25.7 and vendored dependencies. Complete corresponding GPL source, dependency
licenses, build recipe and hash pins ship with both desktop and headset bundles:
[provenance](../../ui/vendor/deckyfileserver-1.2.0/README.md).
The sole Go change sends its log to Decky's per-plugin log directory instead of
one global `/tmp` file. No compiler, pip, root service or runtime download is used.
Actual published Decky 3.3.0 discovery/public API1 RPC reproduced a hostname-DNS
failure in `get_status`. A local-address fallback selects the kernel source IP
without sending a packet, or returns loopback when no route exists.
The original command array includes empty strings for omitted flags; Go stops
parsing there and silently ignores the following `-disablethumbnails` flag.
The shim filters empty arguments and applies the thumbnail setting before start.
A negative runtime case restores the original empty arguments and proves that
thumbnails remain enabled despite the setting. **Decky 3.3.0 does expose
`decky.HOME`**; an initial assumption that it was removed was disproved by the
actual runtime. That API call and the plugin's API1 instance convention stay intact.
## Verification
- **Published Decky 3.3.0 amd64:** actual discovery, default settings, public
API1 calls, native HTTPS directory listing and marker download, disabled
thumbnails, upload endpoint returning 401, history and server stop. Execution
used an unprivileged, network-isolated, read-only disposable container.
- **Real Frame ARM64, Frame OS 0.5.6:** the same useful backend actions passed
through Steam CEF's existing Decky websocket, using a bounded private user
service and a directory containing only public test markers. No home directory
was shared and uploads stayed off.
- **Local Chromium opaque iframe with the actual Frame backend:** official
warning, Enable Server, stop, settings modal and panel reopen with persisted
private directory passed. A separate LAN host downloaded exactly the public
marker over HTTPS after the panel started the server. The relay allowed only
safe settings on that private directory; it denied uploads and other methods.
- **Fake Frame:** pinned archive install/remove, original frontend bytes,
replaced native helper hash, root-free manifest and managed shim receipt
passed through HTTP and SSH. Fake Frame does not execute plugin backends.
The shared `/tmp/frame-test.lock` was acquired normally and released after each
run. Two private test-adapter errors and an early LAN-readiness assertion were
corrected before the successful run; all attempts cleaned their private services
and artifacts. Normal managed Decky remained failed and the panel inactive,
matching their original states. Battery stayed **100 → 100** and no power setting
was changed. An independent SSH audit confirms base/unit/upload absent,
`MainPID=0`, `LoadState=not-found` and lock absent. Chromium and the local relay
were closed. No worn-VR claim is made.
The final branch, including merged DeckySpy and Simple Timer, passes **1,662
Python tests (16 skipped)**, all **9** published Decky 3.3.0 amd64 probes, **46**
app tests and pinned fake-Frame install/remove. Both helper rebuilds match byte
for byte. The combined headset bundle is 12,485,251 bytes, below its existing
32 MiB limit, and includes both native dependencies plus GPL source/licenses.
## Remaining limitations
The official frontend advertises **HTTP** and a **steamdeck** hostname even
though its helper always serves HTTPS. Use `https://<Frame address>:<port>`;
the upstream public self-signed certificate produces a browser warning.
Its Steam folder picker is unavailable in Framey; tests preconfigured only the
private fixture directory. The thumbnail toggle also appeared unchecked in the
settings modal while backend state was true; saving that modal was not tested.
These are concrete frontend follow-ups, so the plugin remains **partial**.
Uploads, thumbnail generation, inactivity shutdown, protected-file permissions
and worn VR remain unverified. The server exposes its selected directory on all
interfaces without authentication; select only files intended for sharing on a
trusted network. Existing unadapted copies require managed update/reinstall.
+308
View File
@@ -0,0 +1,308 @@
{
"date": "2026-10-27",
"scope": "Shared standalone Toggle value prop; no plugin-wide status promotion",
"affected": [
{
"id": 7,
"name": "CSS Loader",
"version": "2.1.3",
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
"status": "partial"
},
{
"id": 13,
"name": "Pause Games",
"version": "1.0.3",
"sha256": "4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62",
"status": "partial"
},
{
"id": 32,
"name": "AutoFlatpaks",
"version": "1.6.8",
"sha256": "344fce82cfb4800caccb4d6c0ec46e1ad6c9594a622d5baa0bb31286baf2d1a5",
"status": "broken"
},
{
"id": 60,
"name": "DeckyFileServer",
"version": "1.2.0",
"sha256": "7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0",
"status": "broken"
},
{
"id": 64,
"name": "Syncthing",
"version": "0.2.7",
"sha256": "27e47379c280c92253d5533435f57c33b332b215f866d2ac4493daf18ec0d1c4",
"status": "broken"
},
{
"id": 113,
"name": "Decky LSFG-VK",
"version": "0.12.2",
"sha256": "ec59fc8aceb4e1abe2066b3a0707ed6313cabc4421b7c17765397903e2f8fd26",
"status": "partial"
},
{
"id": 116,
"name": "Muon",
"version": "0.2.1",
"sha256": "a7659c79b74110476cc4458cb10530cdcb2539fa11bc8c759038a080eaa81049",
"status": "broken"
},
{
"id": 126,
"name": "Launch Options",
"version": "1.9.2",
"sha256": "381ac8b915402176cf768d1ccc1bc29a6b82ea9255675f0a30c0c81ea5427225",
"status": "broken"
},
{
"id": 129,
"name": "ShadowDeck",
"version": "1.1.11",
"sha256": "2b97b769a49ac2a45f546090fc96d07e09e146020104d708a0efdad990687f8b",
"status": "broken"
}
],
"browser": {
"passed": true,
"opaqueOrigin": "null",
"checks": [
"saved true renders checked",
"standalone Toggle has no empty field wrapper",
"native click writes boolean false and rerenders",
"second click writes boolean true",
"external false update renders unchecked",
"external true update renders checked",
"disabled Toggle stays inert",
"ToggleField checked contract preserved"
]
},
"publishedDeckyRuntime": {
"version": "v3.3.0",
"architecture": "x86_64",
"loaderSha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"totalProbes": 8,
"pauseGames": {
"id": 13,
"name": "Pause Games",
"version": "1.0.3",
"sha256": "4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62",
"shim": null,
"calls": [
{
"method": "load_settings",
"result": {}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true
}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": false
}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true
}
},
{
"method": "add_no_auto_pause_set",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"noAutoPauseSet": [
424242
]
}
},
{
"method": "remove_no_auto_pause_set",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"noAutoPauseSet": []
}
}
],
"discovered": [
{
"name": "Pause Games",
"version": "1.0.3",
"load_type": 1,
"disabled": false
}
],
"result": "discovery and Toggle-backed private settings persistence passed; no process actions"
}
},
"realFrame": {
"timeUtc": "2026-10-27T01:10:45.197435+00:00",
"architecture": "aarch64",
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"backend": {
"passed": true,
"discovered": [
{
"name": "Pause Games",
"version": "1.0.3",
"load_type": 1,
"disabled": false
}
],
"privateSettingsPersisted": true,
"calls": [
{
"method": "load_settings",
"result": {}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true
}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": false
}
},
{
"method": "save_setting",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"pauseBeforeSuspend": false,
"overlayPause": false,
"noAutoPauseSet": []
}
},
{
"method": "add_no_auto_pause_set",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"pauseBeforeSuspend": false,
"overlayPause": false,
"noAutoPauseSet": [
424242
]
}
},
{
"method": "remove_no_auto_pause_set",
"result": null
},
{
"method": "load_settings",
"result": {
"autoPause": true,
"pauseBeforeSuspend": false,
"overlayPause": false,
"noAutoPauseSet": []
}
}
]
},
"batteryBefore": [
100
],
"batteryAfter": [
100
],
"originalServiceStatesPreserved": true,
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"archiveAbsent": true
},
"lockReleased": true,
"independentCleanupAudit": {
"passed": true,
"privateBasesUnitsUploadsAbsent": true,
"privateUnitsNotLoaded": true
}
},
"fakeFrame": {
"blocked": 55,
"installed-removed": 61
},
"python": {
"ran": 1659,
"skipped": 16,
"result": "OK"
},
"unverified": [
"Other importer plugin backends",
"Process pause/resume",
"Automatic pausing/suspend",
"Physical controller/keyboard",
"Worn VR"
],
"affectedSnapshot": "Initial independent verification before merging main b963937; DeckyFileServer is partial in the merged catalog.",
"mergeValidation": {
"mainCommit": "b963937",
"catalog": {
"works": 6,
"partial": 51,
"broken": 48,
"na": 11
},
"python": {
"ran": 1663,
"skipped": 16,
"result": "OK"
},
"publishedDeckyRuntime": {
"version": "v3.3.0",
"architecture": "x86_64",
"probes": 10,
"result": "All passed, including Pause Games and DeckyFileServer positive/negative cases"
},
"hardwareRerun": false
}
}
+80
View File
@@ -0,0 +1,80 @@
# Shared standalone Toggle contract — 2026-10-27
Framey previously exported `DFL.Toggle` as `ToggleField`. Official standalone
toggles pass `value`, while ToggleField takes `checked`. A saved true setting
therefore appeared unchecked, and clicking it could send true again instead of
turning it off. Toggle now renders a standalone controlled checkbox from
`value`; ToggleField retains its `checked` contract. Both emit booleans and keep
disabled controls inert. No official archive or manifest is rewritten.
Nine exact catalog archives use this standalone contract: CSS Loader (7),
Pause Games (13), AutoFlatpaks (32), DeckyFileServer (60), Syncthing (64),
Decky LSFG-VK (113), Muon (116), Launch Options (126), and ShadowDeck (129).
These are affected API consumers, **not nine verified working plugins**.
Their existing root, native, Steam UI and runtime blockers still apply.
The catalog records this repair without promoting any status.
## Verification
- The Node regression fails against the old alias: true renders false. It
passes with the fix and checks true/false callbacks, disabled behavior and
the unchanged ToggleField contract.
- The pinned, unmodified Pause Games 1.0.3 frontend component renders both
persisted exclusion-list states and dispatches only its expected add/remove
settings callbacks. This VM fixture does not run effects or process actions.
- Local Chromium, React 18.3.1 and the production host in an opaque sandboxed
iframe pass eight DOM checks: saved true, standalone shape, false/true native
clicks and rerenders, external false/true updates, disabled input, and
ToggleField. This is a control fixture, not nine end-to-end plugin tests.
- Actual published Decky 3.3.0 on native amd64 discovers the exact Pause Games
archive and uses public API1 instance RPC to save/read true/false settings,
add/remove a synthetic exclusion and independently read its private settings
file. This is a permanent runtime probe alongside existing backend shims;
all eight probes, including historical negative cases, pass. Hosted CI also
runs this gate on native ARM64; local ARM64 evidence below is from the Frame.
- Under the shared headset lock, a bounded, removable private Decky 3.3.0 ARM64
user service repeats discovery and those safe settings operations through
the normal Steam CEF DeckyBackend bridge. The private file readback matches.
The harness never requests pause/resume/kill or suspend operations. Steam's
Decky frontend also populated its normal default setting keys during this
run; no running-game behavior is claimed.
- Fake-Frame audit: all 116 archives attempted, 61 installed/removed and 55
blocked by existing policy. Full Python suite: 1,659 tests, 16 skipped, OK.
The exact Pause Games archive SHA-256 is
`4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62`.
The official store refresh still contains 116 entries; all pinned IDs, versions
and digests are unchanged.
[Structured results](framey-toggle-2026-10-27.json) include scoped readbacks and
the nine version/digest pairs.
## Probe retries and cleanup
The first hardware adapter connected directly to Decky's single WebSocket and
reused a request ID also used by Steam CEF; it received an unrelated cached
reply. A second adapter retained a CEF connection across Decky's startup reload
and disconnected. Neither is a successful plugin verification. Both private
services and files were removed. The successful adapter waits for startup and
reattaches CEF during discovery, then serializes settings calls through the
normal DeckyBackend bridge.
All private services, bases and uploaded archives were removed, and the shared
lock was released. An independent locked audit confirms no matching unit is
loaded and no private base, unit file or upload remains. Original managed Decky
and panel states were preserved. Battery stayed 100 → 100 during the successful
run; no power setting changed.
No plugin gains a works claim. Real process control, other importer backends,
physical input and worn VR remain unverified. The original independent branch
had **6 works / 50 partial / 49 broken / 11 N/A**. After merging main's landed
DeckyFileServer ARM64 support, current totals are **6 works / 51 partial /
48 broken / 11 N/A**. Its managed-native receipt guard and both runtime probes
are preserved; its former Toggle display limitation is removed from the current
catalog reason. The initial verification counts above describe the original
branch; combined validation is recorded below.
Combined merge validation against main `b963937`: full Python suite passes
1,663 tests with 16 skips; all ten actual published Decky 3.3.0 native amd64
probes pass, including Pause Games settings and DeckyFileServer positive/negative
cases. Generated documentation matches the merged catalog. No hardware rerun
was needed for these fixture/catalog conflicts.
+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.
@@ -0,0 +1,52 @@
# v0.4.50 candidate gate, 27 October 2026
The version bump at `a160043` follows the merged Windows panel-response repair
[#356](https://github.com/saphid/frame-control/pull/356) and the Framey changes
[#353](https://github.com/saphid/frame-control/pull/353),
[#354](https://github.com/saphid/frame-control/pull/354),
[#355](https://github.com/saphid/frame-control/pull/355),
[#357](https://github.com/saphid/frame-control/pull/357),
[#358](https://github.com/saphid/frame-control/pull/358), and
[#359](https://github.com/saphid/frame-control/pull/359).
The exact DeckyFileServer catalog entry is **partial**; the shared Toggle fix
does not promote any plugin status. Worn VR and physical controller behavior
remain unverified.
## Locked real Frame smoke
Early `scripts/frame-smoke.sh` attempts exited before any headset read or test
action because lxso2's Tailnet route was down and lxso1 lacked the authorized
SSH key and pinned host record. No lock was taken by those attempts. The final
run kept lxso2's existing private key and pinned Frame host key end to end,
using lxso1 only as a TCP relay. A task-owned localhost tunnel reached the
devkit endpoint after the runner acquired `/tmp/frame-test.lock`; no global
SSH, network or security setting was changed.
At **02:25:14 UTC**, `scripts/frame-smoke.sh` ran from candidate commit
`fcae5d6` on Frame OS **0.5.6**, build `20261022.6153909`. Its locked
read-only preflight found SteamVR activity **3 (standby)**, proximity raw
**3.25** and battery **100%**. The result was **12 passed / one optional
x86_64 launch skipped / zero failed**. ARM64 and Windows/Proton test titles
installed, launched and were removed. The x86_64 title installed and was
removed, but Steam Linux Runtime 4 was absent, so no x86_64 launch is claimed.
Both the initial leftover sweep and final owned cleanup passed; the runner
released only its lock. The private result is
`/tmp/frame-control-v0450-smoke-results/2026-10-27T022514Z-20261022.6153909.json`
(SHA-256 `7b8d88aafb4459bf882edeb10f65c021c5aa25ad9ed2755faf04e3b4a95c1632`).
A separate read-only audit acquired a fresh unique lock after the smoke. It
found activity **3**, proximity raw **3.25**, battery **100%**, no exact reserved
`fc_smoke_*` or `fc-smoke-*` title directories or metadata, and no processes
owned by those paths. That audit released its own lock. The local tunnel was
stopped; the isolated lxso1 checkout and temporary SSH files were removed.
There was no wearer confirmation, wake command or worn interaction test.
## Remaining release gates
The candidate needs green exact-head PR CI, e2e, native amd64/ARM64 Decky
runtime checks and all four installer previews. After the candidate merges,
its exact main commit needs green CI and e2e. The tagged release then needs
every installer build, ten assets, matching checksums, release verification,
independent package checks and publisher dry run. A failed, cancelled, skipped,
pending or unverified release gate holds publication. The current published
release remains v0.4.49.
+22 -4
View File
@@ -63,8 +63,9 @@ none of these frontend controls has passed a worn-headset check yet.
| Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test |
| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test |
| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified |
| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Plugins may still use ordinary CSS overflow containers. | Parent/iframe message tests; physical controller scroll remains unverified |
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Hold either button to repeat; keyboard activation still moves one step. Plugins may use ordinary CSS overflow containers. | Parent/iframe message tests and a real Frame synthetic OpenVR pointer hold; physical controller scroll and hold remain unverified |
| `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium |
| `ProgressBar`, `ProgressBarItem`, `ProgressBarWithInfo` | Native progress control; `indeterminate` omits its value so it animates as unfinished, and `sOperationText` appears with the bar. Estimated completion time is not shown. | Host contract tests; official plugin progress behavior in worn VR remains unverified. |
| `SteamUIStore` / `DFL.Router.RunningApps`, `MainRunningApp` | Bounded detached Steam CEF reads while the panel is open; refresh about every two seconds; no MobX observation. Failed reads clear stale context. | Locked real Frame empty-list reads; nonempty local Chromium fixtures. Real running-game selection unverified. |
| `GameSessions.RegisterForAppLifetimeNotifications` | Actual Steam callbacks, bounded queue and disposable `.unregister()` handle; no events inferred from snapshot changes; lease cleanup after lost panels | Real Frame registration/poll/close; launch/exit, retry, expiry and duplicate tests use explicit fixtures. Real game transitions unverified. |
| Other `SteamClient` subscriptions | Explicit inert registration methods return disposable handles; no fabricated power/input/game-action events. Mutating Steam/power methods are unavailable. | Host contract tests; safe fallbacks only |
@@ -74,10 +75,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.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. Back walks up to 32 local page transitions and skips routes removed since navigation. | 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 |
@@ -87,7 +91,21 @@ Frame Control store; only paths registered by that same plugin can navigate.
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.
`Navigation.NavigateBack()` returns to the preceding local page, then the
plugin root. `Navigation.CloseSideMenus()` returns directly to the root and
clears that panel's history. Neither operation navigates Steam's own UI.
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
+17 -15
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: 6 · Partial: 51 · Broken: 48 · N/A: 11.**
- **Works** requires verified useful plugin behavior with its real backend. Read the reason for the verified scope and remaining limits; it does not imply worn-VR verification.
- **Partial** means only a subset or a local fixture scope was verified, often just frontend/settings initialization. The remaining backend/device functionality is unverified; read the reason for the exact scope.
@@ -29,7 +29,7 @@ A [Free Loader duplicate-entry follow-up](evidence/framey-free-loader-duplicates
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. That initial pass left Simple Timer partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. An [ARM64 audio follow-up](evidence/framey-audio-arm64-2026-10-26.md) verified the unmodified official backends in actual Decky 3.3.0 on the Frame user audio session. Volume Mixer and Mixer now qualify as works for per-app volume: Chromium controls changed only a named silent stream, with independent Pulse readbacks. That run left Volume Boost partial because sink writes were denied. A [Volume Boost follow-up](evidence/framey-volume-boost-arm64-2026-10-26.md) now verifies linked/separate channel controls, re-link and reopen through the actual ARM64 backend. Independent Pulse reads confirmed 125%/150% gain writes while the default sink stayed muted; exact original volume/mute was restored. Volume Boost qualifies as works for sink controls. Audible output, Bluetooth playback and worn VR remain unverified. A [running-context follow-up](evidence/framey-running-context-2026-10-26.md) supplies detached running-app/main-app reads and relays actual Steam lifetime callbacks while a panel is open. Real Frame checks verified an empty list, registration/cleanup and the exact Discord Status ARM64 backend reporting no IPC connection. Nonempty games and transitions were synthetic fixtures; real game events, Discord activity and worn VR remain unverified. Discord Status moves to partial; no new works claim is made. A [library context follow-up](evidence/framey-library-context-2026-10-26.md) read 1,106 app overviews, 895 game IDs and the current public Steam ID from real Frame CEF under the shared lock. Local Chromium replayed this detached snapshot to render Deck Settings game selection, Steamback's empty save panel and PlayTime statistics/settings. Steamback and PlayTime used actual Decky 3.3.0 amd64 backends in an isolated container; their ARM64 backends and automatic game hooks remain unverified. Shotty's frontend now starts, but its published backend fails on missing `click`; it stays broken. A [chunked frontend follow-up](evidence/framey-deck-shelves-browser-2026-10-26.md) loaded Deck Shelves 2.0.0 through a managed JavaScript module route and rendered its settings in an opaque local Chromium panel. Its backend was denied; Steam Home shelves and worn VR remain unverified. A [local module lookup follow-up](evidence/framey-module-lookup-2026-10-26.md) supplies a finite registry of Framey-local controls and CSS. TabMaster QAM/docs now render; Steam library-tab editing remains unavailable. That pass exposed Junk-Store executable-script extraction failures; the follow-up below repairs them. SteamGridDB still requires Steam context-menu internals and DeckSP still fails its published MobX fallback. That pass tested TabMaster/Junk-Store in actual isolated amd64 Decky, without ARM64 or worn-VR coverage. A [script entrypoint follow-up](evidence/framey-script-entrypoints-2026-10-26.md) grants owner execute to shebang scripts and ZIP-declared executables without copying archive privilege/write bits. Junk-Store now passes static menu/backend calls in actual Decky 3.3.0 ARM64 and local Chromium Games/Epic navigation, back and reopen. It moves to partial: About still crashes on missing installed-app metadata, Legendary is absent, and login/game operations remain unverified. Existing installations require update/reinstall to repair permissions. A [read-only installed-metadata follow-up](evidence/framey-installed-context-2026-10-26.md) now repairs the About crash. The library snapshot copies only Steam-supplied installed booleans into detached metadata, preserving unknown values as undefined. Junk-Store About/Dependencies, Back and reopen render in local Chromium with actual Frame library data and the official ARM64 backend. It remains partial: dependency/login/game operations, RSSLine truncated
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. 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
@@ -38,11 +38,11 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| Plugin / version | Manifest / bundle API | Status | Reason | Verified by |
| --- | --- | --- | --- | --- |
| 1: XR Gaming 1.6.2 | 1 / 2 | N/A | Not supported on Frame: Requires supported external XR glasses and the Breezy/XRLinuxDriver stack; it does not target Steam Frame displays. | static archive, local browser, fake-Frame |
| 7: CSS Loader 2.1.3 | legacy / legacy | partial | Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. | static archive, local browser, fake-Frame |
| 7: CSS Loader 2.1.3 | legacy / legacy | partial | Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 10: vibrantDeck 2.0.1 | legacy / legacy | N/A | Not supported on Frame: Changes X11 gamescope color properties, not the Frame SteamVR compositor. | static archive, local browser, fake-Frame |
| 11: Fantastic 0.5.1 | legacy / legacy | N/A | Not supported on Frame: Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. | static archive, local browser, fake-Frame |
| 12: MusicControl 1.1.6 | legacy / legacy | partial | Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply. | static archive, local browser, fake-Frame, isolated Decky 3.3.0 + MPRIS fixture, real Frame SSH |
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied. | static archive, local browser, fake-Frame |
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings and a synthetic running-game list render locally. Actual Decky 3.3.0 discovery, private settings writes/readback and exclusion-list persistence pass on amd64 and ARM64 Frame; standalone Toggle now honors its value prop. Live empty-list/lifetime checks passed earlier. Real process pause/resume, automatic pausing, suspend integration and worn VR remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel. | static archive, local browser, fake-Frame |
| 18: Bluetooth 3.1.1 | 1 / 2 | partial | Controls render with fixture replies; actual Bluetooth discovery, labels and connections remain unverified. | static archive, local browser, fake-Frame |
@@ -55,7 +55,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 27: AutoSuspend 2.2.0 | legacy / legacy | broken | Frontend audit failed: Cannot read properties of null (reading '6'). | static archive, local browser, fake-Frame |
| 28: Emuchievements 2.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: Emuchievements/bin/hash. | static archive, local browser, fake-Frame |
| 30: TunnelDeck 1.0.4 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 32: AutoFlatpaks 1.6.8 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 35: MoonDeck 1.13.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: moondeck/python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so. Frontend initializes; native CPython wheels and executables in the published archive are x86_64. | static archive, local browser, fake-Frame |
| 36: SteamGridDB 1.8.1 | 1 / 2 | broken | Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel. | static archive, local browser, fake-Frame |
| 37: Discord Status 1.5.0 | 1 / 2 | partial | Disconnected panel renders; running-activity construction accepts a synthetic game in local Chromium. Exact official is_connected returned false in real Decky 3.3.0 ARM64. Empty live running-list reads and lifetime registration/cleanup passed. Real game transitions, Discord shortcut detection/connection/activity, local custom artwork and worn VR remain unverified/unsupported. | static archive, local browser, fake-Frame, real Frame SSH |
@@ -72,11 +72,11 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 56: MangoPeel 0.0.7 | 1 / 2 | partial | Initializer succeeds; gamescope/MangoHud actions and backend are unverified. | static archive, local browser, fake-Frame |
| 58: Volume Boost 0.0.7 | legacy / legacy | works | Default-sink controls pass in local Chromium through real Decky 3.3.0 ARM64: linked slider/keyboard and numeric writes, separate channels, re-link and reopen, with independent Pulse readback including 125%/150% while muted. Exact original volume/mute restored. Audible output, Bluetooth sinks and worn VR unverified. | static archive, local browser, fake-Frame, isolated Linux backend, real Frame SSH |
| 59: Web Browser 1.4.3 | legacy / legacy | broken | The exact frontend now initializes with a null Steam React-root lookup. The browser itself still requires Steam GamepadUI CDP injection and a backend WebSocket unavailable in Framey; no working browser tab was verified. | static archive, local browser, fake-Frame, focused local browser |
| 60: DeckyFileServer 1.2.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend. | static archive, local browser, fake-Frame |
| 60: DeckyFileServer 1.2.0 | 1 / 2 | partial | Managed install supplies a native ARM64 helper and removes the exact archive's unnecessary root request. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. Frontend address/picker limitations remain; use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame, real Decky 3.3.0 amd64, real Frame SSH |
| 61: TabMaster 2.15.1 | 1 / 2 | partial | Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified. | static archive, local browser, fake-Frame |
| 62: TS3 QuickAccess 1.2.0 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: ts3-qs4sd/bin/ts3-qs4sd.so. | static archive, local browser, fake-Frame |
| 63: Free Loader 1.6.0 | 1 / 2 | partial | The exact archive's duplicate names contain identical bytes and differ only in execute bits; the guarded installer now accepts them, and a private local install/remove transaction passed. The current opaque Chromium host renders Free Games but cannot load deals with backend/network denied. Exact official Python settings and empty local-deals methods passed in a no-network container with a Decky stub. Real Decky, feed refresh, notifications and worn VR remain unverified. | static archive, local browser, fake-Frame pinned install/remove, local exact-archive transaction, isolated Python backend |
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. | static archive, local browser, fake-Frame |
| 64: Syncthing 0.2.7 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 65: Decky Terminal 0.5.3 | 1 / 2 | partial | Local routes initialize after parameter-route support; no shell commands were run. | static archive, local browser, fake-Frame |
| 66: PlayTime 3.2.5 | 1 / 2 | partial | Statistics and routed settings render with a detached real Frame library/user snapshot and actual Decky 3.3.0 amd64 backend in isolation. User selection and empty statistics reads pass. Running snapshots are now exposed, but its private MobX observation is unsupported; automatic recording and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 67: Battery Tracker 0.2.0 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
@@ -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 | partial | Initializer succeeds; account tokens and external service integration were not exercised. | 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 |
| 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 |
@@ -121,10 +121,10 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 110: Dimmer Deck 0.0.4 | 1 / 2 | N/A | Not supported on Frame: Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. | static archive, local browser, fake-Frame |
| 111: Music Control 1.1.7 | legacy / legacy | partial | Managed reinstall preserves Decky’s legacy calling convention and repairs the Frame OS shell library-path failure. Chromium transport/seek/volume against a silent MPRIS fixture and real ARM64 empty-player discovery pass; actual Frame media playback, album art and worn VR remain unverified. Reinstall existing copies to apply. | static archive, local browser, fake-Frame, isolated Python backend, isolated Decky 3.3.0 + MPRIS fixture, real Frame SSH |
| 112: Better Keyboard 0.3.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: window.SteamClient.UI.GetUIMode is not a function. | static archive, local browser, fake-Frame |
| 113: Decky LSFG-VK 0.12.2 | 1 / 2 | partial | Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. | static archive, local browser, fake-Frame |
| 113: Decky LSFG-VK 0.12.2 | 1 / 2 | partial | Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 114: Network Sentinel 1.0.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 115: Decky Clash 1.3.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 116: Muon 0.2.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 116: Muon 0.2.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 117: DeckyParty File Manager 1.0.0 | legacy / legacy | partial | Initializer succeeds; privileged file operations and backend are unverified. No file changes were run. | static archive, local browser, fake-Frame |
| 118: Speed Test 1.0.2 | 1 / 2 | partial | Initializer succeeds; automatic bandwidth tests were blocked by the audit network policy. | static archive, local browser, fake-Frame |
| 119: Decky Clipboard 1.0.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-clipboard/bin/xclip. | static archive, local browser, fake-Frame |
@@ -133,10 +133,10 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
| 123: Steam Status 1.2.3 | 2 / 2 | partial | Settings/public status rendered locally; cache errors fixed. Read-only get_settings RPC returned actual Decky 3.3.0 defaults on ARM64 Frame. Persistence, live backend diagnostics and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
| 124: AlarMe 1.6.3 | 1 / 2 | partial | Silent labeled timer create/ticks/pause/cancel, preset save/cancel-confirm/delete and stopwatch start/lap/pause/reset/reopen pass in local Chromium with the official Decky 3.3.0 ARM64 backend. Audio, alarms/reminders, suspend inhibition, Steam overlays and worn VR remain unverified; notifications require the panel to stay open. | static archive, local browser, fake-Frame, real Frame SSH |
| 125: Decky Localsend 0.4.5 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-localsend/bin/localsend-core. | static archive, local browser, fake-Frame |
| 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
| 126: Launch Options 1.9.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 127: Decky Translator 0.9.0 | 1 / 2 | partial | Initializer succeeds; x86_64 download and hidraw/controller assumptions need an ARM64 audit. OCR/translation not exercised. | static archive, local browser, fake-Frame |
| 128: Quick User Switcher 1.0.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 129: ShadowDeck 1.1.11 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 129: ShadowDeck 1.1.11 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain. | static archive, local browser, fake-Frame |
| 130: Games Prefix Manager 1.0.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
| 131: Download All 1.1.0 | 1 / 2 | broken | Initializer succeeds; Steam download registration is absent, so the primary action is unavailable. | static archive, local browser, fake-Frame |
| 132: SuggestMe 1.5.3 | 1 / 2 | partial | Initializer succeeds; recommendation backend and Steam library data unverified. | 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 | No specific hardware requirement detected statically; runtime requirements unverified. |
| 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. |
| 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. |
@@ -279,7 +279,9 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
## Native payload portability
All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. The three Windows DLLs belong to Decky-Framegen. The installer still rejects these archives. Apart from the explicitly described LocalSend experiment, ARM64 builds were not attempted: the constraints below remain unresolved.
All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. The three Windows DLLs belong to Decky-Framegen. Except for the exact managed DeckyFileServer adaptation below, the installer still rejects these archives. Apart from LocalSend and DeckyFileServer, ARM64 builds were not attempted: the constraints below remain unresolved.
A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. The plugin moves from broken to partial: its frontend advertises HTTP/steamdeck URLs and needs a Steam folder picker. Use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.
[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) covers a reproducible MIT helper build and its isolated real-Frame protocol 2.0 info response. The official x86 archive remains blocked; full plugin integration and file transfers are unverified.
@@ -293,7 +295,7 @@ All published Linux native payloads in this snapshot are x86_64; no published aa
| Controller Tools | bin/backend | Native backend needs an ARM64 build and controller/HID dependencies; its direct websocket also bypasses the Framey bridge. |
| DeckMTP | bin/umtprd | umtprd needs ARM64 libusb/MTP dependencies and USB gadget permissions; root user-service blocker remains. |
| Decky Cloud Save | bin/rclone | An ARM64 rclone artifact and safe package policy are needed; the official 60,776,600-byte x86_64 member fails archive validation. No cloud writes were run (docs/evidence/framey-native-port-blockers-2026-10-26.md). |
| DeckyFileServer | bin/backend | Native file-server backend needs ARM64 dependencies; root requirement also blocks the user service. |
| DeckyFileServer | bin/backend | GPL-3.0 helper rebuilt offline for ARM64 with vendored corresponding source. Actual Frame user service passes HTTPS listing/download of a private public fixture; uploads disabled. Exact managed digest only; other native/root archives remain blocked (docs/evidence/framey-fileserver-arm64-2026-10-27.md). |
| TS3 QuickAccess | bin/ts3-qs4sd.so | TeamSpeak client plugin must match an ARM64 TeamSpeak client and SDK ABI, not merely rebuild the .so. |
| Syncthing | bin/decky-syncthing-watchdog | The watchdog needs an ARM64 build, plus a supported Syncthing runtime/package and safe path validation. |
| Wine Cellar | bin/backend | Native backend needs an ARM64 build; direct websocket and Wine runtime assumptions also need integration. |
+16 -6
View File
@@ -203,18 +203,25 @@ the managed service is stopped; it does not restart the service.
Install, Update, and Remove show the current job stage in the headset panel.
The store verifies each downloaded archive and the headset applies it with a
rollback if Decky cannot start. The panel remembers the current job ID for this
browser session, so **Check result** can resume polling after a navigation or
temporary connection error. After a polling error, Install, Update, and Remove
rollback if Decky cannot start. The panel saves a random request ID before
submitting a change. The server uses that ID as the job ID and returns the
same job for a repeated, identical request while its result is retained;
reusing the ID for a different change is rejected. **Check result** can resume
polling even if the POST response is lost. After a connection error, Install, Update, and Remove
stay disabled while the saved job's result is unknown; use **Check result**
before starting another change.
If Chromium denies session storage, the open panel keeps the request ID in
memory and **Check result** still works after a connection error. A page reload
loses that memory fallback; wait for the old job to finish and refresh installed
status before starting another change.
Refresh the installed list before starting another
operation after an error. The headset panel and desktop app send the hash of
the version you saw; if the official store changes that version before the job
starts, Frame Control stops before downloading or changing the headset. Refresh
the store and review the new version before retrying. If Frame Control restarts, it cannot recover the old
job result. The panel clears that job ID and asks you to refresh the installed
list before making another change.
the store and review the new version before retrying. Job results live in server
memory for one hour after completion. If Frame Control restarts, or the request
never reached it, the panel cannot recover the result. It clears that ID and
asks you to refresh the installed list before making another change.
An open plugin panel closes before its own Update or Remove starts, so its old
frontend cannot call the changing backend. Open panel is unavailable while a
@@ -228,6 +235,9 @@ chunks from two installed versions.
The headset also includes the panel's store SHA-256 receipt with each backend
call, Steam read, and event poll. It rejects a request if the installed receipt
changed before the next status refresh; reopen the panel to use the new version.
Panel-ready, error, and trusted-pointer reports also carry the current panel
session. A delayed report from a closed panel cannot change the new panel's
status or count as input to that panel.
## For plugin authors
+7 -3
View File
@@ -61,8 +61,10 @@ No wheel event or offset change means this synthetic injection path remains
unverified; it does not establish whether a physical controller can scroll.
The exercise then clicks the visible **Scroll plugin down/up** buttons through
the same native OpenVR pointer transport and requires the test region to move
down and return to the top. This proves the button path only; it does not
identify a physical controller or repair native wheel delivery.
down and return to the top. It also holds each button through that transport,
requires Down to move farther than a single click, and requires Up to return
to the top. These prove the synthetic button paths only; they do not identify
a physical controller or repair native wheel delivery.
Temporary controls exercise the shipped
`DFL.SliderField` and `DFL.TextField`; their values remain UI-only. Text uses
CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input,
@@ -119,7 +121,9 @@ the same-plugin navigation regression without a headset.
3. **1:15–2:15:** Use the controller to open Settings, return to Panel, invoke
**Read saved setting**, close/reopen the plugin, and confirm the value
remains. Try controller scroll, a slider and a harmless text field in an
installed compatible plugin; check SteamVR's actual keyboard and Back.
installed compatible plugin. Hold **Scroll plugin down** and then **Scroll
plugin up** with the controller; record continuous movement separately from
native thumbstick or wheel scrolling. Check SteamVR's actual keyboard and Back.
4. **2:15–3:00:** Restore the original sample setting. Switch away and back,
close/reopen the Frame Control panel, and confirm controller input still
works. Record comfort/legibility, controller model, any haptic behavior,
+1 -1
View File
@@ -51,7 +51,7 @@ count. So a build reaches people only when you publish it, after testing it.
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
cancelled, skipped or running jobs in any run attempt, 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
+3 -1
View File
@@ -578,7 +578,9 @@ uncovered shim names. The runtime job verifies the managed receipt, loader
plugin discovery, and a successful backend call; a discovery listing alone
could conceal a crashed backend process.
Current cases cover both Music Control entries and Shotty. Music Control uses
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
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
+13 -1
View File
@@ -14,9 +14,20 @@ action calls the backend, so it reads the persisted setting rather than a
browser cache. A missing, damaged, or old non-boolean value reads as the
caller's default;
the next save atomically replaces the sample's settings file.
The current file is `{"schema": 1, "enabled": true|false}`. An unversioned
`{"enabled": true|false}` file from the first sample release still reads, and
the next save writes schema 1. A file with another explicit schema returns a
visible read or save error and remains untouched, so downgrading the sample
cannot erase settings written by a newer version. Add a deliberate migration
for each future schema instead of changing this guard silently.
The panel also guards its own read and write calls synchronously. If two
controller events arrive before React redraws a disabled control, the second
event is ignored; after the request settles, a new change can be saved.
**Reset example setting** opens a local `ConfirmModal`. **Keep setting** closes
it without a backend call; **Reset** saves `false` through the same guarded
write path as the toggle. This is a safe example of putting a settings change
behind an explicit confirmation in Framey. It has not been tried with a
physical controller in a worn headset.
The **About this setting** button opens a local detail page at
`/framey-sample/details/saved-setting`. It demonstrates `routerHook.addRoute`,
@@ -48,7 +59,8 @@ plugin directory does not enable its VR panel.
Run `python3 -m unittest discover -s tests -p test_framey_sample.py` from the
repository root. It checks backend persistence and evaluates the built bundle
against Framey's actual host script, including the quick action's scoped
backend call and local route registration/navigation. The fake Frame store flow is exercised by
backend call, modal cancel/confirm behavior, and local route
registration/navigation. The fake Frame store flow is exercised by
the repository's e2e suite. No worn-headset visibility or controller-input test
has passed for this sample yet.
+9 -1
View File
@@ -31,6 +31,7 @@ function Content() {
const [busy, setBusy] = SP_REACT.useState(true);
const [message, setMessage] = SP_REACT.useState('Loading setting…');
const pending = SP_REACT.useRef(false);
const resetModalOpen = SP_REACT.useRef(false);
async function refresh() {
if (pending.current)
return;
@@ -72,7 +73,14 @@ function Content() {
setBusy(false);
}
}
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
function confirmReset() {
if (pending.current || resetModalOpen.current)
return;
const dialog = DFL.showModal(SP_JSX.jsx(DFL.ConfirmModal, { strTitle: "Reset example setting?", strDescription: "This changes only the sample plugin's saved setting.", strOKButtonText: "Reset", strCancelButtonText: "Keep setting", onOK: () => { void change(false); } }));
resetModalOpen.current = true;
void dialog.ClosedPromise.then(() => { resetModalOpen.current = false; });
}
return SP_JSX.jsxs(DFL.PanelSection, { title: "Framey settings sample", children: [SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ToggleField, { label: "Example setting", description: "Stored by the Decky Python backend", checked: enabled, disabled: busy, onChange: (value) => { void change(value); } }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: () => { void refresh(); }, children: "Reload saved setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { disabled: busy, onClick: confirmReset, children: "Reset example setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx(DFL.ButtonItem, { onClick: () => DFL.Navigation.Navigate('/framey-sample/details/saved-setting'), children: "About this setting" }) }), SP_JSX.jsx(DFL.PanelSectionRow, { children: SP_JSX.jsx("p", { role: "status", children: message }) })] });
}
function Details() {
const { topic } = DFL.useParams();
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, useRef, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n const pending = useRef(false);\n\n async function refresh() {\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n // Two controller events can arrive before React redraws the disabled toggle.\n // Keep writes ordered so a late first reply cannot replace the final choice.\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample',\n body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><p role=\"status\">{message}</p></PanelSectionRow>\n </PanelSection>;\n}\n\nfunction Details() {\n const {topic} = useParams<{topic?: string}>();\n return <PanelSection title=\"Sample detail page\">\n <PanelSectionRow><p>Topic: {topic || 'overview'}</p></PanelSectionRow>\n <PanelSectionRow><p>The toggle is stored by this plugin's Decky backend in the user's settings directory.</p></PanelSectionRow>\n <PanelSectionRow><ButtonItem onClick={() => Navigation.NavigateBack()}>Back Line truncated
{"version":3,"file":"index.js","sources":["decky://decky/plugin/Framey%20Settings%20Sample/node_modules/.pnpm/@decky+api@1.1.3/node_modules/@decky/api/dist/index.js","decky://decky/plugin/Framey%20Settings%20Sample/src/index.tsx"],"sourcesContent":["import _manifest from '@decky/manifest';\nexport * from \"./types\";\nconst manifest = _manifest;\nconst API_VERSION = 2;\nif (!manifest?.name) {\n throw new Error('[@decky/api]: Failed to find plugin manifest.');\n}\nconst internalAPIConnection = window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit;\nif (!internalAPIConnection) {\n throw new Error('[@decky/api]: Failed to connect to the loader as as the loader API was not initialized. This is likely a bug in Decky Loader.');\n}\nlet api;\ntry {\n api = internalAPIConnection.connect(API_VERSION, manifest.name);\n}\ncatch {\n api = internalAPIConnection.connect(1, manifest.name);\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version 1. Some features may not work.`);\n}\nif (api._version != API_VERSION) {\n console.warn(`[@decky/api] Requested API version ${API_VERSION} but the running loader only supports version ${api._version}. Some features may not work.`);\n}\nexport const call = api.call;\nexport const callable = api.callable;\nexport const addEventListener = api.addEventListener;\nexport const removeEventListener = api.removeEventListener;\nexport const routerHook = api.routerHook;\nexport const toaster = api.toaster;\nexport const openFilePicker = api.openFilePicker;\nexport const executeInTab = api.executeInTab;\nexport const injectCssIntoTab = api.injectCssIntoTab;\nexport const removeCssFromTab = api.removeCssFromTab;\nexport const fetchNoCors = api.fetchNoCors;\nexport const getExternalResourceURL = api.getExternalResourceURL;\nexport const useQuickAccessVisible = api.useQuickAccessVisible;\nexport const definePlugin = (fn) => {\n return (...args) => {\n return fn(...args);\n };\n};\n","import {callable, definePlugin, routerHook, toaster} from '@decky/api';\nimport {ButtonItem, ConfirmModal, Navigation, PanelSection, PanelSectionRow, ToggleField, showModal, useParams} from '@decky/ui';\nimport {useEffect, useRef, useState} from 'react';\n\nconst getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');\nconst setSetting = callable<[key: string, value: boolean], boolean>('set_setting');\n\nfunction Content() {\n const [enabled, setEnabled] = useState(false);\n const [busy, setBusy] = useState(true);\n const [message, setMessage] = useState('Loading setting…');\n const pending = useRef(false);\n const resetModalOpen = useRef(false);\n\n async function refresh() {\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n const value = await getSetting('enabled', false);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n } catch (error) {\n setMessage(`Could not read setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\n // Two controller events can arrive before React redraws the disabled toggle.\n // Keep writes ordered so a late first reply cannot replace the final choice.\n if (pending.current) return;\n pending.current = true;\n setBusy(true);\n try {\n await setSetting('enabled', value);\n setEnabled(value);\n setMessage(`Saved setting: ${value ? 'on' : 'off'}`);\n toaster.toast({title: 'Framey sample',\n body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\n pending.current = false;\n setBusy(false);\n }\n }\n\n function confirmReset() {\n if (pending.current || resetModalOpen.current) return;\n const dialog = showModal(<ConfirmModal strTitle=\"Reset example setting?\"\n strDescription=\"This changes only the sample plugin's saved setting.\"\n strOKButtonText=\"Reset\" strCancelButtonText=\"Keep setting\"\n onOK={() => {void change(false);}} />);\n resetModalOpen.current = true;\n void dialog.ClosedPromise.then(() => {resetModalOpen.current = false;});\n }\n\n return <PanelSection title=\"Framey settings sample\">\n <PanelSectionRow>\n <ToggleField label=\"Example setting\" description=\"Stored by the Decky Python backend\"\n checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />\n </PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>\n <PanelSectionRow><ButtonItem disabled={busy} onClick={confirmReset}>Reset example setting</ButtonItem></PanelSectionRLine truncated
+21 -6
View File
@@ -5,26 +5,41 @@ from pathlib import Path
import tempfile
import decky
SETTINGS_SCHEMA = 1
def settings_file():
# Decky provides this per-plugin path through its official Python module.
return Path(decky.DECKY_PLUGIN_SETTINGS_DIR) / 'settings.json'
def read_settings():
try:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return {}
if not isinstance(stored, dict):
return {}
# The first sample release wrote {"enabled": bool} without a schema.
# Keep reading it; the next save writes the current format.
schema = stored.get('schema', SETTINGS_SCHEMA)
if type(schema) is not int or schema != SETTINGS_SCHEMA:
raise ValueError('Settings were written by an unsupported sample version')
return stored
class Plugin:
async def get_setting(self, key: str, default=False):
if key != 'enabled':
raise ValueError('Unknown setting')
try:
stored = json.loads(settings_file().read_text(encoding='utf-8'))
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return default
value = stored.get(key) if isinstance(stored, dict) else None
value = read_settings().get(key)
return value if type(value) is bool else default
async def set_setting(self, key: str, value: bool):
if key != 'enabled' or type(value) is not bool:
raise ValueError('Expected an enabled boolean')
# Refuse to erase settings from a newer plugin after a downgrade.
read_settings()
target = settings_file()
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary = None
@@ -32,7 +47,7 @@ class Plugin:
with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=target.parent,
prefix='.settings-', delete=False) as output:
temporary = Path(output.name)
json.dump({'enabled': value}, output)
json.dump({'schema': SETTINGS_SCHEMA, 'enabled': value}, output)
output.write('\n')
temporary.chmod(0o600)
os.replace(temporary, target)
+13 -1
View File
@@ -1,5 +1,5 @@
import {callable, definePlugin, routerHook, toaster} from '@decky/api';
import {ButtonItem, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';
import {ButtonItem, ConfirmModal, Navigation, PanelSection, PanelSectionRow, ToggleField, showModal, useParams} from '@decky/ui';
import {useEffect, useRef, useState} from 'react';
const getSetting = callable<[key: string, fallback: boolean], boolean>('get_setting');
@@ -10,6 +10,7 @@ function Content() {
const [busy, setBusy] = useState(true);
const [message, setMessage] = useState('Loading setting…');
const pending = useRef(false);
const resetModalOpen = useRef(false);
async function refresh() {
if (pending.current) return;
@@ -49,12 +50,23 @@ function Content() {
}
}
function confirmReset() {
if (pending.current || resetModalOpen.current) return;
const dialog = showModal(<ConfirmModal strTitle="Reset example setting?"
strDescription="This changes only the sample plugin's saved setting."
strOKButtonText="Reset" strCancelButtonText="Keep setting"
onOK={() => {void change(false);}} />);
resetModalOpen.current = true;
void dialog.ClosedPromise.then(() => {resetModalOpen.current = false;});
}
return <PanelSection title="Framey settings sample">
<PanelSectionRow>
<ToggleField label="Example setting" description="Stored by the Decky Python backend"
checked={enabled} disabled={busy} onChange={(value: boolean) => {void change(value);}} />
</PanelSectionRow>
<PanelSectionRow><ButtonItem disabled={busy} onClick={() => {void refresh();}}>Reload saved setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><ButtonItem disabled={busy} onClick={confirmReset}>Reset example setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><ButtonItem onClick={() => Navigation.Navigate('/framey-sample/details/saved-setting')}>About this setting</ButtonItem></PanelSectionRow>
<PanelSectionRow><p role="status">{message}</p></PanelSectionRow>
</PanelSection>;
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Rebuild the pinned GPL DeckyFileServer helper offline (Go 1.25.7 required)."""
import argparse
import gzip
import hashlib
import json
import os
from pathlib import Path
import subprocess
import tarfile
import tempfile
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT/'ui/vendor/deckyfileserver-1.2.0'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--go', default='go')
parser.add_argument('--output', type=Path, default=ASSETS)
args = parser.parse_args()
assert subprocess.check_output([args.go, 'version'], text=True).split()[2] == 'go1.25.7', 'Use Go 1.25.7'
assert hashlib.sha256((ASSETS/'source.tar.gz').read_bytes()).hexdigest() == '50d90a7328daa412bf963ec68bb4855a9ec0860a8475cdf7d2019e6c2507112d', 'Source archive digest mismatch'
args.output.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='framey-fileserver-build-') as temp:
base = Path(temp)
with tarfile.open(ASSETS/'source.tar.gz') as archive:
assert all(m.isfile() and m.name.startswith('source/') and '..' not in Path(m.name).parts
for m in archive.getmembers())
archive.extractall(base, filter='data')
env = dict(os.environ, CGO_ENABLED='0', GOOS='linux', GOPROXY='off', GOSUMDB='off', GOTOOLCHAIN='local',
GOCACHE=str(base/'cache'), GOMODCACHE=str(base/'modules'))
for arch, name in [('amd64', 'x86_64'), ('arm64', 'aarch64')]:
output = base/'backend'
subprocess.run([args.go, 'build', '-mod=vendor', '-trimpath', '-buildvcs=false',
'-ldflags=-s -w -buildid=', '-o', str(output), '.'], cwd=base/'source',
env=dict(env, GOARCH=arch), check=True)
data = output.read_bytes()
packed = gzip.compress(data, mtime=0)
(args.output/f'{name}.gz').write_bytes(packed)
print(json.dumps({'arch': name, 'sha256': hashlib.sha256(data).hexdigest(),
'gzipSha256': hashlib.sha256(packed).hexdigest(), 'bytes': len(data)}), flush=True)
if __name__ == '__main__':
main()
+17 -3
View File
@@ -86,13 +86,23 @@ 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.', '',
'The private service, files, test listeners and processes were removed; independent cleanup audit passed and battery stayed 99 → 99. '
'A [shared Toggle follow-up](evidence/framey-toggle-2026-10-27.md) separates standalone Toggle.value from ToggleField.checked. '
'Nine official archives consume this API; their remaining blockers and status labels are preserved. '
'Local opaque Chromium controls and the exact Pause Games frontend callback pass. Actual published Decky 3.3.0 discovery and '
'private settings/exclusion-list persistence pass on amd64 and the ARM64 Frame through the normal CEF bridge. '
'Process control and worn VR remain unverified; independent cleanup audit passed.', '',
'## 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.', '',
@@ -113,8 +123,12 @@ def render(data):
p['backend']+('; '+', '.join(native) if native else ''),p['hardware']]))+' |')
lines += ['', '## Native payload portability', '',
'All published Linux native payloads in this snapshot are x86_64; no published aarch64 payload was found. '
'The three Windows DLLs belong to Decky-Framegen. The installer still rejects these archives. '
'Apart from the explicitly described LocalSend experiment, ARM64 builds were not attempted: the constraints below remain unresolved.', '',
'The three Windows DLLs belong to Decky-Framegen. Except for the exact managed DeckyFileServer adaptation below, the installer still rejects these archives. '
'Apart from LocalSend and DeckyFileServer, ARM64 builds were not attempted: the constraints below remain unresolved.', '',
'A [DeckyFileServer ARM64 follow-up](evidence/framey-fileserver-arm64-2026-10-27.md) ships an offline, digest-checked GPL helper build, corresponding vendored source and a build recipe. '
'Only its exact managed archive can replace the x86 helper and remove its unnecessary root flag. Actual Decky 3.3.0 discovery/RPC and real Frame HTTPS private-fixture listing/download pass; uploads stay disabled. '
'Hostname-DNS failures and empty optional arguments are repaired. Decky 3.3.0 still exposes `decky.HOME`; that call is unchanged. '
'The plugin moves from broken to partial: its frontend advertises HTTP/steamdeck URLs and needs a Steam folder picker. Use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR remain unverified.', '',
'[LocalSend ARM64 build/runtime evidence](evidence/framey-localsend-arm64-2026-10-26.md) '
'covers a reproducible MIT helper build and its isolated real-Frame protocol 2.0 info response. '
'The official x86 archive remains blocked; full plugin integration and file transfers are unverified.', '',
+4 -2
View File
@@ -40,7 +40,8 @@ def check_coverage():
raise ValueError('Managed shims must declare a literal name for runtime test coverage')
shims.add(node.args[0].value)
cases = json.loads((ROOT/'tests/decky-runtime/cases.json').read_text())
if shims != {case['shim'] for case in cases}:
# Frontend-only compatibility probes need no archive/backend adaptation.
if shims != {case['shim'] for case in cases if case['shim'] is not None}:
raise ValueError('Every managed shim needs an actual Decky runtime case')
return cases
@@ -83,7 +84,8 @@ def main():
loader.write_bytes(data)
loader.chmod(0o644) # Private container user copies/chmods its own executable.
probes = [(case['id'], None) for case in cases] + [
(111, '--reproduce-bound-method-regression'), (96, '--reproduce-timer-regression')]
(111, '--reproduce-bound-method-regression'), (96, '--reproduce-timer-regression'),
(60, '--reproduce-fileserver-empty-flag-regression')]
for plugin_id, negative in probes:
name = 'framey-decky-'+uuid.uuid4().hex[:12]
command = ['docker', 'run', '--rm', '--name', name, '--network', 'none', '--read-only',
+28 -2
View File
@@ -71,13 +71,19 @@ def exercise(profile, plugin_id, folder):
if not hit:
raise RuntimeError('Native click hit another element: ' + str(panel.eval('({tag:__vrLastTarget?.tagName,text:__vrLastTarget?.textContent?.slice(0,100)})')))
def click_parent(selector):
def parent_point(selector):
parent.eval(f'document.querySelector({json.dumps(selector)}).scrollIntoView({{block:"center"}})')
time.sleep(.3)
pos = parent.eval(f'''(()=>{{let r=document.querySelector({json.dumps(selector)}).getBoundingClientRect();
return {{x:r.x+r.width/2,y:r.y+r.height/2,w:innerWidth,h:innerHeight}}}})()''')
sx, sy = vr.snapshot()['input']['scale']
x, y = pos['x'] * sx / pos['w'], sy - pos['y'] * sy / pos['h']
if not 0 <= x <= sx or not 0 <= y <= sy:
raise RuntimeError('Parent control is outside the visible viewport')
return x, y
def click_parent(selector):
x, y = parent_point(selector)
count = parent.eval('window.__vrParentClickCount')
vr.post_mouse(300, x, y)
vr.post_mouse(301, x, y, 1)
@@ -86,6 +92,21 @@ def exercise(profile, plugin_id, folder):
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
raise RuntimeError('Native click missed parent control ' + selector)
def hold_parent(selector, seconds):
x, y = parent_point(selector)
count = parent.eval('window.__vrParentClickCount')
vr.post_mouse(300, x, y)
try:
vr.post_mouse(301, x, y, 1)
time.sleep(seconds)
offset = panel.eval('document.getElementById("vr-scroll-probe").scrollTop')
finally:
vr.post_mouse(302, x, y, 1)
wait(lambda:parent.eval('window.__vrParentClickCount'), lambda n:n > count, seconds=3)
if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'):
raise RuntimeError('Native hold missed parent control ' + selector)
return offset
try:
parent.eval(f'(async()=>{{await refreshDecky();openFrameyPanel({plugin_id},"Framey VR Check");return true}})()')
wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready')
@@ -172,6 +193,10 @@ def exercise(profile, plugin_id, folder):
result['buttonScrollAfterUp'] = wait(
lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n == 0)
result['buttonScrollMoved'] = result['buttonScrollAfterDown'] > 0 and result['buttonScrollAfterUp'] == 0
result['holdScrollAfterDown'] = hold_parent('#frameyScrollDown', 1.2)
result['holdScrollAfterUp'] = hold_parent('#frameyScrollUp', 2.0)
result['holdScrollMoved'] = (result['holdScrollAfterDown'] > result['buttonScrollAfterDown']
and result['holdScrollAfterUp'] == 0)
panel.eval('document.getElementById("vr-scroll-probe").remove()')
panel.eval('document.getElementById("vr-controls").remove()')
# Scroll saved status into the visible iframe for compositor evidence.
@@ -179,7 +204,8 @@ def exercise(profile, plugin_id, folder):
result['finalOverlay'] = vr.snapshot()
result['passed'] = (result['trustedOverlayPointer'] and result['diskSetting'] == {'enabled':True}
and result['reopenedChecked'] and result['sliderValue'] != '10'
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved'])
and result['textValue'] == 'framey-ci' and result['buttonScrollMoved']
and result['holdScrollMoved'])
return result
except Exception as exc:
result['error'] = str(exc)
+18 -12
View File
@@ -77,19 +77,25 @@ 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):
latest_jobs = None
for number in range(1, attempt + 1):
data = api(f"actions/runs/{run['id']}/attempts/{number}/jobs?per_page=100")
jobs = data.get("jobs") or []
if data.get("total_count") != len(jobs):
raise GateError(f"run {run['id']} attempt {number} has more jobs than the gate inspected")
names = [job.get("name") for job in jobs]
if not jobs or len(names) != len(set(names)):
raise GateError(f"run {run['id']} attempt {number} has missing or duplicate jobs")
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']} attempt {number} has unfinished, failed, cancelled, or skipped jobs: {bad}")
latest_jobs = jobs
names = [job.get("name") for job in latest_jobs]
if 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)")
raise GateError(f"run {run['id']} has missing jobs in its latest attempt: {missing}")
print(f"gate passed: {run.get('html_url')} ({len(latest_jobs)} jobs, {attempt} attempts)")
def main(tag):
+15
View File
@@ -1,4 +1,9 @@
[
{
"id": 13,
"shim": null,
"probe": "toggle-settings"
},
{
"id": 12,
"shim": "music-control-system-dbus-env",
@@ -14,9 +19,19 @@
"shim": "shotty-click-app-dir",
"probe": "screenshots"
},
{
"id": 89,
"shim": "deckyspy-psutil-and-battery",
"probe": "system-monitor"
},
{
"id": 96,
"shim": "simple-timer-lifecycle",
"probe": "timer-lifecycle"
},
{
"id": 60,
"shim": "deckyfileserver-user-native",
"probe": "fileserver"
}
]
+85 -3
View File
@@ -7,6 +7,8 @@ import os
from pathlib import Path
import shutil
import signal
import socket
import ssl
import subprocess
import sys
import time
@@ -59,6 +61,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-fileserver-empty-flag-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'
@@ -81,13 +84,13 @@ 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,
record['sha256'], record['folder'], record['version'], '-'], input=frame_decky_store.apply_script(record['id'], record['sha256']),
capture_output=True, text=True, env=env, timeout=15)
assert install.returncode == 0, install.stderr
plugin = base/'plugins'/record['folder']
receipt = json.loads((plugin/'.frame-control-store.json').read_text())
if not args.reproduce_timer_regression:
assert receipt['compatShims'] == [case['shim']], receipt
assert receipt.get('compatShims', []) == ([case['shim']] if case['shim'] else []), receipt
else:
assert record['id'] == 96
with zipfile.ZipFile(io.BytesIO(data)) as original:
@@ -95,6 +98,11 @@ def main():
# Exercise the production manifest's API convention, never import Plugin ourselves.
manifest = json.loads((plugin/'plugin.json').read_text())
assert manifest.get('api_version', 'legacy') == record['api_version'], manifest
assert '_root' not in manifest.get('flags', []), manifest
if args.reproduce_fileserver_empty_flag_regression:
assert record['id'] == 60
source = plugin/'main.py'
source.write_text(source.read_text().replace('[arg for arg in [', '[').replace('] if arg],', '],'))
if args.reproduce_bound_method_regression:
assert record['id'] == 111
source = (plugin/'main.py').read_text()
@@ -139,7 +147,65 @@ def main():
report['calls'].append({'method': method, 'result': response['result']})
return response['result']
if case['probe'] == 'timer-lifecycle':
def current(method, *values):
response = rpc('loader/call_plugin_method', record['name'], method, *values)
report['calls'].append({'method': method, 'result': response})
return response
if case['probe'] == 'fileserver':
assert until_ready(lambda: current('get_port') == 8000, 'default port')
assert current('get_directory') == str(home)
assert current('get_server_running') is False
fixture = home/'public-fixture'
fixture.mkdir()
marker = b'Framey public read-only fixture\n'
(fixture/'marker.txt').write_bytes(marker)
# A thumbnail-compatible file must remain a normal link when disabled.
(fixture/'image.png').write_bytes(b'public placeholder; thumbnails disabled')
with socket.socket() as listener:
listener.bind(('127.0.0.1', 0))
port = listener.getsockname()[1]
status = current('set_status', {'directory': str(fixture), 'port': port, 'timeout': 1,
'allow_uploads': False, 'disable_thumbnails': True, 'server_running': True})
assert status['server_running'] and not status['allow_uploads'] and status['disable_thumbnails'], status
context = ssl._create_unverified_context() # Upstream ships its public self-signed test cert.
url = f'https://127.0.0.1:{port}'
def fetch(path):
return urllib.request.urlopen(url+path, context=context, timeout=3).read()
listing = until_ready(lambda: fetch('/files/'), 'private file server')
assert b'marker.txt' in listing and b'image.png' in listing, listing
assert (b'/preview/files/image.png' in listing) == args.reproduce_fileserver_empty_flag_regression, listing
assert fetch('/files/marker.txt') == marker
try:
fetch('/upload')
raise AssertionError('uploads must be disabled')
except urllib.error.HTTPError as error:
assert error.code == 401, error.code
assert current('set_server_running', False) is False
assert current('get_server_running') is False
assert current('get_history') == [str(fixture)]
report['result'] = ('original empty argument bug reproduced: thumbnail policy ignored'
if args.reproduce_fileserver_empty_flag_regression else
'discovery, API1 settings, native HTTPS listing/download, thumbnail policy and stop passed; uploads disabled')
elif case['probe'] == 'toggle-settings':
def call(method, *values):
result = rpc('loader/call_plugin_method', record['name'], method, *values)
report['calls'].append({'method': method, 'result': result})
return result
assert call('load_settings') == {}
# Settings only. Never call pause/resume/kill or signal a process.
for value in (True, False, True):
call('save_setting', 'autoPause', value)
assert call('load_settings')['autoPause'] is value
call('add_no_auto_pause_set', 424242)
assert call('load_settings')['noAutoPauseSet'] == [424242]
call('remove_no_auto_pause_set', 424242)
assert call('load_settings')['noAutoPauseSet'] == []
settings = json.loads((base/'settings'/record['folder']/'settings.json').read_text())
assert settings['autoPause'] is True and settings['noAutoPauseSet'] == []
report['result'] = 'discovery and Toggle-backed private settings persistence passed; no process actions'
elif case['probe'] == 'timer-lifecycle':
from timer_lifecycle import probe
def restart(settings=None):
@@ -194,6 +260,22 @@ 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
+21 -2
View File
@@ -107,9 +107,16 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertEqual((catalogue[0]['version'], catalogue[0]['sha256']), ('1.0.0', HASHES[0]))
status, _, _ = api('POST', '/api/decky/plugin', {'action':'install','id':999})
self.assertEqual(status, 400) # Third-party code needs explicit acknowledgement.
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':999,'acknowledged':True}))
request = {'action':'install','id':999,'acknowledged':True,
'requestId':'0123456789abcdef' * 2}
started = ok('POST', '/api/decky/plugin', request)
self.assertEqual(ok('POST', '/api/decky/plugin', request)['job'], started['job'])
status, conflict, _ = api('POST', '/api/decky/plugin', {**request, 'id':998})
self.assertEqual(status, 409)
self.assertIn('already used', conflict['error'])
job = finished(started)
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('POST', '/api/decky/plugin', request)['job'], started['job'])
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
receipt = HASHES[0]
@@ -195,10 +202,22 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertEqual(
ssh(f'sha256sum {installed}/dist/{chunk}').split()[0],
hashlib.sha256(expected_chunk).hexdigest())
if plugin['id'] == 60:
self.assertEqual(json.loads(ssh(f'cat {installed}/plugin.json'))['flags'], [])
receipt = json.loads(ssh(f'cat {installed}/.frame-control-store.json'))
self.assertEqual(receipt['compatShims'], ['deckyfileserver-user-native'])
import frame_decky_store
arch = ssh('uname -m').strip()
self.assertEqual(ssh(f'sha256sum {installed}/bin/backend').split()[0],
frame_decky_store.FILESERVER_BINARIES[arch])
if plugin['id'] == 53:
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'])
+8 -3
View File
@@ -91,17 +91,22 @@ elif method == "GET" and endpoint.startswith(repo + "actions/workflows/"):
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,
"head_branch": branch, "event": "push", "run_attempt": gate.get("release_attempts", 1) if run_id == 502 else 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]
parts = endpoint[len(repo + "actions/runs/"):].split("/")
run_id = parts[0]
attempt = int(parts[2])
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"}
"conclusion": (gate.get("prior_bad_job_conclusion", "failure")
if run_id == "502" and attempt == 1 and name == gate.get("prior_bad_job")
else 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?"):
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15 -2
View File
@@ -1,13 +1,16 @@
# Pinned Decky store test archives
These are unmodified ZIPs downloaded from the official Decky store CDN on
2026-10-25, except `96.zip` and `111.zip` downloaded on 2026-10-26. Each ZIP includes its
upstream `LICENSE`. They are test data, not plugins shipped or installed by
2026-10-25, except `96.zip` and `111.zip` downloaded on 2026-10-26 and `60.zip` on
2026-10-27; `13.zip` was added from the digest-verified audit cache on 2026-10-27.
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 |
| --- | --- | --- | --- |
| `13.zip` | Pause Games | 1.0.3 | `4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62` |
| `60.zip` | DeckyFileServer | 1.2.0 | `7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0` |
| `96.zip` | Simple Timer | 1.0.0 | `973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312` |
| `7.zip` | CSS Loader | 2.1.3 | `99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231` |
| `12.zip` | MusicControl | 1.1.6 | `246b89fd653c60a735b1cc401d1b0937d5cb969eea9024561661ff741f081d62` |
@@ -48,3 +51,13 @@ RPC and events in the native isolated runtime gate. Cancel/replace/completion an
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.
Pause Games runs through actual published Decky 3.3.0 discovery and instance RPC
for private settings and a synthetic exclusion list. No process-control method
is called. The official frontend's standalone Toggle component separately
checks saved true/false state and matching settings callbacks; effects are inert.
DeckyFileServer also runs through actual Decky 3.3.0 discovery/API1 RPC with a
native helper replacement. HTTPS listing/download use only public markers in a
private directory; uploads remain disabled. The exact source build, GPL licenses
and reproducible recipe are in [the native assets](../../../ui/vendor/deckyfileserver-1.2.0/README.md).
The original empty optional-argument bug is reproduced by a negative runtime case.
+6
View File
@@ -4,6 +4,12 @@ from pathlib import Path
ROOT = Path(__file__).parent / 'fixtures/framey-official'
PLUGINS = (
{'id': 13, 'name': 'Pause Games', 'version': '1.0.3', 'api_version': 1,
'folder': 'SDH-PauseGames', 'sha256': '4aefdfb0ef25dea9c6d9fd037731c8b99ee8a1be966c2f7d5b0602112ef29f62'},
{'id': 60, 'name': 'DeckyFileServer', 'version': '1.2.0', 'api_version': 1,
'folder': 'DeckyFileServer', 'sha256': '7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0'},
{'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'},
+60
View File
@@ -46,6 +46,22 @@ const nodes=(tree,type)=>Array.isArray(tree)?tree.flatMap(child=>nodes(child,typ
[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))];
// Disabled controls stay inert even if a caller invokes their handlers directly.
let changed=0;
// DFL.Toggle is a standalone value-controlled input. ToggleField uses checked.
const toggleChanges=[];
for(const value of [true,false]) {
const input=nodes(window.DFL.Toggle({value,onChange:v=>toggleChanges.push(v)}),'input')[0];
assert.equal(input.props.checked,value,'Toggle must render its value prop');
input.props.onChange({target:{checked:!value}});
}
assert.deepEqual(toggleChanges,[false,true]);
const disabledToggle=nodes(window.DFL.Toggle({value:true,disabled:true,
onChange:()=>changed++}),'input')[0];
assert.equal(disabledToggle.props.checked,true);
assert.equal(disabledToggle.props.disabled,true);
disabledToggle.props.onChange({target:{checked:false}});
assert.equal(changed,0);
const fieldToggle=nodes(window.DFL.ToggleField({checked:true,value:false}),'input')[0];
assert.equal(fieldToggle.props.checked,true,'ToggleField must retain its checked contract');
const toggle=nodes(window.DFL.ToggleField({label:'Enabled',checked:true,disabled:true,
onChange:()=>changed++}),'input')[0];
const options=[{label:'Modes',options:[{label:'Slow',data:'slow'},{label:'Fast',data:'fast'}]}];
@@ -253,6 +269,14 @@ nodes(tabs,'button')[2].props.onClick();
assert.deepEqual(tabChanges,['one']);
assert.equal(window.DFL.Spinner({}).props.role,'status');
assert.equal(window.DFL.ProgressBar({nProgress:120}).props.value,100);
assert.equal(window.DFL.ProgressBar({nProgress:40,indeterminate:true}).props.value,undefined,
'an indeterminate progress element must omit its value');
assert.equal(window.DFL.ProgressBarItem({nProgress:35}).props.value,35);
const progressInfo=window.DFL.ProgressBarWithInfo({indeterminate:true,
sOperationText:'Downloading plugin'});
assert.equal(nodes(progressInfo,'progress')[0].props.value,undefined);
assert.equal(nodes(progressInfo,'small')[0].props.role,'status');
assert.equal(nodes(progressInfo,'small')[0].children[0],'Downloading plugin');
assert.equal(window.DFL.joinClassNames('one',null,'two'),'one two');
let eventCount=0;
const unsubscribe=window.SteamClient.GameSessions.RegisterForAppLifetimeNotifications(()=>eventCount++);
@@ -464,11 +488,47 @@ assert.equal(window.DFL.useParams().view,'art');
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art/extra'),false);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123?admin=1'),false);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game//art'),false);
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(window.DFL.useParams().appid,'123');
assert.equal(window.DFL.useParams().view,undefined,'Back returns to the previous local page');
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(window.DFL.useParams().appid,undefined,'A second Back returns to the plugin root');
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123/art'),true);
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(window.DFL.useParams().view,undefined,'Navigating to the same page did not add a history entry');
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(window.DFL.useParams().appid,undefined);
assert.throws(()=>api.routerHook.addRoute('/plugin/:id/:id',GameRoute));
assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
api.routerHook.addRoute('/plugin/other',LocalRoute);
assert.equal(window.DFL.Navigation.Navigate('/plugin/game/123'),true);
assert.equal(window.DFL.Navigation.Navigate('/plugin/other'),true);
removeGame();
assert.equal(window.DFL.Navigation.NavigateBack(),true);
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0,
'Back skips a route that the plugin unregistered');
assert.equal(window.DFL.useParams().appid,undefined);
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
api.routerHook.removeRoute('/plugin/other');
api.routerHook.addRoute('/plugin/first',LocalRoute);
api.routerHook.addRoute('/plugin/second',LocalRoute);
for(let index=0;index<40;index++)
assert.equal(window.DFL.Navigation.Navigate(index%2 ? '/plugin/first' : '/plugin/second'),true);
for(let index=0;index<32;index++)window.DFL.Navigation.NavigateBack();
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,1);
window.DFL.Navigation.NavigateBack();
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0,
'local route history is bounded and falls back to the plugin root');
window.DFL.Navigation.Navigate('/plugin/first');
window.DFL.Navigation.Navigate('/plugin/second');
window.DFL.Navigation.CloseSideMenus();
window.DFL.Navigation.NavigateBack();
assert.equal(nodes(window.__surface({plugin}),LocalRoute).length,0,
'CloseSideMenus clears the local route history');
api.routerHook.removeRoute('/plugin/first');
api.routerHook.removeRoute('/plugin/second');
// Event subscription is acknowledged before a method that immediately emits.
const onMessage=listeners.find(x=>x.type==='message').handler;
+50 -1
View File
@@ -5,6 +5,7 @@ import vm from 'node:vm';
const html=readFileSync(new URL('../../ui/headset.html',import.meta.url),'utf8');
const source=html.match(/<script>([\s\S]*?)<\/script>/)[1].split('refresh();refreshGames();')[0];
const elements=new Map(),listeners=[],requests=[],replies=[];
const timeouts=new Map(),intervals=new Map();let nextTimer=0;
const el=id=>{if(!elements.has(id))elements.set(id,{removeAttribute(){},scrollIntoView(){}});return elements.get(id);};
const frame={postMessage:data=>replies.push(data)};
el('frameyFrame').contentWindow=frame;
@@ -12,7 +13,10 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
location:{hash:'',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem:()=>null,setItem(){}},localStorage:{getItem:()=>null,setItem(){}},
window:{addEventListener:(type,fn)=>listeners.push({type,fn})},
setTimeout(){},setInterval(){},console,
setTimeout(fn){const id=++nextTimer;timeouts.set(id,fn);return id;},
clearTimeout(id){timeouts.delete(id);},
setInterval(fn){const id=++nextTimer;intervals.set(id,fn);return id;},
clearInterval(id){intervals.delete(id);},console,
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
vm.runInContext(source,context);
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};openFrameyPanel(96,"Timer")',context);
@@ -101,13 +105,58 @@ el('frameyScrollDown').onclick();
assert.equal(replies.at(-1).type,'framey:scroll');
assert.equal(replies.at(-1).session,scrollSession);
assert.equal(replies.at(-1).direction,'down');
const down=el('frameyScrollDown');
const beforeHold=replies.length;
down.onpointerdown({button:0,isPrimary:true,pointerId:7});
assert.equal(replies.length,beforeHold+1,'pointer press scrolls once');
down.onclick({detail:1});
assert.equal(replies.length,beforeHold+1,'pointer click must not scroll twice');
timeouts.get(nextTimer)();
assert.equal(intervals.size,1);
intervals.get(nextTimer)();
assert.equal(replies.length,beforeHold+2,'held pointer scrolls again');
down.onpointerup({pointerId:7});
assert.equal(intervals.size,0,'release stops scrolling');
down.onclick({detail:0});
assert.equal(replies.length,beforeHold+3,'keyboard click scrolls once');
await send({type:'framey:scroll:result',session:'old',direction:'down',moved:true});
assert.equal(el('frameyPanelStatus').textContent,'Loading plugin panel…');
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:true});
assert.equal(el('frameyPanelStatus').textContent,'Scrolled plugin down');
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
down.onpointerdown({button:0,isPrimary:true,pointerId:8});
timeouts.get(nextTimer)();
assert.equal(intervals.size,1);
await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false});
assert.equal(intervals.size,0,'end of content stops held scrolling');
down.onpointerdown({button:0,isPrimary:true,pointerId:9});
const lastHoldTimer=nextTimer;
assert.equal(timeouts.has(lastHoldTimer),true);
listeners.find(({type})=>type==='blur').fn();
assert.equal(timeouts.has(lastHoldTimer),false,'loss of panel focus cancels a held scroll');
// WindowProxy also survives panel navigation for lifecycle and input messages.
// An old iframe must not mark a replacement ready, hide its error, or record
// controller evidence for the new panel.
const beforeStatusRequests=requests.length;
for(const type of ['framey:ready','framey:input','framey:error']) {
await send({type,session:'old',message:'old panel failed'});
await send({type,message:'missing session'});
}
assert.equal(requests.length,beforeStatusRequests);
assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction');
await send({type:'framey:ready',session:scrollSession});
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel ready');
assert.equal(requests.at(-1).path,'/api/framey/check');
assert.equal(requests.at(-1).body.kind,'panel');
await send({type:'framey:input',session:scrollSession});
assert.equal(requests.at(-1).body.kind,'input');
await send({type:'framey:error',session:scrollSession,message:'current panel failed'});
assert.equal(el('frameyPanelStatus').textContent,'Plugin panel unavailable: current panel failed');
down.onpointerdown({button:0,isPrimary:true,pointerId:10});
const closeTimer=nextTimer;
el('frameyClose').onclick();
assert.equal(timeouts.has(closeTimer),false,'closing the panel cancels a held scroll');
const replyCount=replies.length;
el('frameyScrollUp').onclick();
assert.equal(replies.length,replyCount);
+32 -1
View File
@@ -8,6 +8,7 @@ const bundle=readFileSync(new URL('../../examples/framey-settings/dist/index.js'
assert.match(bundle,/SP_JSX\.jsx/);
const React={Fragment:Symbol('fragment'),Component:class {},
createElement:(type,props,...children)=>({type,props:props||{},children}),
cloneElement:(element,props)=>({...element,props:{...element.props,...props}}),
useState:value=>[value,()=>{}],useRef:value=>({current:value}),
useEffect(){},isValidElement:value=>!!value?.type};
class Element {
@@ -20,13 +21,15 @@ const elements=new Map(), roots=new Map();
const document={getElementById:id=>{
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);
},createElement:()=>new Element()};
const window={React,ReactDOM:{createRoot:node=>{
const window={React,location:{hash:'#sample-session'},ReactDOM:{createRoot:node=>{
const root={render(value){this.value=value;},unmount(){this.unmounted=true;}};
roots.set(node,root);return root;
}},addEventListener(){}};
const listeners=[];
const calls=[];
const messages=[];
const parent={postMessage(message){
messages.push(message);
if(message.type!=='framey:call')return;
calls.push(message);
listeners.find(x=>x.type==='message').handler({source:parent,
@@ -36,6 +39,12 @@ const context=vm.createContext({window,document,
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(host,context);
const pointerup=listeners.find(listener=>listener.type==='pointerup').handler;
pointerup({isTrusted:false});
assert.equal(messages.some(message=>message.type==='framey:input'),false);
pointerup({isTrusted:true});
assert.equal(messages.at(-1).type,'framey:input');
assert.equal(messages.at(-1).session,'sample-session');
Object.assign(context,{SP_REACT:window.SP_REACT,SP_JSX:window.SP_JSX,DFL:window.DFL});
vm.runInContext(bundle.replace(/export \{ index as default \};/, 'window.sample = index;'),context);
const plugin=window.sample();
@@ -71,6 +80,28 @@ toggle.props.onChange(false);
await new Promise(resolve=>setImmediate(resolve));
assert.equal(calls.filter(call=>call.method==='set_setting').length,2,
'a completed save must release the sample setting lock');
// The sample reset is a real Framey modal: Cancel cannot write, OK writes the
// sample setting only after explicit confirmation.
const reset=content.children[0][2].children[0];
const setCalls=()=>calls.filter(call=>call.method==='set_setting');
const dialog=()=>roots.get(elements.get('framey-modal')).value[0].children[1];
reset.props.onClick();
reset.props.onClick();
assert.equal(roots.get(elements.get('framey-modal')).value.length,1,
'a rapid second tap must not stack reset dialogs');
assert.equal(dialog().props.strTitle,'Reset example setting?');
let rendered=dialog().type(dialog().props);
rendered.children.find(child=>child?.children?.[0]==='Keep setting').props.onClick();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(setCalls().length,2,'cancel leaves the saved value untouched');
assert.equal(roots.get(elements.get('framey-modal')).value,null);
reset.props.onClick();
rendered=dialog().type(dialog().props);
rendered.children.find(child=>child?.children?.[0]==='Reset').props.onClick();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(setCalls().length,3);
assert.deepEqual(Array.from(setCalls().at(-1).args),['enabled',false]);
assert.equal(roots.get(elements.get('framey-modal')).value,null);
assert.equal(window.DFL.Navigation.Navigate('/framey-sample/details/saved-setting'),true);
assert.equal(window.DFL.useParams().topic,'saved-setting');
assert.equal(window.DFL.Navigation.Navigate('/library/home'),false);
+85 -1
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import {webcrypto} from 'node:crypto';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
@@ -17,6 +18,7 @@ const document = {getElementById(id) {if(!elements.has(id)) elements.set(id,new
createElement() {return new Element();}, hidden:false};
const session = new Map();
const ctx = vm.createContext({document, location:{hash:'',pathname:'/'},
crypto:webcrypto,
sessionStorage:{getItem:key=>session.get(key)||'',setItem:(key,value)=>session.set(key,value),removeItem:key=>session.delete(key)},
history:{replaceState(){}}, confirm:()=>true,
window:{addEventListener(){}}, setInterval(){}, setTimeout(){},
@@ -61,6 +63,45 @@ assert.equal(el('deckyJobProgress').value,100);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
// If the server accepts the POST but its response is lost, the panel already
// has the ID needed to find the job. It must not offer a second install.
let lostJobId, lostPosts=0;
ctx.mockApi=async (path,body) => {
if(path==='/api/decky/plugin'){
lostPosts++; lostJobId=body.requestId;
assert.match(lostJobId,/^[0-9a-f]{32}$/);
throw Error('POST response lost');
}
if(path.startsWith('/api/job')){
assert.equal(new URL(path,'http://localhost').searchParams.get('id'),lostJobId);
return {done:true,message:'Recovered install'};
}
if(path==='/api/decky')return {managed:true,running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(JSON.parse(session.get('frameyPluginJob')).job,lostJobId);
assert.equal(el('deckyJobCheck').hidden,false);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(lostPosts,1);
await vm.runInContext('checkDeckyJob()',ctx);
assert.equal(el('deckyJobStatus').textContent,'Recovered install');
assert.equal(session.has('frameyPluginJob'),false);
// A definite validation rejection never reserved a job on the server.
ctx.mockApi=async path => {
if(path==='/api/decky/plugin'){
const error=Error('Invalid reviewed store version');error.status=400;throw error;
}
if(path==='/api/decky')return {managed:true,running:true,plugins:[]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"bad"})',ctx);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyJobCheck').hidden,true);
// A stopped Decky service must still offer recovery by removing its owned plugin.
ctx.mockApi=async path => path==='/api/decky' ?
{managed:true,running:false,plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]} :
@@ -149,7 +190,7 @@ ctx.mockApi=async path => {
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
assert.match(el('deckyJobStatus').textContent,/server has no record of this request/);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
@@ -244,3 +285,46 @@ vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('frameyPanel').hidden,true);
assert.match(el('deckyState').textContent,/panel was closed until status can be refreshed/);
// A denied sessionStorage must not lose an accepted job in the current panel.
// Keep Update/Remove disabled after a polling outage and allow Check result.
for(const method of ['getItem','setItem','removeItem'])
ctx.sessionStorage[method]=()=>{throw Error('storage denied');};
let accepted=0;
ctx.mockApi=async path => {
if(path==='/api/decky/plugin'){accepted++;return {job:'memory-job'};}
if(path.startsWith('/api/job'))throw Error('connection lost');
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi;deckyState={managed:true,running:true,plugins:[]};' +
'deckyStore=[{id:2,name:"Plugin 2",version:"1.0",description:"A panel",root:false}];showDeckyStore()',ctx);
await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".repeat(64)})',ctx);
assert.equal(accepted,1);
assert.equal(vm.runInContext('deckyJobPending()',ctx),true);
assert.equal(el('deckyJobCheck').hidden,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,true);
await vm.runInContext('deckyChange("remove",{id:2,name:"Plugin 2"})',ctx);
assert.equal(accepted,1);
ctx.mockApi=async path => {
if(path.startsWith('/api/job'))return {done:true,message:'Installed Plugin 2'};
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'1.0'}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.equal(vm.runInContext('deckyJobPending()',ctx),false);
assert.equal(el('deckyJobCheck').hidden,true);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
// Opening the headset panel with a key in its URL must survive denied storage.
const deniedDocument={getElementById:id=>new Element(id),createElement:()=>new Element()};
const deniedBoot=vm.createContext({document:deniedDocument,
location:{hash:'#private-key',pathname:'/'},history:{replaceState(){}},
sessionStorage:{getItem(){throw Error('storage denied');},setItem(){throw Error('storage denied');}},
window:{addEventListener(){}},setInterval(){},setTimeout(){},confirm:()=>true,console,
fetch(){throw Error('No network expected');}});
vm.runInContext(script,deniedBoot);
assert.equal(vm.runInContext('key',deniedBoot),'private-key');
+46
View File
@@ -0,0 +1,46 @@
// Exact official Pause Games component and settings callbacks; effects/process
// actions never execute here. Actual Decky discovery/RPC has a separate gate.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
const bundle=readFileSync(process.argv[2],'utf8');
const official=bundle.replace(/export \{ index as default \};/,
'window.__toggleTest = {AppItem, Settings};');
assert.notEqual(official,bundle);
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
const host=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n})();');
assert.notEqual(host,source);
const React={Fragment:'fragment',Component:class {},
createElement:(type,props,...children)=>({type,props:props||{},children}),
createContext:()=>({}),useState:value=>[value,()=>{}],useEffect(){},
useRef:value=>({current:value}),useMemo:fn=>fn(),useCallback:fn=>fn};
const messages=[],listeners=[];
const parent={postMessage:message=>messages.push(message)};
const window={React,ReactDOM:{createRoot:()=>({render(){}})},location:{hash:''}};
const context=vm.createContext({window,parent,navigator:{language:'en'},
document:{getElementById:()=>({})},console,URL,Blob,
addEventListener:(type,handler)=>listeners.push({type,handler}),setTimeout:()=>1,clearTimeout(){}});
vm.runInContext(host,context);
Object.assign(context,{DFL:window.DFL,SP_REACT:React});
vm.runInContext(official,context,{timeout:2000});
const {AppItem,Settings}=window.__toggleTest;
const find=(node,type)=>node?.type===type?node:(node?.children||[]).flat(Infinity)
.map(child=>find(child,type)).find(Boolean);
for(const excluded of [false,true]) {
vm.runInContext(`window.__toggleTest.Settings.data.noAutoPauseSet=new Set(${excluded?'[424242]':'[]'});`,context);
const row=AppItem({app:{appid:'424242',display_name:'Private fixture'},autoPause:true});
const toggle=find(row,window.DFL.Toggle);
assert.ok(toggle);
const input=window.DFL.Toggle(toggle.props);
assert.equal(input.props.checked,!excluded,'official persisted exclusion must display correctly');
input.props.onChange({target:{checked:excluded}});
const call=messages.at(-1);
assert.equal(call.type,'framey:call');
assert.equal(call.method,excluded?'remove_no_auto_pause_set':'add_no_auto_pause_set');
assert.deepEqual(Array.from(call.args),[424242]);
assert.equal(Settings.data.noAutoPauseSet.has(424242),!excluded);
listeners.find(x=>x.type==='message').handler({source:parent,
data:{type:'framey:reply',id:call.id,result:null}});
}
assert.equal(messages.filter(x=>x.type==='framey:call').length,2);
+86 -1
View File
@@ -65,6 +65,15 @@ class ArchiveTests(unittest.TestCase):
return_value=io.BytesIO(json.dumps(records).encode())):
self.assertEqual([(p['id'], p['root']) for p in frame_decky_store.catalog()], [(42, True)])
def test_only_exact_fileserver_adaptation_clears_store_root(self):
records = [{'id': 60, 'name': 'DeckyFileServer', 'tags': ['root'],
'versions': [{'name': '1.2.0', 'hash': frame_decky_store.FILESERVER_DIGEST}]}]
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertFalse(frame_decky_store.catalog()[0]['root'])
records[0]['versions'][0]['hash'] = 'a'*64
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
self.assertTrue(frame_decky_store.catalog()[0]['root'])
def test_accepts_hashed_non_root_archive(self):
data, digest = archive()
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
@@ -129,7 +138,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, text=True, capture_output=True,
input=frame_decky_store.apply_script(plugin_id, digest), text=True, capture_output=True,
env=self.env, timeout=15)
finally:
source.unlink(missing_ok=True)
@@ -161,6 +170,82 @@ class StoreTransaction(unittest.TestCase):
self.assertNotEqual(result.returncode, 0)
self.assertTrue(external.exists())
def test_modified_fileserver_archive_does_not_inherit_exception(self):
from framey_official import PLUGINS, archive as official_archive
record = next(p for p in PLUGINS if p['id'] == 60)
original = official_archive(record)
with zipfile.ZipFile(io.BytesIO(original)) as source:
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w', zipfile.ZIP_DEFLATED) as target:
for entry in source.infolist():
target.writestr(entry, source.read(entry))
target.writestr(record['folder']+'/changed.txt', b'new version')
data = stream.getvalue()
with self.assertRaisesRegex(ValueError, 'non-ARM64'):
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), record['name'])
result = self.apply('install', data, plugin_id=60, folder=record['folder'], version=record['version'])
# Remote defence rejects root even when the desktop validation is bypassed.
self.assertNotEqual(result.returncode, 0)
self.assertIn('Root', result.stderr)
def test_corrupt_fileserver_offline_helper_is_rejected(self):
with patch.object(frame_decky_store, 'FILESERVER_BINARIES', {'x86_64': '0'*64}):
with self.assertRaisesRegex(ValueError, 'SHA-256 mismatch'):
frame_decky_store.apply_script(60, frame_decky_store.FILESERVER_DIGEST)
self.assertEqual(frame_decky_store.apply_script(60, '0'*64), frame_decky_store.APPLY)
def test_exact_fileserver_gets_native_user_helper(self):
from framey_official import PLUGINS, archive as official_archive
import platform
if platform.machine() not in frame_decky_store.FILESERVER_BINARIES:
self.skipTest('native Linux helper architecture')
record = next(p for p in PLUGINS if p['id'] == 60)
data = official_archive(record)
self.assertEqual(frame_decky_store.archive_info(data, record['sha256'], record['name']), record['folder'])
result = self.apply('install', data, plugin_id=60, folder=record['folder'], version=record['version'])
self.assertEqual(result.returncode, 0, result.stderr)
plugin = self.folder.parent/record['folder']
self.assertEqual(json.loads((plugin/'plugin.json').read_text())['flags'], [])
self.assertEqual(json.loads((plugin/'.frame-control-store.json').read_text())['compatShims'], ['deckyfileserver-user-native'])
helper = plugin/'bin/backend'
self.assertEqual(hashlib.sha256(helper.read_bytes()).hexdigest(), frame_decky_store.FILESERVER_BINARIES[platform.machine()])
self.assertEqual(helper.stat().st_mode & 0o777, 0o700)
compile((plugin/'main.py').read_text(), str(plugin/'main.py'), 'exec')
self.assertIn('if arg]', (plugin/'main.py').read_text())
@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_simple_timer_gets_lifecycle_repair_and_keeps_api1_and_license(self):
from framey_official import PLUGINS, archive as official_archive
plugin = next(p for p in PLUGINS if p['id'] == 96)
+5
View File
@@ -26,6 +26,11 @@ 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 filename in ('aarch64.gz', 'x86_64.gz', 'source.tar.gz', 'LICENSE.txt', 'GO-LICENSE.txt', 'README.md'):
self.assertIn('ui/vendor/deckyfileserver-1.2.0/'+filename, names)
self.assertIn('scripts/build-framey-fileserver.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
@@ -0,0 +1,59 @@
"""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,6 +59,17 @@ 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_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']),
+2 -1
View File
@@ -19,7 +19,8 @@ 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', 'timer-lifecycle'))
self.assertIn(case['probe'], ('mpris', 'screenshots', 'system-monitor', 'timer-lifecycle',
'toggle-settings', 'fileserver'))
def test_new_shim_without_runtime_case_fails_coverage(self):
import frame_decky_store
+18
View File
@@ -21,6 +21,16 @@ from framey_official import LEGACY_PLUGINS, PLUGINS, archive
class OfficialBundles(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the official frontend check')
def test_pause_games_toggle_renders_saved_value_and_dispatches_settings_only(self):
plugin = next(p for p in PLUGINS if p['id'] == 13)
with zipfile.ZipFile(io.BytesIO(archive(plugin))) as bundle, tempfile.TemporaryDirectory() as root:
frontend = Path(root)/'index.js'
frontend.write_bytes(bundle.read(plugin['folder']+'/dist/index.js'))
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_toggle.mjs'), str(frontend)],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_legacy_css_loader_store_archive_has_unversioned_frontend(self):
plugin = LEGACY_PLUGINS[0]
data = archive(plugin)
@@ -74,6 +84,14 @@ class OfficialBundles(unittest.TestCase):
folder = plugins / plugin['folder']
(folder / '.frame-control-store.json').write_text(json.dumps({
'storeId': plugin['id'], 'sha256': plugin['sha256']}))
if plugin['id'] == 60:
# This renderer fixture extracts the original ZIP. The
# real managed adaptation is covered by transaction and
# published Decky tests; an unadapted root copy must fail.
with patch.object(framey_panel, 'PLUGINS', plugins), self.assertRaises(FileNotFoundError):
framey_panel.panel(plugin['id'])
manifest['flags'] = []
(folder/'plugin.json').write_text(json.dumps(manifest))
with patch.object(framey_panel, 'PLUGINS', plugins):
page = framey_panel.panel(plugin['id']).decode()
encoded = re.search(r'<script id="framey-bundle" type="application/json"[^>]*>([^<]+)</script>', page)
+40 -1
View File
@@ -1,6 +1,7 @@
"""The author example stores settings as a normal Decky user plugin."""
import asyncio
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
@@ -36,7 +37,8 @@ class SamplePlugin(unittest.TestCase):
self.assertTrue(asyncio.run(first.set_setting('enabled', True)))
second = backend(folder)
self.assertTrue(asyncio.run(second.get_setting('enabled', False)))
self.assertTrue((Path(folder) / 'settings.json').is_file())
self.assertEqual(json.loads((Path(folder) / 'settings.json').read_text()),
{'schema': 1, 'enabled': True})
def test_backend_rejects_unknown_keys_and_non_boolean_values(self):
with tempfile.TemporaryDirectory() as folder:
@@ -57,6 +59,43 @@ class SamplePlugin(unittest.TestCase):
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
def test_legacy_boolean_survives_restart_and_migrates_on_save(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
path.write_text('{"enabled": true}\n')
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
self.assertEqual(json.loads(path.read_text()), {'enabled': True},
'Reading must not rewrite a user setting')
self.assertTrue(asyncio.run(backend(folder).set_setting('enabled', False)))
self.assertEqual(json.loads(path.read_text()), {'schema': 1, 'enabled': False})
self.assertFalse(asyncio.run(backend(folder).get_setting('enabled', True)))
def test_newer_schema_is_preserved_on_read_and_save(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
original = '{"schema": 2, "enabled": true, "future": "keep"}\n'
path.write_text(original)
plugin = backend(folder)
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
asyncio.run(plugin.get_setting('enabled', False))
with self.assertRaisesRegex(ValueError, 'unsupported sample version'):
asyncio.run(plugin.set_setting('enabled', False))
self.assertEqual(path.read_text(), original)
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
def test_failed_atomic_replace_keeps_previous_setting(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'settings.json'
plugin = backend(folder)
self.assertTrue(asyncio.run(plugin.set_setting('enabled', True)))
previous = path.read_bytes()
with patch('os.replace', side_effect=OSError('storage unavailable')):
with self.assertRaisesRegex(OSError, 'storage unavailable'):
asyncio.run(plugin.set_setting('enabled', False))
self.assertEqual(path.read_bytes(), previous)
self.assertEqual(list(Path(folder).glob('.settings-*')), [])
self.assertTrue(asyncio.run(backend(folder).get_setting('enabled', False)))
if __name__ == '__main__':
unittest.main()
+65
View File
@@ -0,0 +1,65 @@
"""Store requests can reconnect when the headset panel loses the POST response."""
import sandbox # noqa: F401
import sys
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import server
class StoreJobs(unittest.TestCase):
def test_same_request_id_runs_once_and_conflicting_reuse_is_rejected(self):
request_id = 'a' * 32
started = threading.Event()
finish = threading.Event()
calls = []
def change(_ssh, action, plugin_id, report, reviewed_hash=None):
calls.append((action, plugin_id, reviewed_hash))
started.set()
self.assertTrue(finish.wait(5))
report('Installed', 100)
return {'message': 'Installed once'}
body = {'action': 'install', 'id': 2, 'sha256': 'b' * 64,
'acknowledged': True, 'requestId': request_id}
try:
with patch.object(server.frame_decky_store, 'change', side_effect=change):
first = server.decky_plugin(body)
self.assertTrue(started.wait(5))
self.assertEqual(server.decky_plugin(body), first)
for changed in ({'action': 'update'}, {'id': 3}, {'sha256': 'c' * 64}):
with self.subTest(changed=changed), self.assertRaises(server.Failure) as error:
server.decky_plugin({**body, **changed})
self.assertEqual(error.exception.status, 409)
self.assertEqual(calls, [('install', 2, 'b' * 64)])
self.assertNotIn('_request', server.job_status('id=' + request_id))
finish.set()
for _ in range(100):
if server.job_status('id=' + request_id)['done']:
break
time.sleep(0.01)
self.assertEqual(server.job_status('id=' + request_id)['message'], 'Installed once')
self.assertEqual(server.decky_plugin(body), first)
self.assertEqual(len(calls), 1)
finally:
finish.set()
with server._jobs_lock:
server._jobs.pop(request_id, None)
def test_invalid_request_id_does_not_start_work(self):
with patch.object(server.frame_decky_store, 'change') as change:
for request_id in (1, '', 'A' * 32, 'f' * 31, 'f' * 33, '../' * 11):
with self.subTest(request_id=request_id), self.assertRaises(server.Failure) as error:
server.decky_plugin({'action': 'install', 'id': 2,
'acknowledged': True, 'requestId': request_id})
self.assertEqual(error.exception.status, 400)
change.assert_not_called()
if __name__ == '__main__':
unittest.main()
+11
View File
@@ -134,6 +134,17 @@ class PublishRelease(unittest.TestCase):
self.assertIsNone(upload)
self.assertFalse(any("-X" in call for call in log))
def test_refuses_a_prior_cancelled_installer_after_successful_rerun(self):
gates = {"release_attempts": 2,
"prior_bad_job": "build (macos-latest, dist, app/dist/*.dmg app/dist/*.zip)",
"prior_bad_job_conclusion": "cancelled"}
p, log, upload = self.run_script([draft()], "v9.8.7", gates=gates)
self.assertNotEqual(p.returncode, 0)
self.assertIn("attempt 1", p.stderr)
self.assertIn("cancelled", 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"},
+164 -4
View File
@@ -3,14 +3,18 @@
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 gzip
import hashlib
import io
import json
import os
from pathlib import Path
import re
import secrets
import urllib.request
import zipfile
from pathlib import Path
import frame_decky
import framey_compatibility
@@ -23,6 +27,13 @@ MAX_ZIP = 50 * 1024 * 1024
MAX_UNPACKED = 150 * 1024 * 1024
SAFE_FOLDER = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\Z')
SHA = re.compile(r'[0-9a-f]{64}\Z')
FILESERVER_DIGEST = '7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0'
FILESERVER_BINARIES = {
'x86_64': '5e90efd1bf5163bef1686045f4714ad0e4fd0f54edb037fce8cdb06c58177bcb',
'aarch64': '86b80021ebd62ed619eba065757c1122defa5a74b2c045aa44a0c6b4403a508a',
}
FILESERVER_ORIGINAL = 'd9eab8559c87c01736188bc676f3b4b44dee47cf952b6e571c770d1ea61733e9'
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
@@ -68,6 +79,8 @@ def catalog():
counts[item['id']] = counts.get(item['id'], 0) + 1
unique = [item for item in result if counts[item['id']] == 1]
for item in unique:
if item['id'] == 60 and item['name'] == 'DeckyFileServer' and item['sha256'] == FILESERVER_DIGEST:
item['root'] = False # Exact managed adaptation runs as the Frame user.
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
return unique
@@ -86,6 +99,8 @@ def archive_info(data, expected_hash, expected_name):
folder = manifests[0].split('/')[0]
if not SAFE_FOLDER.fullmatch(folder):
raise ValueError('Invalid plugin folder')
fileserver = (expected_hash == FILESERVER_DIGEST and expected_name == 'DeckyFileServer'
and folder == 'DeckyFileServer')
seen = {}
for entry in files:
name = entry.filename
@@ -115,7 +130,9 @@ def archive_info(data, expected_hash, expected_name):
with archive.open(entry) as source:
header = source.read(20)
if header[:4] == b'\x7fELF' and int.from_bytes(header[18:20], 'little') != 183:
raise ValueError('Plugin contains a non-ARM64 Linux binary: ' + name)
if not (fileserver and name == 'DeckyFileServer/bin/backend'
and hashlib.sha256(archive.read(entry)).hexdigest() == FILESERVER_ORIGINAL):
raise ValueError('Plugin contains a non-ARM64 Linux binary: ' + name)
if header[:2] == b'MZ':
raise ValueError('Plugin contains a Windows binary: ' + name)
manifest = json.loads(archive.read(manifests[0]))
@@ -126,13 +143,48 @@ def archive_info(data, expected_hash, expected_name):
flags = manifest.get('flags', [])
if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags):
raise ValueError('Invalid plugin flags')
if 'root' in flags or '_root' in flags:
if ('root' in flags or '_root' in flags) and not fileserver:
raise ValueError('This plugin needs root and is not supported by the Frame user service')
return folder
DECKYSPY_DIGEST = '2d431a89826ed5cf3fd37258373eb3b46c841c39795aa8b25f83e59f4eee87c7'
PSUTIL_DIGESTS = {
'x86_64': '076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9',
'aarch64': 'b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e',
}
def apply_script(plugin_id, digest):
"""Deliver verified offline dependencies only to exact managed archives."""
if plugin_id == 60 and digest == FILESERVER_DIGEST:
assets = Path(__file__).parent/'vendor/deckyfileserver-1.2.0'
payloads = {}
for arch, expected in FILESERVER_BINARIES.items():
packed = (assets/f'{arch}.gz').read_bytes()
data = gzip.decompress(packed)
if hashlib.sha256(data).hexdigest() != expected:
raise ValueError('Bundled DeckyFileServer SHA-256 mismatch')
payloads[arch] = base64.b64encode(packed).decode('ascii')
# Python stdin, never shell arguments. The Frame rechecks hash and ELF machine.
return ('FRAMEY_FILESERVER_BINARIES = '+repr(payloads)+'\n'
+'FRAMEY_FILESERVER_HASHES = '+repr(FILESERVER_BINARIES)+'\n'+APPLY)
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 fcntl,hashlib,json,os,pathlib,shutil,subprocess,sys,zipfile
import base64,fcntl,gzip,hashlib,io,json,os,pathlib,platform,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'
@@ -174,6 +226,14 @@ try:
files=z.infolist()
if not files or len(files)>3000 or sum(f.file_size for f in files)>150*1024*1024:
raise RuntimeError('Plugin archive too large')
fileserver=(int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer')
manifest=json.loads(z.read(folder+'/plugin.json'))
if not isinstance(manifest,dict): raise RuntimeError('Invalid plugin manifest')
flags=manifest.get('flags',[])
if not isinstance(flags,list) or not all(isinstance(flag,str) for flag in flags):
raise RuntimeError('Invalid plugin flags')
if ('root' in flags or '_root' in flags) and not fileserver:
raise RuntimeError('Root plugins are not supported by the Frame user service')
seen={}
for f in files:
parts=f.filename.split('/')
@@ -194,6 +254,13 @@ try:
z.read(prior)!=z.read(f)):
raise RuntimeError('Unsafe plugin archive')
else: seen[f.filename]=f
if not f.is_dir():
with z.open(f) as source: header=source.read(20)
if header[:4]==b'\x7fELF' and int.from_bytes(header[18:20],'little')!=183:
if not (fileserver and f.filename==folder+'/bin/backend' and
hashlib.sha256(z.read(f)).hexdigest()=='d9eab8559c87c01736188bc676f3b4b44dee47cf952b6e571c770d1ea61733e9'):
raise RuntimeError('Plugin contains a non-ARM64 Linux binary')
if header[:2]==b'MZ': raise RuntimeError('Plugin contains a Windows binary')
stage.mkdir(mode=0o700)
z.extractall(stage)
# zipfile drops execute bits. Legacy plugins also publish shebang
@@ -209,6 +276,54 @@ try:
unpacked=stage/folder
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
compat_shims=[]
# GPL source and reproducible build recipe ship with Frame Control.
# The exact official helper is x86_64; this plugin needs no root.
if int(plugin_id)==60 and digest=='7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0' and folder=='DeckyFileServer':
helper=unpacked/'bin/backend'
source=unpacked/'main.py'
if hashlib.sha256(helper.read_bytes()).hexdigest()!='d9eab8559c87c01736188bc676f3b4b44dee47cf952b6e571c770d1ea61733e9':
raise RuntimeError('DeckyFileServer original helper mismatch')
if hashlib.sha256(source.read_bytes()).hexdigest()!='4fb60745c083465d198d84e1abb0afdb59695d9eec3c873b0b35247676af05c3':
raise RuntimeError('DeckyFileServer Python source mismatch')
arch=platform.machine()
machine={'aarch64':183,'x86_64':62}.get(arch)
hashes=globals().get('FRAMEY_FILESERVER_HASHES',{})
packed=globals().get('FRAMEY_FILESERVER_BINARIES',{}).get(arch)
if not machine or not packed: raise RuntimeError('Missing native DeckyFileServer helper')
native=gzip.decompress(base64.b64decode(packed,validate=True))
if (hashlib.sha256(native).hexdigest()!=hashes.get(arch) or native[:4]!=b'\x7fELF'
or int.from_bytes(native[18:20],'little')!=machine):
raise RuntimeError('DeckyFileServer native helper mismatch')
helper.write_bytes(native)
helper.chmod(0o700)
manifest_path=unpacked/'plugin.json'
manifest=json.loads(manifest_path.read_text())
if manifest.get('name')!='DeckyFileServer' or manifest.get('api_version')!=1 or manifest.get('flags')!=['_root']:
raise RuntimeError('DeckyFileServer manifest mismatch')
manifest['flags']=[]
manifest['publish']['tags']=[tag for tag in manifest['publish']['tags'] if tag not in ('root','_root')]
manifest_path.write_text(json.dumps(manifest)+'\n')
text=source.read_text()
# Go flag parsing stops at an empty positional argument. Filter
# omitted boolean flags so -disablethumbnails actually takes effect.
text=text.replace(' ],\n stdout=PIPE,',
' ] if arg],\n stdout=PIPE,')
text=text.replace(' [\n f"{decky.DECKY_PLUGIN_DIR}',
' [arg for arg in [\n f"{decky.DECKY_PLUGIN_DIR}')
# Hostname DNS is not guaranteed on Frame OS. A UDP connect
# selects the kernel source address without sending any packet.
text=text.replace(' return socket.gethostbyname(socket.gethostname())',
' try:\n return socket.gethostbyname(socket.gethostname())\n'
' except OSError:\n with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:\n'
' try:\n probe.connect(("192.0.2.1", 9))\n'
' return probe.getsockname()[0]\n'
' except OSError:\n return "127.0.0.1"')
# Configure thumbnail policy before launching the helper.
old=' if "server_running" in status:\n await Plugin.set_server_running(self, status["server_running"])\n if "disable_thumbnails" in status:\n await Plugin.set_disable_thumbnails(self, status["disable_thumbnails"])'
new=' if "disable_thumbnails" in status:\n await Plugin.set_disable_thumbnails(self, status["disable_thumbnails"])\n if "server_running" in status:\n await Plugin.set_server_running(self, status["server_running"])'
if old not in text: raise RuntimeError('DeckyFileServer start configuration changed')
source.write_text(text.replace(old,new))
compat_shims.append('deckyfileserver-user-native')
# 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.
@@ -322,6 +437,51 @@ 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
@@ -437,7 +597,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, timeout=90))
stdin=apply_script(plugin_id, plugin["sha256"]), timeout=90))
finally:
if data is not None:
try: ssh('rm -f ' + remote, timeout=10)
+4 -1
View File
@@ -163,7 +163,10 @@ def bundle():
for path in sorted(folder.rglob('*')):
if not path.is_file() or path.is_symlink() or '__pycache__' in path.parts:
continue
if path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md') and path.name not in ('ssh',):
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):
continue
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(ROOT).as_posix())
+10 -1
View File
@@ -137,7 +137,16 @@ class Handler(BaseHTTPRequestHandler):
return self.reply(403, {'error': 'Forbidden'})
try:
size = int(self.headers.get('Content-Length', '0'))
if not 0 < size <= 1024:
if size > 1024:
# Windows can reset a connection closed with an unread POST
# body before the client receives our 400 response. Drain only
# a bounded rejected body; never buffer an unbounded payload.
if size <= 64 * 1024:
self.rfile.read(size)
else:
self.close_connection = True
raise ValueError('Invalid request size')
if size <= 0:
raise ValueError('Invalid request size')
body = json.loads(self.rfile.read(size))
if not isinstance(body, dict):
+89 -20
View File
@@ -183,7 +183,7 @@
"https://github.com/suchmememanyskill/SDH-CssLoader.git"
],
"status": "partial",
"reason": "Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell.",
"reason": "Panel initialization succeeds; CSS themes patch Steam UI and cannot theme the isolated VR shell. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": false,
"verification": [
"static archive",
@@ -238,6 +238,11 @@
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
"archiveValidation": "accepted",
"result": "installed-removed"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -584,12 +589,13 @@
"https://github.com/wynn1212/SDH-PauseGames.git"
],
"status": "partial",
"reason": "Settings and running-game list initialize in local Chromium with an explicit synthetic game; live Frame empty-list reads and lifetime registration/cleanup passed. Process pause/resume, automatic pausing and suspend integration remain unverified/unsupported; backend writes were denied.",
"reason": "Settings and a synthetic running-game list render locally. Actual Decky 3.3.0 discovery, private settings writes/readback and exclusion-list persistence pass on amd64 and ARM64 Frame; standalone Toggle now honors its value prop. Live empty-list/lifetime checks passed earlier. Real process pause/resume, automatic pausing, suspend integration and worn VR remain unverified/unsupported.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"real Frame SSH"
],
"browser": {
"id": 13,
@@ -622,6 +628,11 @@
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
"mode": "synthetic nonempty running/library replay; backend and network denied",
"frontendReady": true
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": true
}
},
{
@@ -1962,7 +1973,7 @@
"https://github.com/jurassicplayer/decky-autoflatpaks.git"
],
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service.",
"reason": "This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -2004,6 +2015,11 @@
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -4332,13 +4348,15 @@
"https://github.com/SteamDeckHomebrew/decky-plugin-template.git",
"https://github.com/grimkor/DeckyFileServer"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend.",
"blockInstall": true,
"status": "partial",
"reason": "Managed install supplies a native ARM64 helper and removes the exact archive's unnecessary root request. Real Decky 3.3.0 on Frame passes private read-only HTTPS listing/download and upload denial. Frontend address/picker limitations remain; use HTTPS and a preconfigured directory. Uploads, generated thumbnails and worn VR are unverified. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": false,
"verification": [
"static archive",
"local browser",
"fake-Frame"
"fake-Frame",
"real Decky 3.3.0 amd64",
"real Frame SSH"
],
"browser": {
"id": 60,
@@ -4357,7 +4375,7 @@
},
"backend": "Python + native payload",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"nativePort": "Native file-server backend needs ARM64 dependencies; root requirement also blocks the user service.",
"nativePort": "GPL-3.0 helper rebuilt offline for ARM64 with vendored corresponding source. Actual Frame user service passes HTTPS listing/download of a private public fixture; uploads disabled. Exact managed digest only; other native/root archives remain blocked (docs/evidence/framey-fileserver-arm64-2026-10-27.md).",
"uiSurfaces": [],
"fakeFrame": {
"id": 60,
@@ -4365,6 +4383,20 @@
"archiveValidation": "Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"requiredShims": [
"deckyfileserver-user-native"
],
"managedNative": {
"shim": "deckyfileserver-user-native",
"helperSha256": "86b80021ebd62ed619eba065757c1122defa5a74b2c045aa44a0c6b4403a508a",
"sourceCommit": "dcf3f3ce5104d05811fa8c156c4f58632b84b313",
"verification": "Real Decky discovery/public RPC and private read-only HTTPS fixture; docs/evidence/framey-fileserver-arm64-2026-10-27.md"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -4791,7 +4823,7 @@
"https://github.com/theCapypara/steamdeck-decky-syncthing.git"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog.",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -4832,6 +4864,11 @@
"archiveValidation": "Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog",
"result": "blocked",
"reason": "ValueError: Plugin contains a non-ARM64 Linux binary: decky-syncthing/bin/decky-syncthing-watchdog."
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -6985,13 +7022,14 @@
"https://github.com/Seraphli/decky-spy",
"https://github.com/Seraphli/decky-spy.git"
],
"status": "partial",
"reason": "Initializer succeeds; account tokens and external service integration were not exercised.",
"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.",
"blockInstall": false,
"verification": [
"static archive",
"fake-Frame",
"local browser",
"fake-Frame"
"real Frame SSH"
],
"browser": {
"id": 89,
@@ -7011,8 +7049,8 @@
"accepted": true,
"folder": "decky-spy"
},
"backend": "Python",
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
"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.",
"uiSurfaces": [
"toasts"
],
@@ -7026,7 +7064,18 @@
"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,
@@ -9818,7 +9867,7 @@
"https://github.com/xXJSONDeruloXx/lsfg-vk"
],
"status": "partial",
"reason": "Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated.",
"reason": "Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": false,
"verification": [
"static archive",
@@ -9867,6 +9916,11 @@
"evidence": "docs/evidence/framey-running-context-2026-10-26.json",
"mode": "synthetic nonempty running/library replay; backend and network denied",
"frontendReady": true
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -10188,7 +10242,7 @@
"https://github.com/wtlnetwork/muon.git"
],
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service.",
"reason": "This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -10232,6 +10286,11 @@
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -11215,7 +11274,7 @@
"https://github.com/casey/just"
],
"status": "broken",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object.",
"reason": "Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -11249,6 +11308,11 @@
"archiveValidation": "Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello",
"result": "blocked",
"reason": "ValueError: Plugin contains a non-ARM64 Linux binary: decky-launch-options/bin/hello. Frontend audit failed: Cannot convert undefined or null to object."
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
@@ -11533,7 +11597,7 @@
"https://github.com/tikh-iv/ShadowDeck.git"
],
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service.",
"reason": "This plugin needs root and is not supported by the Frame user service. Shared Toggle value rendering is repaired; other listed blockers remain.",
"blockInstall": true,
"verification": [
"static archive",
@@ -11571,6 +11635,11 @@
"archiveValidation": "This plugin needs root and is not supported by the Frame user service",
"result": "blocked",
"reason": "ValueError: Root plugins are not supported by the Frame user service"
},
"toggleContract": {
"verified": "local browser control fixture; official Pause Games callback regression",
"evidence": "docs/evidence/framey-toggle-2026-10-27.md",
"backendVerified": false
}
},
{
+37 -12
View File
@@ -56,6 +56,7 @@
const pending = new Map();
const routes = new Map();
const routeListeners = new Set();
const routeHistory = [];
let currentRoute = null;
let currentParams = {};
let nextId = 0;
@@ -190,7 +191,7 @@
parent.postMessage({type:'framey:events:close', session:cacheSession}, '*');
});
addEventListener('pointerup', event => {
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
if (event.isTrusted) parent.postMessage({type:'framey:input', session:cacheSession}, '*');
}, {passive:true});
addEventListener('pointermove', event => {
if (event.isTrusted) lastScrollTarget = scrollTarget(event.target);
@@ -219,6 +220,11 @@
};
const Field = ({label, description, children}) => R.createElement('label', null,
R.createElement('strong', null, label), description && R.createElement('small', null, description), children);
// DFL/@decky/ui Toggle uses value; ToggleField uses checked. Keep the
// standalone control separate so persisted true settings render correctly.
const Toggle = ({value, disabled, onChange}) => R.createElement('input', {
type:'checkbox', checked:!!value, disabled:!!disabled,
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}});
const ToggleField = ({label, description, checked, disabled, onChange}) => R.createElement(Field, {label, description},
R.createElement('input', {type:'checkbox', checked:!!checked, disabled:!!disabled,
onChange:e=>{if(!disabled)onChange?.(e.target.checked);}}));
@@ -278,8 +284,10 @@
title && R.createElement('h2', null, title), R.createElement(Tabs, {
tabs:pages.filter(page=>page.visible!==false).map((page,index)=>({
id:page.route || String(index), title:page.title, content:page.content}))}));
const ProgressBar = ({nProgress=0, value, max=100}) => R.createElement('progress', {
value:Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max});
const ProgressBar = ({nProgress=0, value, max=100, indeterminate=false}) => R.createElement('progress', {
value:indeterminate ? undefined : Math.max(0,Math.min(max,Number(value ?? nProgress)||0)), max});
const ProgressBarWithInfo = ({sOperationText, ...props}) => R.createElement('div', null,
ProgressBar(props), sOperationText == null ? null : R.createElement('small', {role:'status'}, sOperationText));
const MenuItem = ({children, disabled, bDisabled, onClick, onSelected}) =>
R.createElement('button', {type:'button',disabled:!!(disabled || bDisabled),
onClick:event=>{if(!disabled && !bDisabled)(onClick || onSelected)?.(event);}}, children);
@@ -368,15 +376,31 @@
}
return null;
}
function navigate(path) {
if (path !== null && !localPath(path)) return false;
const match = path === null ? null : activeRoute(path);
if (path !== null && !match) return false;
function showRoute(path, match) {
currentRoute = path;
currentParams = match?.params || {};
for (const listener of routeListeners) listener(path);
return true;
}
function navigate(path) {
if (path !== null && !localPath(path)) return false;
const match = path === null ? null : activeRoute(path);
if (path !== null && !match) return false;
if (path === null) routeHistory.length = 0;
else if (path !== currentRoute) {
routeHistory.push(currentRoute);
if (routeHistory.length > 32) routeHistory.shift();
}
return showRoute(path, match);
}
function navigateBack() {
while (routeHistory.length) {
const previous = routeHistory.pop();
const match = previous === null ? null : activeRoute(previous);
if (previous === null || match) return showRoute(previous, match);
}
return navigate(null);
}
const routerHook = {
addRoute(path, component) {
if (!routePattern(path) ||
@@ -476,7 +500,7 @@
Focusable, ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'),
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
Toggle: ToggleField, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
Toggle, Spinner, SteamSpinner:Spinner, Tabs, SidebarNavigation,
SuspensefulImage:({alt='', ...props})=>R.createElement('img', {...props, alt}),
Marquee:passthrough('span'), DialogBody:passthrough('div'), DialogBodyText:passthrough('p'),
DialogHeader:passthrough('h2'), DialogSubHeader:passthrough('h3'), DialogFooter:passthrough('footer'),
@@ -484,14 +508,14 @@
DialogControlsSectionHeader:passthrough('h3'), ModalRoot:passthrough('section'),
ModalPosition:passthrough('div'),
Menu:passthrough('div'), MenuGroup:passthrough('section'), MenuItem,
ProgressBar, ProgressBarWithInfo:ProgressBar,
ProgressBar, ProgressBarItem:ProgressBar, ProgressBarWithInfo,
gamepadDialogClasses:{}, quickAccessMenuClasses:{}, gamepadTabbedPageClasses:{},
gamepadSliderClasses:{}, gamepadContextMenuClasses:{}, scrollPanelClasses:{},
quickAccessControlsClasses:{}, mainMenuAppRunningClasses:{}, libraryAssetImageClasses:{},
joinClassNames:(...names)=>names.filter(Boolean).join(' '),
sleep:ms=>new Promise(resolve=>setTimeout(resolve,Math.max(0,Number(ms)||0))),
staticClasses: {Title:'framey-title'},
Navigation: {Navigate:navigate, NavigateBack:()=>navigate(null), CloseSideMenus:()=>navigate(null),
Navigation: {Navigate:navigate, NavigateBack:navigateBack, CloseSideMenus:()=>navigate(null),
NavigateToExternalWeb: url => { if (/^https:\/\//.test(url)) window.open(url, '_blank', 'noopener'); }},
showModal, showContextMenu:showModal,
// Steam app, library, and store patches have no target inside this panel.
@@ -829,10 +853,11 @@
if (!plugin?.content && !plugin?.settings) throw Error('The Decky plugin has no panel to display');
window.ReactDOM.createRoot(root).render(R.createElement(PanelBoundary, null,
R.createElement(PluginSurface, {plugin})));
parent.postMessage({type:'framey:ready'}, '*');
parent.postMessage({type:'framey:ready', session:cacheSession}, '*');
} catch (failure) {
error.textContent = `This Decky panel is not compatible with Framey: ${failure.message}`;
parent.postMessage({type:'framey:error', message:failure.message}, '*');
parent.postMessage({type:'framey:error', session:cacheSession,
message:failure.message}, '*');
}
}
start();
+42
View File
@@ -0,0 +1,42 @@
"""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
+86 -23
View File
@@ -55,6 +55,7 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
<div class="row" style="margin-top:14px"><button id="deckyStorePrevious" class="secondary">Previous</button><span id="deckyStorePage"></span><button id="deckyStoreNext" class="secondary">Next</button></div></div>
<div id="frameyPanel" hidden><div class="row" style="margin-top:16px"><strong id="frameyTitle"></strong><button id="frameyClose" class="secondary">Close plugin panel</button>
<button id="frameyScrollUp" class="secondary">Scroll plugin up</button><button id="frameyScrollDown" class="secondary">Scroll plugin down</button></div>
<p><small>Hold a scroll button to keep moving through plugin content.</small></p>
<p id="frameyCompatibility"></p>
<p id="frameyPanelStatus" role="status">Loading plugin panel…</p>
<iframe id="frameyFrame" title="Framey Decky plugin panel" sandbox="allow-scripts allow-popups" referrerpolicy="no-referrer"></iframe></div>
@@ -64,14 +65,15 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
<div id="extensionPanel" hidden><div class="row" style="margin-top:16px"><strong id="extensionTitle"></strong><button id="extensionClose" class="secondary">Close extension</button></div>
<iframe id="extensionFrame" title="Frame Control extension" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div>
<script>
const key=location.hash.slice(1)||sessionStorage.getItem('frameHeadsetKey')||'';
if(key)sessionStorage.setItem('frameHeadsetKey',key);
const key=location.hash.slice(1)||(()=>{try{return sessionStorage.getItem('frameHeadsetKey')||'';}catch(_){return '';}})();
if(key)try{sessionStorage.setItem('frameHeadsetKey',key);}catch(_){}
history.replaceState(null,'',location.pathname);
const byId=id=>document.getElementById(id);
let latestStatus=null, activeExtension=null, recordBusy=false;
async function api(path,body){const response=await fetch(path,{method:body?'POST':'GET',
headers:{'X-Frame-UI':key,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const data=await response.json();if(!response.ok)throw Error(data.error||'Frame request failed');return data;}
const data=await response.json();if(!response.ok){const error=Error(data.error||'Frame request failed');
error.status=response.status;throw error;}return data;}
async function refresh(){try{const status=latestStatus=await api('/api/status');
byId('battery').textContent=status.battery?.percent==null?'Unavailable':`${status.battery.percent}%`;
const free=status.disk?.home?.free;
@@ -106,7 +108,24 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
}catch(error){list.textContent=error.message;}}
function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.status === 'na' ? 'N/A' : c.status}: ${c.reason}${c.verification?.length ? ' Verified: '+c.verification.join(', ')+'.' : ''}` : 'Unverified on Frame';}
function deckyJobPending(){return !!sessionStorage.getItem('frameyPluginJob');}
// Some Chromium profiles deny sessionStorage. Keep the reserved request ID in
// memory so this open panel can still reconnect if the POST response is lost.
let deckyPendingJob=null;
function savedDeckyJob(){
if(deckyPendingJob)return deckyPendingJob;
try{return JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){return null;}
}
function rememberDeckyJob(job){
deckyPendingJob=job;
try{sessionStorage.setItem('frameyPluginJob',JSON.stringify(job));}catch(_){}
}
function clearDeckyJob(){
deckyPendingJob=null;
try{sessionStorage.removeItem('frameyPluginJob');}catch(_){}
}
function deckyJobPending(){return !!savedDeckyJob();}
function newDeckyRequestId(){return Array.from(crypto.getRandomValues(new Uint8Array(16)),
byte=>byte.toString(16).padStart(2,'0')).join('');}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
deckyStatusRequest=0,
activeFrameyId=null, activeFrameyReceipt=null, frameySession=null, deckyBusy=false;
@@ -174,7 +193,7 @@ function deckyJobView(title,message,percent,failed=false){
byId('deckyJobPanel').hidden=false;byId('deckyJobTitle').textContent=title;
byId('deckyJobStatus').textContent=message;
const bar=byId('deckyJobProgress');if(Number.isFinite(percent))bar.value=percent;else bar.removeAttribute('value');
byId('deckyJobCheck').hidden=!failed||!sessionStorage.getItem('frameyPluginJob');
byId('deckyJobCheck').hidden=!failed||!deckyJobPending();
}
async function followDeckyJob(job,action,name){
for(let n=0;n<180;n++){
@@ -182,12 +201,12 @@ async function followDeckyJob(job,action,name){
try{progress=await api(`/api/job?id=${encodeURIComponent(job)}`);}
catch(error){
if(/no such job/i.test(error.message)){
sessionStorage.removeItem('frameyPluginJob');
throw Error('The app restarted and lost this job result. Refresh installed plugins to see what changed.');
clearDeckyJob();
throw Error('The server has no record of this request. It may not have arrived, or the app may have restarted. Refresh installed plugins before trying again.');
}
throw error;
}
if(progress.done){if(progress.error){sessionStorage.removeItem('frameyPluginJob');throw Error(progress.error);}
if(progress.done){if(progress.error){clearDeckyJob();throw Error(progress.error);}
deckyJobView(`${action} ${name}`,progress.message||`${action} complete`,100);return;}
deckyJobView(`${action} ${name}`,progress.stage||'Waiting for Frame…',progress.percent);
await new Promise(resolve=>setTimeout(resolve,1000));
@@ -207,27 +226,31 @@ async function deckyChange(action,plugin){if(deckyBusy)return;
if(panelClosed)closeFrameyPanel();
deckyJobView(`${action} ${plugin.name}`,
panelClosed?'Starting… Old plugin panel closed until the change finishes.':'Starting…',0);
const requestId=newDeckyRequestId();
rememberDeckyJob({job:requestId,action,name:plugin.name});
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true,requestId});
if(!started.job)throw Error('The server did not return a job ID');
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
rememberDeckyJob({job:started.job,action,name:plugin.name});
await followDeckyJob(started.job,action,plugin.name);
sessionStorage.removeItem('frameyPluginJob');
clearDeckyJob();
if(panelNote)byId('deckyJobStatus').textContent+=panelNote;
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
}catch(error){if(error.status>=400&&error.status<500)clearDeckyJob();
deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
finally{deckyBusy=false;await refreshDecky();}
}
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
async function checkDeckyJob(){const saved=savedDeckyJob();
if(deckyBusy)return;
if(!saved||typeof saved.job!=='string'){sessionStorage.removeItem('frameyPluginJob');showDeckyStore();return;}
if(!saved||typeof saved.job!=='string'){clearDeckyJob();showDeckyStore();return;}
deckyBusy=true;byId('deckyJobCheck').hidden=true;
try{await followDeckyJob(saved.job,saved.action,saved.name);sessionStorage.removeItem('frameyPluginJob');}
try{await followDeckyJob(saved.job,saved.action,saved.name);clearDeckyJob();}
catch(error){deckyJobView(`${saved.action} ${saved.name}`,`Error: ${error.message}`,null,true);}
finally{deckyBusy=false;await refreshDecky();}}
byId('deckyJobCheck').onclick=checkDeckyJob;
function openFrameyPanel(id,name){
if(deckyBusy||deckyJobPending()||!deckyState?.running)return;
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
stopFrameyScrollHold();
byId('frameyCompatibility').textContent=compatibilityText(installed);
closeFrameyEvents();
activeFrameyId=id;activeFrameyReceipt={sha256:installed.sha256,version:installed.version};
@@ -249,15 +272,52 @@ function closeFrameyEvents(){
session:frameySession,events:[],cursor:0}).catch(()=>{});
frameyEventsActive=false;
}
function closeFrameyPanel(){closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
function closeFrameyPanel(){stopFrameyScrollHold();closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;}
byId('frameyClose').onclick=closeFrameyPanel;
function scrollFramey(direction){
if(activeFrameyId===null||!frameySession||!['up','down'].includes(direction))return;
byId('frameyFrame').contentWindow?.postMessage({type:'framey:scroll',session:frameySession,direction},'*');
if(activeFrameyId===null||!frameySession||!['up','down'].includes(direction))return false;
const target=byId('frameyFrame').contentWindow;if(!target)return false;
target.postMessage({type:'framey:scroll',session:frameySession,direction},'*');
return true;
}
byId('frameyScrollUp').onclick=()=>scrollFramey('up');
byId('frameyScrollDown').onclick=()=>scrollFramey('down');
let frameyScrollHold=null;
function stopFrameyScrollHold(){
if(!frameyScrollHold)return;
clearTimeout(frameyScrollHold.delay);
clearInterval(frameyScrollHold.repeat);
frameyScrollHold=null;
}
function bindFrameyScroll(id,direction){
const button=byId(id);
button.onpointerdown=event=>{
if(event.button!==0||event.isPrimary===false)return;
stopFrameyScrollHold();
if(!scrollFramey(direction))return;
const hold={button,direction,pointerId:event.pointerId,delay:null,repeat:null};
frameyScrollHold=hold;
hold.delay=setTimeout(()=>{
if(frameyScrollHold!==hold)return;
hold.repeat=setInterval(()=>{
if(frameyScrollHold!==hold)return;
if(!scrollFramey(direction))stopFrameyScrollHold();
},180);
},400);
};
const stop=event=>{
if(frameyScrollHold?.button===button&&frameyScrollHold.pointerId===event.pointerId)
stopFrameyScrollHold();
};
button.onpointerup=stop;
button.onpointercancel=stop;
button.onpointerleave=stop;
// Pointerdown already sent one step. Keyboard activation emits a click with
// detail 0; direct onclick calls from older tests also mean one step.
button.onclick=event=>{if(!event||event.detail===0)scrollFramey(direction);};
}
bindFrameyScroll('frameyScrollUp','up');
bindFrameyScroll('frameyScrollDown','down');
window.addEventListener('blur',stopFrameyScrollHold);
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
function frameyCacheRead(id){
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
@@ -282,6 +342,7 @@ window.addEventListener('message',async event=>{
if(['up','down'].includes(req.direction)&&typeof req.moved==='boolean')
byId('frameyPanelStatus').textContent=req.moved?'Scrolled plugin '+req.direction:
'No more plugin content in that direction';
if(req.moved===false&&frameyScrollHold?.direction===req.direction)stopFrameyScrollHold();
return;
}
if(req.type==='framey:cache:init'&&req.session===frameySession){
@@ -292,10 +353,12 @@ window.addEventListener('message',async event=>{
if(req.session===frameySession)frameyCacheSave(activeFrameyId,req.entries);
return;
}
if(req.type==='framey:ready'){byId('frameyPanelStatus').textContent='Plugin panel ready';
if(req.type==='framey:ready'&&req.session===frameySession){byId('frameyPanelStatus').textContent='Plugin panel ready';
api('/api/framey/check',{kind:'panel',pluginId:activeFrameyId}).catch(()=>{});return;}
if(req.type==='framey:input'){api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
if(req.type==='framey:error'){byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
if(req.type==='framey:input'&&req.session===frameySession){
api('/api/framey/check',{kind:'input'}).catch(()=>{});return;}
if(req.type==='framey:error'&&req.session===frameySession){
byId('frameyPanelStatus').textContent=`Plugin panel unavailable: ${String(req.message).slice(0,200)}`;return;}
if(req.type==='framey:running:close'){
if(req.session===frameySession&&frameyRunningActive){
frameyRunningActive=false;
+25 -6
View File
@@ -345,11 +345,16 @@ def backfill_art(apps=(), titles=(), meant=None):
return True
def _new_job_locked(label, **fields):
def _prune_jobs_locked():
now = time.time()
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
del _jobs[j]
job = secrets.token_hex(8)
def _new_job_locked(label, job_id=None, **fields):
_prune_jobs_locked()
now = time.time()
job = job_id or secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now, **fields}
return job
@@ -376,10 +381,18 @@ def _run_job(job, label, work, progress):
_jobs[job].update(fields, done=True, time=time.time())
def start_job(label, work, progress=False):
def start_job(label, work, progress=False, *, request_id=None, request=None):
"""Run work() in the background. It returns a dict with a "message"."""
with _jobs_lock:
job = _new_job_locked(label)
if request_id is not None:
_prune_jobs_locked()
existing = _jobs.get(request_id)
if existing:
if existing.get('_request') != request:
raise Failure('This plugin request ID was already used for a different change', 409)
return {"message": f"{label}…", "job": request_id}
job = _new_job_locked(label, job_id=request_id,
**({'_request': request} if request_id is not None else {}))
busy_thread(_run_job, job, label, work, progress).start()
return {"message": f"{label}…", "job": job}
@@ -462,7 +475,7 @@ def queue_action(body):
def job_status(query):
with _jobs_lock:
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
snapshot = job and {k: v for k, v in job.items() if k != "time"}
snapshot = job and {k: v for k, v in job.items() if k not in ("time", "_request")}
if not snapshot:
raise Failure("no such job (the app may have restarted)", 404)
return snapshot
@@ -572,9 +585,15 @@ def decky_plugin(body):
if body.get('acknowledged') is not True:
raise Failure('Confirm that store plugins run code as your Frame user', 400)
reviewed_hash = body.get('sha256')
request_id = body.get('requestId')
if request_id is not None and (not isinstance(request_id, str) or
not re.fullmatch(r'[0-9a-f]{32}', request_id)):
raise Failure('Invalid plugin request ID', 400)
device = LINK.active_device()['id'] if LINK else FRAME
return start_job('Decky plugin ' + action,
lambda report: frame_decky_store.change(ssh, action, plugin_id, report,
reviewed_hash=reviewed_hash), progress=True)
reviewed_hash=reviewed_hash), progress=True,
request_id=request_id, request=(device, action, plugin_id, reviewed_hash))
def framey_method_action(body):
+27
View File
@@ -0,0 +1,27 @@
Copyright 2009 The Go Authors.
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 Google LLC 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.
+707
View File
@@ -0,0 +1,707 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
BSD 3-Clause License
Copyright (c) 2023, David Sturman
Original Copyright (c) 2022-2023, Steam Deck Homebrew
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. 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.
3. Neither the name of the copyright holder 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 HOLDER 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.
+51
View File
@@ -0,0 +1,51 @@
# DeckyFileServer 1.2.0 native helper
The official store archive (`7dc2ecdcd05aa415d1ac90afaff28ecb17f77809a2bc7a77aee8e92505b976f0`)
contains an x86_64 helper. Frame Control replaces only that exact archive's
`bin/backend` with the native Linux helper here. No runtime download, compiler,
root service or system package is used.
Upstream: https://github.com/grimkor/DeckyFileServer
Pinned source commit: `dcf3f3ce5104d05811fa8c156c4f58632b84b313`.
The source tarball fetched from GitHub has SHA-256
`0854ca668b64598b0871ceda842912332b75e5c482afeea8a533e4cb38921639`.
Its `main.py`, `plugin.json` and `package.json` match the store ZIP byte for byte.
The published Go binary has no VCS revision metadata, so its source revision
cannot be independently established from the binary.
`source.tar.gz` is the complete corresponding helper source, embedded resources,
Go module metadata, vendored dependencies and their licenses, plus upstream
`LICENSE`. SHA-256:
`50d90a7328daa412bf963ec68bb4855a9ec0860a8475cdf7d2019e6c2507112d`.
It is available in the same app bundle/repository as these binaries. Upstream is
GPL-3.0; see `LICENSE.txt`. The statically linked Go runtime uses the BSD license
in `GO-LICENSE.txt` (toolchain source: https://go.dev/dl/#go1.25.7).
The sole Go source change replaces the fixed `/tmp/deckyfileserver.log` path
with `filepath.Join(os.Getenv("DECKY_PLUGIN_LOG_DIR"), "deckyfileserver.log")`,
falling back to `os.TempDir()` when the environment variable is empty. This keeps
managed and private test logs separate. The upstream HTTPS certificate/key are
public embedded test material, unchanged; this is not a trusted TLS certificate.
Reproduce offline with Go **1.25.7** and Python 3.12+:
```sh
python3 scripts/build-framey-fileserver.py --go /path/to/go1.25.7/bin/go --output /tmp/framey-fileserver-build
```
The script extracts only this source archive, disables module downloads, uses
`CGO_ENABLED=0`, `GOOS=linux`, `-mod=vendor`, `-trimpath`, `-buildvcs=false` and
`-ldflags='-s -w -buildid='`, and emits deterministic gzip assets (mtime 0).
| Architecture | Uncompressed helper SHA-256 | Gzip SHA-256 |
| --- | --- | --- |
| x86_64 | `5e90efd1bf5163bef1686045f4714ad0e4fd0f54edb037fce8cdb06c58177bcb` | `84e49f049bbc475bc553ef52adc05dcd323e36c5f5ec75008558ae87f3a0e6d7` |
| aarch64 | `86b80021ebd62ed619eba065757c1122defa5a74b2c045aa44a0c6b4403a508a` | `7ca1b8aebd4e8d2c8ce382ca561e1413ad8e581f85f419a1da777c87d86c4a19` |
The managed Python adaptation removes only this digest's `_root` flag, handles
unavailable hostname DNS,
filters omitted flags and applies thumbnail policy before server startup.
The server still shares the selected directory on all interfaces using the
upstream self-signed HTTPS server; it has no authentication. Select only a
directory you intend to share. Uploads remain off by default. Protected system
files remain subject to the ordinary Frame user's permissions.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
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
@@ -0,0 +1,20 @@
# 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.