mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 18:01:57 +01:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e2faacc7 | ||
|
|
5c9ca110ed | ||
|
|
e3540cab5e | ||
|
|
0eaa9f2088 | ||
|
|
59f7348aa5 | ||
|
|
67b9151513 | ||
|
|
67a95d4f4c | ||
|
|
ce6e8f807f | ||
|
|
e7fe34d865 | ||
|
|
fd0b0a946f | ||
|
|
2895d5711e | ||
|
|
1ad6a97a36 | ||
|
|
b795c1dc56 | ||
|
|
db079eec9f | ||
|
|
bc72c41be5 | ||
|
|
697e68f098 | ||
|
|
3921e59035 | ||
|
|
b863527c33 | ||
|
|
c08f5df677 | ||
|
|
c62bffc8ef | ||
|
|
aeb6bb5294 | ||
|
|
65964f0e46 | ||
|
|
792e9c016e | ||
|
|
aeac07cf54 | ||
|
|
d90d111218 | ||
|
|
8fc1117209 | ||
|
|
442b5ecfd8 | ||
|
|
5afffbef89 | ||
|
|
76f619280a | ||
|
|
2b92599d94 | ||
|
|
d87b863aa3 | ||
|
|
6aa74619d5 | ||
|
|
0a463dea23 | ||
|
|
e44ea65080 | ||
|
|
ebb1091a37 | ||
|
|
e6430c39ed | ||
|
|
b518f8c741 | ||
|
|
1bb5ed6216 | ||
|
|
93a4ae95fa | ||
|
|
e3bb608dde | ||
|
|
f2e73d98f4 | ||
|
|
78d284b154 | ||
|
|
2c4581f122 | ||
|
|
65fee8ca8d | ||
|
|
4372c913a6 | ||
|
|
5239dae1a8 | ||
|
|
52a0974a84 | ||
|
|
f910c3b278 | ||
|
|
9bc5294f36 | ||
|
|
7ed08c5bbd | ||
|
|
449146bbdb | ||
|
|
e115006ac9 | ||
|
|
30fbadbb23 | ||
|
|
cae1270796 | ||
|
|
dd62de4f6d | ||
|
|
5a4848cab9 | ||
|
|
480c6dcc36 | ||
|
|
30b20cbdd6 | ||
|
|
19e8b826c6 | ||
|
|
aea65bb282 | ||
|
|
2c7b93f756 | ||
|
|
879e84468b | ||
|
|
6ea9eb2618 |
No files matched your search
@@ -70,6 +70,9 @@ jobs:
|
||||
- os: macos-latest
|
||||
script: dist
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: macos-15-intel
|
||||
script: dist:mac:x64
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: windows-latest
|
||||
script: dist:win
|
||||
files: app/dist/*.exe app/dist/*.zip
|
||||
@@ -110,6 +113,17 @@ jobs:
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Check the Mac bundle matches its runner
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
bundle=$(find app/dist -maxdepth 2 -name 'Frame Control.app' -type d -print -quit)
|
||||
test -n "$bundle"
|
||||
resources="$bundle/Contents/Resources"
|
||||
for binary in "$bundle/Contents/MacOS/Frame Control" "$resources/python/bin/python3" "$resources/tools/adb" "$resources/mac/bin/frame-mac-view"; do
|
||||
lipo "$binary" -verify_arch "$(uname -m)"
|
||||
done
|
||||
"$resources/python/bin/python3" -I -B -c 'import http.server, ssl, sqlite3; print(ssl.OPENSSL_VERSION)'
|
||||
- name: Launch the built Linux AppImage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
|
||||
|
||||
@@ -173,16 +173,39 @@ distribution's. If you already have `adb`, the app uses yours.
|
||||
<details>
|
||||
<summary><b>macOS: the app isn't notarized</b></summary>
|
||||
|
||||
There's no paid Apple developer account behind it, so macOS says the app is
|
||||
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||
quarantine once, only for a download you trust from the releases linked above:
|
||||
The app has an **ad hoc signature**, no Apple Developer ID signature, and no
|
||||
notarization. The DMG is unsigned. First verify the download against the
|
||||
`SHA256SUMS` from the same release, then open the DMG and drag **Frame Control**
|
||||
to **Applications**.
|
||||
|
||||
Check your processor before opening: v0.4.36 and earlier publish only
|
||||
`mac-arm64` for Apple Silicon. On an Intel Mac, this build cannot run;
|
||||
removing quarantine or installing Rosetta does not make an ARM app run on Intel.
|
||||
On a real Intel MacBook Air with macOS 15.7.7, opening v0.4.35 and v0.4.36
|
||||
reported: **“The application cannot be opened because it has an incorrect
|
||||
executable format.”** This is an architecture failure, not an observed
|
||||
Gatekeeper warning. See the [real-Mac test record](docs/evidence/mac-desktop-2026-10-26.md).
|
||||
|
||||
On a compatible Mac, if macOS blocks an unverified developer, try opening the
|
||||
app once, then go to **System Settings → Privacy & Security → Open Anyway**
|
||||
and confirm **Open**. Only do this for a download you verified and trust.
|
||||
[Apple's opening instructions](https://support.apple.com/102445) describe the
|
||||
current security controls. The exact warning and this override flow have not
|
||||
yet been verified here on Apple Silicon.
|
||||
|
||||
If a trusted, verified copy still reports “damaged” and offers no Open Anyway
|
||||
option, the Terminal fallback removes only this app's download quarantine
|
||||
(it does not sign or notarize the app):
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
|
||||
The first time, macOS also asks to allow local network access (for SSH) and
|
||||
control of Terminal (for the password prompts).
|
||||
Then open it from Applications. Do not disable Gatekeeper globally. Depending
|
||||
on the features you use, macOS may also request Local Network access, permission
|
||||
to control Terminal, or Screen Recording; approve only the feature you intend
|
||||
to use. Those permission prompts were not verified in the release test above.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
|
||||
// packages for every build), into ../frame/kdeconnect/packages as listed in
|
||||
// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
|
||||
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
|
||||
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
@@ -15,6 +15,7 @@ const PY = "3.12.14+20260924";
|
||||
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
|
||||
+ `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
|
||||
const PYTHON = {
|
||||
"mac-x64": ["x86_64-apple-darwin", "7ea9761b9069c10b9a20531d568645849d604c59e9c7f11f6659f1e1790c968e"],
|
||||
"mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
|
||||
"win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
|
||||
"linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.36",
|
||||
"version": "0.4.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.36",
|
||||
"version": "0.4.39",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+7
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.36",
|
||||
"version": "0.4.39",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -9,10 +9,11 @@
|
||||
"scripts": {
|
||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
|
||||
"dist:mac:x64": "FRAME_MAC_ARCH=x86_64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac x64 && electron-builder --mac --x64 --publish never"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
@@ -55,7 +56,8 @@
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json"
|
||||
"telemetry.json",
|
||||
"framey-compatibility.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
Release baseline: Frame Control **v0.4.39**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Core title smoke test | Linux lxso2 installed, launched and removed reserved native/Proton titles on the real Frame; all 13 steps passed | No | Unworn headset; visible rendering and controller input not checked. Other feature paths retain their separate limits below | 25 Oct, v0.4.36 tag `1701e62`, Frame OS 0.5.6 build `20261022.6153909`: [release smoke evidence](evidence/v0436-release-smoke-2026-10-25.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): v0.4.35/v0.4.36 DMGs verified and installed; both fail to launch because they are ARM64-only. Separate Intel candidate launched: UI, native preference read/write (restored), bundled Python logs, latest-version check and offline OBS Start/Stop checked; released UI browsed 116 Decky store entries | No | Candidate results are not released-build acceptance. Native Open at Login state, Gatekeeper override, real OBS/Frame stream, native settings across restart, headset transfer and wearer interaction remain unverified | 26 Oct, v0.4.35/v0.4.36 and Intel candidate `6ea9eb2`: [real-Mac evidence](evidence/mac-desktop-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 installed and removed reserved native/Proton titles on the real Frame; all 13 steps passed. ARM64 and Windows titles launched | No | The x86_64 title could not launch because Steam Linux Runtime 4.0 was not installed. The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, v0.4.38 exact tag `d87b863`, Frame OS 0.5.6 build `20261022.6153909`: [latest release smoke](evidence/v0438-release-smoke-2026-10-26.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
@@ -22,8 +23,8 @@ Current for Frame Control **v0.4.36**, 25 October 2026. “Real Frame + physical
|
||||
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified. The new large read-only status screen has browser layout and packaging checks only | 25 Oct, v0.4.29 device check and v0.4.31 status screen: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [PR #220](https://github.com/saphid/frame-control/pull/220), [panel guide](headset-app.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; seven official-store plugin backends loaded, and ProtonDB Badges completed managed store install/update/remove | No | v0.4.33 shared transaction lock and stopped-loader plugin removal have Linux and CI tests, but no live recovery test on the Frame. v0.4.35 treats `_root` store tags as root-required and hides duplicate IDs; this has test coverage, not a live store check. v0.4.36 stops a store install or update when the catalog hash changed after review; this was tested with a fake Frame, not with a live store change on the headset. Plugin pages, settings, routes, toasts and controller input in VR remain unverified; some plugins need root, x86_64 binaries or Deck-specific hardware | 25 Oct, v0.4.31 hardware and v0.4.36 tests: [PR #208](https://github.com/saphid/frame-control/pull/208), [PR #230](https://github.com/saphid/frame-control/pull/230), [PR #234](https://github.com/saphid/frame-control/pull/234), [PR #246](https://github.com/saphid/frame-control/pull/246), [PR #253](https://github.com/saphid/frame-control/pull/253), [compatibility table](decky-frame.md) |
|
||||
| Experimental Framey VR plugin panels and store | No live real-Frame panel test | No | ProtonDB Badges settings rendered and sent a setting call in local Chromium. Official Steam Status rendered in local Chromium; a session-only cache avoided its earlier storage errors. A later local browser check restored a bounded per-plugin cache after panel reopen; live Decky and offline behavior were not tested. Fake Frame tests cover setting read/write through update and reinstall and install/remove two pinned official API v2 archives plus the modern API v1 Storage Cleaner archive. Page tests cover the official JSX runtime, disabled controls, text fields, sliders, grouped dropdowns and an author sample bundle with a settings tab and read-only backend quick action. The sample is not published in the store. The 11-archive manifest audit found only ProtonDB Badges API v2 in that set. Storage Cleaner 1.4.0 showed an empty-data panel and four disabled cleanup buttons in local Chromium with a stub backend; no cleanup action was tested. HLTB for Deck 2.0.12 built a shallow settings view and Bluetooth 3.1.1 built its initial panel in local frontend tests without effects or backend calls. Their badges, search, pairing and Bluetooth changes remain unverified. A hash match, local panel and fake Frame install do not prove VR use. Wearer-visible pixels, controller input and live settings persistence remain unverified | 25 Oct, v0.4.32–0.4.36: [#223](https://github.com/saphid/frame-control/pull/223), [#224](https://github.com/saphid/frame-control/pull/224), [#225](https://github.com/saphid/frame-control/pull/225), [#226](https://github.com/saphid/frame-control/pull/226), [#227](https://github.com/saphid/frame-control/pull/227), [#229](https://github.com/saphid/frame-control/pull/229), [#231](https://github.com/saphid/frame-control/pull/231), [#232](https://github.com/saphid/frame-control/pull/232), [#233](https://github.com/saphid/frame-control/pull/233), [#236](https://github.com/saphid/frame-control/pull/236), [#237](https://github.com/saphid/frame-control/pull/237), [#238](https://github.com/saphid/frame-control/pull/238), [#239](https://github.com/saphid/frame-control/pull/239), [#240](https://github.com/saphid/frame-control/pull/240), [#241](https://github.com/saphid/frame-control/pull/241), [#243](https://github.com/saphid/frame-control/pull/243), [#244](https://github.com/saphid/frame-control/pull/244), [#245](https://github.com/saphid/frame-control/pull/245), [#247](https://github.com/saphid/frame-control/pull/247), [#248](https://github.com/saphid/frame-control/pull/248), [#251](https://github.com/saphid/frame-control/pull/251), [#252](https://github.com/saphid/frame-control/pull/252), [#253](https://github.com/saphid/frame-control/pull/253), [browser/fake Frame evidence](evidence/framey-browser-2026-10-25.md), [guide](framey-loader.md) |
|
||||
| Experimental Frame Control SteamVR panel | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.39: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [new standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. Post-release main after PR #282 classifies the 116 exact-version plugins as 1 works (Notebook default flow only), 48 partial, 56 broken and 11 not applicable; v0.4.39 shipped the earlier 1/45/59/11 catalog. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Three official audio plugins had local real-backend controls and independent virtual-audio readbacks, but their ARM64 Frame audio sessions and physical output remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.39: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; post-release PR #282 recorded 101 local Chromium initializers ready and 15 failed, up from 98/18 in v0.4.39. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.39: [latest unworn sample evidence](evidence/framey-vr-standby-gate-2026-10-26.md), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [post-release sample exercise](evidence/v0439-release-2026-10-26.md#post-release-framey-library-change-282), [worn checklist](framey-vr-check.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
+10
-2
@@ -88,8 +88,16 @@ entries. Install, update, and remove need an explicit confirmation. They only
|
||||
change Frame Control owned plugin folders under
|
||||
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
|
||||
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
|
||||
back an update if the service restart fails. The current VR shell does not
|
||||
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
|
||||
back an update if the service restart fails. An update is also refused if its
|
||||
new plugin name is already used by another installed plugin, because Decky
|
||||
routes backend calls by name. This collision check has local tests only.
|
||||
For a managed plugin with a valid recorded SHA-256, Update and Remove recheck
|
||||
that installed receipt under the headset transaction lock. If another store
|
||||
action changed it after the list was read, refresh the installed list and
|
||||
review the action again. This race check has local transaction tests; older
|
||||
receipts without a valid hash still use the existing ownership check.
|
||||
The current VR shell does not provide Decky's Steam Deck Quick Access Menu.
|
||||
Frame Control's headset panel
|
||||
shows installed plugins and store actions. Its separate Framey iframe hosts
|
||||
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
|
||||
with settings, local routes, and panel toasts where supported.
|
||||
|
||||
@@ -0,0 +1,677 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"timeUtc": "2026-10-26T01:30:56.939937+00:00",
|
||||
"baseCommit": "442b5ec",
|
||||
"hostChange": "SliderField editableValue numeric input",
|
||||
"verification": "local Chromium + real Decky 3.3.0 amd64 container; no real Frame/worn VR",
|
||||
"decky": {
|
||||
"tag": "v3.3.0",
|
||||
"name": "PluginLoader",
|
||||
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
|
||||
},
|
||||
"audioVersions": {
|
||||
"pipewire": "1.4.2",
|
||||
"wireplumber": "0.5.8",
|
||||
"pactl": "17.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": 38,
|
||||
"name": "Volume Mixer",
|
||||
"version": "1.2.1-1",
|
||||
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
|
||||
"folder": "VolumeMixer-decky"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"name": "Volume Boost",
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
|
||||
"folder": "volume-boost"
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"name": "Mixer",
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
|
||||
"folder": "decky-volume-mixer"
|
||||
}
|
||||
],
|
||||
"isolation": {
|
||||
"network": "none",
|
||||
"devices": "none",
|
||||
"hostAudioSockets": "none",
|
||||
"audio": "private PipeWire/Pulse null sink; two silent streams; WirePlumber policy-only"
|
||||
},
|
||||
"fixtureBoundaries": [
|
||||
"Outer headset status/installed-plugin responses were fixture-shaped. Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe and unmodified official bundles ran in Chromium.",
|
||||
"The relay used Decky 3.3.0 official websocket RPC routes directly inside the isolated container; Steam CEF and the production SSH/CDP method transport were not part of this audio run.",
|
||||
"Actual published amd64 Decky binary and official unmodified Python backends executed. No ARM64 or real Frame audio claim.",
|
||||
"Previous full-catalog fake-Frame evidence tests archive/install/remove transport only; its backends remain stubbed."
|
||||
],
|
||||
"sharedHeadsetLock": {
|
||||
"acquired": false,
|
||||
"owner": "delightvr-r4-a56d43b4c0",
|
||||
"headsetReadsOrChanges": false
|
||||
},
|
||||
"audioBefore": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "45%",
|
||||
"front-right": "50%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mutatingCalls": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 75,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "75%",
|
||||
"front-right": "75%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 74,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "74%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 73,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "73%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 75,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "75%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 74,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 46,
|
||||
"volume": 0.5
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "50%",
|
||||
"front-right": "50%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 46,
|
||||
"volume": 0.45
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "45%",
|
||||
"front-right": "45%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
37
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "37%",
|
||||
"front-right": "37%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
38
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 42,
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 42,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "42%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "save_settings",
|
||||
"args": [
|
||||
{
|
||||
"filters": [
|
||||
"FrameySilent0"
|
||||
],
|
||||
"boostLimit": 0
|
||||
}
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
39
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "42%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "39%",
|
||||
"front-right": "39%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"volumeMixer38": "Discovery, slider click and ArrowDown: only FrameySilent1 changed 100\u219250\u219245%; FrameySilent0 stayed100%.",
|
||||
"volumeBoost58": "Sync both channels 75\u219274\u219273%; unsynced right 75\u219274% preserved left73%; reopen restored unchecked sync and73/74. Numeric left42 preserved right74; re-sync set right42 and disabled both right inputs.",
|
||||
"mixer144": "Numeric37\u219238\u219239 changed FrameySilent1 only; server readbacks37/38/39%. Invalid101 and off-step37.5 produced no set_volume RPC. Filter FrameySilent0 saved with boostLimit0, survived actual Decky shutdown/restart and reopened showing only FrameySilent1 at38%.",
|
||||
"invalidNumericBrowserMessages": {
|
||||
"inputs": [
|
||||
"101",
|
||||
"37.5",
|
||||
"39"
|
||||
],
|
||||
"setVolumeMessages": [
|
||||
{
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
39
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"notRun": [
|
||||
"boost above100%",
|
||||
"Bluetooth playback commands",
|
||||
"reset_volumes",
|
||||
"physical headset audio",
|
||||
"worn VR",
|
||||
"VR keyboard/controller input"
|
||||
]
|
||||
},
|
||||
"pipewireConfig": "context.properties = { core.daemon = true core.name = framey-audio }\ncontext.spa-libs = { audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }\ncontext.modules = [\n { name = libpipewire-module-protocol-native }\n { name = libpipewire-module-access }\n { name = libpipewire-module-spa-node-factory }\n { name = libpipewire-module-client-node }\n { name = libpipewire-module-client-device }\n { name = libpipewire-module-adapter }\n { name = libpipewire-module-metadata }\n { name = libpipewire-module-link-factory }\n { name = libpipewire-module-session-manager }\n]\ncontext.objects = [\n\n { factory = spa-node-factory args = { factory.name = support.node.driver node.name = Dummy-Driver node.group = pipewire.dummy priority.driver = 20000 } }\n { factory = adapter args = { factory.name = support.null-audio-sink node.name = framey-silent-test node.description = \"Framey Silent Test\" media.class = Audio/Sink audio.position = [ FL FR ] } }\n]",
|
||||
"remaining": "All three remain partial until real ARM64 Frame dependencies and audio-session mapping are verified under the shared lock.",
|
||||
"cleanup": {
|
||||
"testContainerRemoved": true,
|
||||
"browserRelayStopped": true,
|
||||
"headsetUntouched": true,
|
||||
"testServicesOnHeadset": false
|
||||
},
|
||||
"catalogRefresh": {
|
||||
"checkedAtUtc": "2026-10-26T01:34:07.257386+00:00",
|
||||
"plugins": 116,
|
||||
"idsVersionsDigestsUnchanged": true
|
||||
},
|
||||
"regression": {
|
||||
"serverTests": {
|
||||
"count": 1612,
|
||||
"skipped": 16,
|
||||
"result": "OK"
|
||||
},
|
||||
"siteTests": {
|
||||
"passed": 2,
|
||||
"failed": 0
|
||||
},
|
||||
"focusedFrameyTests": 38,
|
||||
"officialFrontendAudit": {
|
||||
"attempted": 116,
|
||||
"ready": 98,
|
||||
"failed": 18,
|
||||
"readyStateChanges": [],
|
||||
"initializerOnly": true,
|
||||
"backendAndNetworkDenied": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Official audio plugins: local real-backend checks
|
||||
|
||||
Volume Mixer 1.2.1-1 (38), Volume Boost 0.0.7 (58) and Mixer 0.1.0 (144)
|
||||
passed useful audio controls in local Chromium with their **unmodified official
|
||||
Python backends running in actual Decky 3.3.0**. All three remain **partial**:
|
||||
this run used amd64 Linux, and Frame ARM64 dependencies/audio-session mapping
|
||||
are unverified. The shared headset lock was occupied; no headset reads or
|
||||
changes occurred. No worn-VR result is claimed.
|
||||
|
||||
The private test container and browser relay were removed after verification.
|
||||
|
||||
The [machine-readable observations](framey-audio-backends-2026-10-26.json)
|
||||
pin all three official archives and the published Decky binary digest, and
|
||||
record each browser-triggered write with independent `pactl` channel readbacks.
|
||||
|
||||
| Plugin | Useful behavior checked | Still unverified on Frame |
|
||||
| --- | --- | --- |
|
||||
| Volume Mixer | Discover two playback streams; slider click and ArrowDown change only the selected stream, 100 → 50 → 45%; other stream remains 100% | ARM64 libpulse/libdbus, actual user audio session, Bluetooth playback controls |
|
||||
| Volume Boost | Both-channel slider; separate right-channel change preserves left; reopen restores sync preference and actual values; exact numeric left entry; syncing disables both right controls | `pactl`, default sink, hard-coded runtime UID, physical output, boost above 100% |
|
||||
| Mixer | Exact stream volume 37 → 38%, independently read back; invalid 101 and off-step 37.5 produce no write; playback filter saved, survives actual Decky restart, hides the intended stream on reopen | `pw-dump`/`wpctl`, actual Frame session, physical output, worn VR |
|
||||
|
||||
## Reproduced host gap
|
||||
|
||||
Both Volume Boost and Mixer request `SliderField.editableValue`. Framey
|
||||
previously ignored that prop and rendered only a range slider. It now supplies
|
||||
an accessible numeric input with the same minimum, maximum, step and disabled
|
||||
state, and emits numeric `onChange` values. Empty, non-finite, out-of-range and
|
||||
browser-invalid drafts do not reach a backend. No official archive was patched.
|
||||
This also makes the control available to other plugins requesting that prop;
|
||||
it does not establish their compatibility.
|
||||
|
||||
## Runtime and isolation
|
||||
|
||||
The disposable Debian 13 container ran PipeWire 1.4.2, WirePlumber 0.5.8 and
|
||||
PulseAudio client tools 17.0. Docker used `--network none --cap-drop ALL
|
||||
--security-opt no-new-privileges`, no host audio sockets and no sound devices.
|
||||
Only the archive cache and test inputs were mounted, read-only. Debian 12 was
|
||||
insufficient for the published Decky binary's GLIBC requirement; that initial
|
||||
runtime did not start.
|
||||
|
||||
Private environment:
|
||||
|
||||
```text
|
||||
XDG_RUNTIME_DIR=/tmp/framey-audio-runtime
|
||||
PIPEWIRE_REMOTE=framey-audio
|
||||
PULSE_SERVER=unix:/tmp/framey-audio-runtime/pulse-native
|
||||
DBUS_SYSTEM_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
|
||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
|
||||
```
|
||||
|
||||
XDG config/data/state and both Decky paths were below the private test base.
|
||||
Decky ran unprivileged with `CHOWN_PLUGIN_PATH=0`, `LIVE_RELOAD=0`,
|
||||
`USE_CEF_CLOSE_WORKAROUND=0` and `KEEP_SYSTEMD_SERVICE=1`. A private D-Bus
|
||||
session satisfied Volume Mixer's import; there was no real BlueZ service.
|
||||
Its fixed `/tmp/VolumeMixer-decky.log` existed only inside the container.
|
||||
|
||||
The JSON includes the complete PipeWire server configuration: only a dummy
|
||||
driver and null stereo sink, no hardware factories. A second PipeWire process
|
||||
loaded protocol-native, client-node, adapter, metadata and protocol-pulse with
|
||||
the private absolute socket. `wireplumber --profile policy` managed this server;
|
||||
that installed profile contains no hardware monitors. Only private default-sink
|
||||
metadata was selected. Two `paplay --raw --format=s16le --rate=48000 --channels=2
|
||||
/dev/zero` clients supplied silence named `FrameySilent0` and `FrameySilent1`.
|
||||
Without policy, successful volume RPCs initially left readbacks unchanged; those
|
||||
fixture attempts are excluded from the passing observations.
|
||||
|
||||
The browser relay served actual `ui/headset.html`, vendored React 18.3.1,
|
||||
`ui/framey-host.js` and the exact archived frontends in an opaque `allow-scripts`
|
||||
iframe. Outer headset/installed-plugin responses were fixture-shaped. Backend
|
||||
RPC used the official Decky websocket protocol inside the isolated container:
|
||||
`loader/call_legacy_plugin_method` with keyword objects for 38/58 and
|
||||
`loader/call_plugin_method` with positional arguments for 144. This bypassed
|
||||
Steam CEF and the production SSH/CDP transport; neither is an audio verification
|
||||
claim from this run.
|
||||
|
||||
The relay allowed discovery/settings reads, bounded volume writes only to
|
||||
identified `FrameySilent` streams/the private default sink, and Mixer filters
|
||||
`[]` or `["FrameySilent0"]` with boost limit 0. It denied Bluetooth commands,
|
||||
`reset_volumes` and boosts. Every volume write was followed by a separate
|
||||
`pactl -f json list sinks` / `list sink-inputs` read. A clean Decky shutdown and
|
||||
restart retained Mixer's actual settings file and filter behavior.
|
||||
|
||||
Earlier full-catalog fake-Frame evidence for these exact digests covers
|
||||
install/receipt/frontend/removal transport, with plugin execution stubbed.
|
||||
The new evidence adds actual local backend execution; it does not turn that
|
||||
older fake-Frame result into an ARM64 backend test.
|
||||
|
||||
## Regression checks
|
||||
|
||||
The required server suite passed 1,612 tests (16 skips); all 38 focused Framey
|
||||
tests and both website tests passed. The 116-plugin browser audit retained
|
||||
98 initializer-ready / 18 failed, with no change to any ready state. That audit
|
||||
keeps backend/network actions denied. A fresh official catalog fetch confirmed
|
||||
all 116 IDs, versions and digests unchanged.
|
||||
|
||||
## Next device check
|
||||
|
||||
Acquire the normal atomic `/tmp/frame-test.lock`; refuse an existing owner.
|
||||
First inspect available tools/libraries and audio runtime UID. If supported,
|
||||
repeat with a private ARM64 Decky user service, isolated audio socket, dummy
|
||||
sink/streams and hardware discovery disabled. Check Volume Mixer's fixed log
|
||||
path for collisions before loading it. Never attach the fixture to the normal
|
||||
headset output/default server. Remove only test-owned services/processes/files,
|
||||
read back original service/battery state and release only the matching lock.
|
||||
Keep all three partial until that check passes; physical output, Bluetooth and
|
||||
worn-VR controls need separate evidence.
|
||||
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:12:47.296778+00:00",
|
||||
"method": "Local Chromium actual Framey headset page and opaque official plugin iframe; production method/event bridge relayed by SSH to real ARM64 Decky 3.3.0 in a private removable user service. Test relay restricts RPC methods and timer durations. Backend code is unmodified, no fake plugin replies. Only file paths to private install and outer status/data APIs differ. Not worn VR.",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"hostSourceSha256": {
|
||||
"framey-host.js": "3bad52546c0b78c20e6479c91899f1f3864b74687c6fce37e776abededfb5f1c",
|
||||
"framey-events.js": "89046f4c89bdab0f578b910170a044d5ada540db0f7b71d6a370c68b4b008c3f",
|
||||
"framey_panel.py": "8ca4b3ba5f0d51c51bb3601f9ccd24c4af397fbf8f63c383a3042e1ae3f7b39b",
|
||||
"headset.html": "ebaf1711639a929ec7287c01dbbbe8eb08f000b852fdc877f31c9d99e5fe69fc"
|
||||
},
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"powerSettingsChanged": false,
|
||||
"plugins": [
|
||||
{
|
||||
"id": 19,
|
||||
"name": "Notebook",
|
||||
"version": "0.1.5",
|
||||
"sha256": "c35b09de4bba4b4157fbe84cb7b0d20fc3924f78fa3c48ae17686b4314c1cc0c",
|
||||
"calls": [
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 0,
|
||||
"empty": true,
|
||||
"data": ""
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "savePage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"timestamp": 1792973680303
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [
|
||||
{
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [],
|
||||
"checks": {
|
||||
"pointerStrokeSaved": true,
|
||||
"sameStrokeLoadedAfterBackendRestart": true,
|
||||
"defaultAppId": 0,
|
||||
"existingNotesTouched": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312",
|
||||
"calls": [
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
300
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "cancel_timer",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
5
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
294.9945251941681
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
289.99201130867004
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
284.9877219200134
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
-0.005679607391357422
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"event": "simple_timer_event",
|
||||
"args": [
|
||||
"Your session has ended!",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"uiStart300Seconds": true,
|
||||
"uiCancel": true,
|
||||
"shortTimer5Seconds": true,
|
||||
"completionToastVisible": true,
|
||||
"recentTimersAndSubtleModeSurvivedRestart": true,
|
||||
"subscriptionsAfterPanelClose": 0
|
||||
},
|
||||
"upstreamError": "Simple Timer 1.0.0 _main after restarting with an expired timer raises AttributeError: 'Plugin' object has no attribute 'timer_task' (main.py:94,129). Frontend read requests still load recent timers and subtle mode."
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"serviceActive": false,
|
||||
"independentlyRechecked": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
"regressions": {
|
||||
"unitTests": 1606,
|
||||
"unitSkips": 16,
|
||||
"browserPlugins": 116,
|
||||
"browserReady": 96,
|
||||
"browserFailed": 20,
|
||||
"fakeFrameInstalled": 60,
|
||||
"fakeFrameRejected": 56
|
||||
},
|
||||
"limitations": [
|
||||
"Timer sound asset and suspend action unsupported; no suspend/power action run.",
|
||||
"Closing a Framey panel removes its event channel; it is not a global background notification service.",
|
||||
"Notebook default notebook only (appid 0): no per-game auto-selection or background artwork.",
|
||||
"No worn VR, physical controller, keyboard, or optical check."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:23:08.939647+00:00",
|
||||
"theme": "Detached read-only Steam library and current-user context",
|
||||
"baselineMain": "3921e59",
|
||||
"hostFileSha256": "3f70e9769c6c95d39cfa329ccf3f822df85d411e9295bdf888508b4738679e9d",
|
||||
"realFrame": {
|
||||
"platform": "Frame OS 0.5.6 ARM64",
|
||||
"path": "SSH via lxso2; Steam SharedJSContext CDP",
|
||||
"lock": "/tmp/frame-test.lock atomic mkdir; matching owner released in finally after every probe",
|
||||
"apps": 1106,
|
||||
"games": 895,
|
||||
"compactBytesWithoutUser": 88451,
|
||||
"invalidRows": 0,
|
||||
"missingGameOverviews": 0,
|
||||
"currentUser": "17-digit string verified; value withheld",
|
||||
"mutations": "none; no wake, service, setting, screenshot or library changes; no device artifacts created",
|
||||
"limits": "read-only CEF projection, not an official backend or worn-VR test"
|
||||
},
|
||||
"browser": {
|
||||
"mode": "opaque iframe Chromium; detached real Frame snapshot replay; all backend/network actions denied",
|
||||
"attempted": 116,
|
||||
"initializerReady": 101,
|
||||
"failed": 15,
|
||||
"initializerGains": [
|
||||
46,
|
||||
53,
|
||||
66
|
||||
],
|
||||
"regressions": [],
|
||||
"focused": [
|
||||
{
|
||||
"id": 37,
|
||||
"name": "Discord Status",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "TypeError: saveInfos.map is not a function"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"set_account_id",
|
||||
"get_saveinfos"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"name": "Shotty",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"set_id_map_fronend"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Failed to set current user: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Error: Read-only compatibility audit: backend method blocked"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"set_current_user",
|
||||
"fetch_playtime_information",
|
||||
"statistics_for_last_two_weeks",
|
||||
"daily_statistics_for_period"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: Error: Read-only compatibility audit: live Steam API blocked"
|
||||
}
|
||||
],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 132,
|
||||
"name": "SuggestMe",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load config: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load library status: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load play next list: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load excluded games: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load filter presets: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load history: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load non-steam info: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load collections: Error: Read-only compatibility audit: backend method blocked"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"log_message",
|
||||
"get_config",
|
||||
"get_library_status",
|
||||
"get_play_next_list",
|
||||
"get_excluded_games",
|
||||
"get_filter_presets",
|
||||
"get_suggestion_history",
|
||||
"get_non_steam_games",
|
||||
"get_collections",
|
||||
"get_available_genres",
|
||||
"get_available_tags",
|
||||
"get_available_community_tags"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 134,
|
||||
"name": "Deck Progress Tracker",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_settings",
|
||||
"get_tag_statistics"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 135,
|
||||
"name": "Deck Shelves",
|
||||
"ready": false,
|
||||
"error": "Failed to resolve module specifier \"./chunks/pluginApi-B8sV7970.js\". Invalid relative url or base scheme isn't hierarchical.",
|
||||
"logs": [],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fakeFrame": {
|
||||
"attempted": 116,
|
||||
"installedRemoved": 60,
|
||||
"expectedBlockers": 56,
|
||||
"backendExecution": "stubbed",
|
||||
"cleanup": "private Compose containers/network/volumes removed"
|
||||
},
|
||||
"localBackends": {
|
||||
"timeUtc": "2026-10-26T02:18:51.848256+00:00",
|
||||
"verification": "local Chromium + actual Decky 3.3.0 amd64, detached real Frame SSH snapshot; no worn VR",
|
||||
"calls": [
|
||||
{
|
||||
"id": 46,
|
||||
"method": "set_account_id",
|
||||
"resultType": "dict",
|
||||
"resultCount": 2
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"method": "get_saveinfos",
|
||||
"resultType": "dict",
|
||||
"resultCount": 2
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "set_current_user",
|
||||
"resultType": "NoneType"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "statistics_for_last_two_weeks",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "fetch_playtime_information",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "daily_statistics_for_period",
|
||||
"resultType": "dict",
|
||||
"resultCount": 3
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "fetch_playtime_information",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "statistics_for_last_two_weeks",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"tag": "v3.3.0",
|
||||
"name": "PluginLoader",
|
||||
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
|
||||
},
|
||||
"legacyReadback": {
|
||||
"plugin": 46,
|
||||
"method": "get_saveinfos",
|
||||
"replyType": 1,
|
||||
"success": true,
|
||||
"saveInfoCount": 0
|
||||
}
|
||||
},
|
||||
"uiChecks": {
|
||||
"Steamback": "Supported games panel, actual private backend set_account_id and get_saveinfos returned successfully; empty isolated Steam save data",
|
||||
"PlayTime": "Statistics panel (empty database) and routed General settings/tabs rendered; actual private backend selected user and returned statistics",
|
||||
"Deck Settings": "Installed game picker rendered real Frame install-folder/name snapshot; search modal rendered",
|
||||
"Shotty": "Initializer repaired; actual Decky 3.3.0 backend fails importing unused click.get_app_dir: ModuleNotFoundError; no screenshot methods invoked"
|
||||
},
|
||||
"notVerified": [
|
||||
"Real ARM64 official backends in this theme",
|
||||
"Worn VR/controllers/keyboard",
|
||||
"Live MobX or running-game observations",
|
||||
"Steamback backups/restores and automatic notifications",
|
||||
"PlayTime automatic recording, existing databases or account switches while panel remains open",
|
||||
"Deck Settings remote reports/proxy fetch and settings changes",
|
||||
"Shotty screenshot aggregation/rescue"
|
||||
],
|
||||
"cleanup": {
|
||||
"localContainer": "stopped/removed; docker ps -a matching name empty",
|
||||
"headsetLockReleased": true,
|
||||
"httpHelpers": "three task-local HTTP servers stopped"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": 37,
|
||||
"name": "Discord Status",
|
||||
"version": "1.5.0",
|
||||
"sha256": "00a45dd919abb6e050685e1e408ce8023b9c4a09d6e91ba528d50aed2a63bf45"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"version": "1.0.0-1",
|
||||
"sha256": "ed5f2fbb47cdfc3aa9a4349a139ab20c29976117a3be0de7e8a588bb265ee7f3"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"name": "Shotty",
|
||||
"version": "0.1.0",
|
||||
"sha256": "ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"version": "3.2.5",
|
||||
"sha256": "10fc3beea48991c77cb02867e919b618d4cd6d5e9c6a6d0ccd5266c13214039f"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"version": "1.2.6",
|
||||
"sha256": "23c2a933a6883e2622f0184c33a76ce4a4e88c0a7dcd730f9bab71b8c3702132"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"serverTests": 1617,
|
||||
"serverSkips": 16,
|
||||
"siteTests": 18,
|
||||
"fakeFramePanel": "2 targeted panel/store flows passed; private Compose resources removed",
|
||||
"focusedFrameyTests": 40
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# Framey detached Steam library context — 2026-10-26
|
||||
|
||||
Framey now reads a bounded library snapshot before importing official frontends
|
||||
that reference `appStore`, `collectionStore` or `m_CurrentUser`. It supplies app
|
||||
IDs/names/types/game IDs, sorting/playtime scalars, the actual all-games
|
||||
collection and the current public Steam ID. It does not copy credentials,
|
||||
balances, account names, filesystem paths or executable Steam objects.
|
||||
|
||||
The projection is a fixed, parameterless `GetLibrarySnapshot` read through the
|
||||
existing installed-store-plugin boundary. The parent checks the iframe source,
|
||||
session, method and empty argument list. The host validates rows before exposing
|
||||
detached Maps and lookup methods. Plugin patches remain local to that iframe.
|
||||
Missing or invalid data leaves the globals unavailable and logs a clear warning;
|
||||
there is no invented account or empty-library fallback. Reopening the panel
|
||||
refreshes the snapshot. Live MobX observations, account-switch notifications and
|
||||
running-game events are outside this change.
|
||||
|
||||
Library-sized backend arguments now have a 256 KiB UTF-8 JSON budget. The old
|
||||
8 KiB ceiling rejected normal app-name maps. Payloads travel as quoted literals
|
||||
on SSH stdin, avoiding operating-system limits on a single command argument.
|
||||
Store receipt ownership, method validation and legacy keyword envelopes still
|
||||
apply. The library read has a separate 2 MiB / 10,000-app limit; other Steam
|
||||
reads retain their 256 KiB response limit.
|
||||
|
||||
## Observations
|
||||
|
||||
| Plugin | Verified behavior | Remaining limit |
|
||||
| --- | --- | --- |
|
||||
| Steamback 1.0.0-1 | Panel opens; account setup and empty save-list reads succeed through actual Decky 3.3.0 amd64 | Private container had no Steam saves. ARM64 backend, backup/restore and automatic hooks unverified. Partial. |
|
||||
| PlayTime 3.2.5 | Actual backend selects the user, returns empty statistics; statistics panel and routed settings/tabs render | No sessions were created. ARM64 backend, existing databases, live observations and automatic recording unverified. Partial. |
|
||||
| Deck Settings 1.2.6 | Installed game picker renders the real Frame folder/name snapshot; search modal opens | Remote reports/proxy fetch, settings changes and ARM64 backend unverified. Partial. |
|
||||
| Shotty 0.1.0 | Library-based initializer now completes | Exact backend fails importing `click.get_app_dir`: `ModuleNotFoundError: No module named 'click'`. Archive does not bundle it. Broken. |
|
||||
| Discord Status 1.5.0 | Detached overview lookups available | Live running-app observations and artwork helpers remain absent; Discord activity unverified. Broken. |
|
||||
|
||||
Exact plugin versions/digests, runtime digest, scoped calls and counts are in the
|
||||
[redacted observations](framey-library-context-2026-10-26.json). The canonical
|
||||
table/store labels use the same digest-scoped JSON. None of these plugins is
|
||||
promoted to **works** by this pass.
|
||||
|
||||
## Verification boundaries
|
||||
|
||||
- **Real Frame SSH:** atomic `/tmp/frame-test.lock` acquisition for every probe,
|
||||
matching-owner release in `finally`. Frame OS 0.5.6 ARM64 Steam CEF returned
|
||||
1,106 apps and 895 game IDs; no missing collection members or invalid rows.
|
||||
The compact library-only response was 88,451 bytes. Current-user field shape
|
||||
was separately inspected. Values remain private; only aggregate counts are
|
||||
published. No wake, power/service changes or device artifacts were needed.
|
||||
- **Local Chromium:** production `headset.html`, current host, opaque iframe and
|
||||
unmodified digest-checked official frontends. Library/user and install-folder
|
||||
data were detached snapshots read from the Frame, replayed locally. This is
|
||||
not a deployed headset HTTP or worn-VR test.
|
||||
- **Actual local backends:** published Decky 3.3.0 amd64 executable, private
|
||||
unprivileged container, network disabled, capabilities dropped, no devices,
|
||||
host home, saves or Steam files mounted. Only account setup within private
|
||||
plugin data and read methods were allowed. Backup, restore, aggregation,
|
||||
screenshot rescue and game actions were denied. Shotty's import failure was
|
||||
observed from the runtime log before invoking screenshot methods.
|
||||
- **Catalog regression:** 116 exact frontends attempted, 101 initializer-ready
|
||||
and 15 failed, compared with 98 ready before this theme. Steamback, Shotty and
|
||||
PlayTime gained initializer readiness; no previous ready state regressed.
|
||||
Backend/network calls stayed denied in this sweep. Readiness alone does not
|
||||
establish useful operation.
|
||||
- **Fake Frame:** 116 archive/transport cases; 60 install/remove cycles and 56
|
||||
expected blockers. Official backend execution was stubbed. Its private
|
||||
Compose containers, network and volumes were removed.
|
||||
|
||||
The browser audit accepts `--library-snapshot /private/library.json` to reproduce
|
||||
the detached replay. This flag does not convert the audit into live Steam or
|
||||
backend evidence. Never commit personal library snapshots or account values.
|
||||
|
||||
No worn-VR, physical controller, keyboard, automatic recording or backup claim
|
||||
is made. Further device backend checks still require the shared hardware lock.
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26",
|
||||
"plugin": {
|
||||
"id": 125,
|
||||
"name": "Decky Localsend",
|
||||
"officialArchiveStatus": "x86_64; blocked; no substitutions or safeguard changes"
|
||||
},
|
||||
"source": {
|
||||
"url": "https://github.com/MoYoez/LocalSend-Go",
|
||||
"revision": "66edc509a28b9f877e7374504892dcbc653a4da4",
|
||||
"license": "MIT",
|
||||
"licenseUrl": "https://github.com/MoYoez/LocalSend-Go/blob/66edc509a28b9f877e7374504892dcbc653a4da4/LICENSE",
|
||||
"pluginLicense": "BSD-3-Clause; distinct from the MIT helper",
|
||||
"officialElfBuildInfo": "Go 1.25.7, CGO_ENABLED=0, GOARCH=amd64; no embedded VCS revision. Exact source correspondence unproven."
|
||||
},
|
||||
"build": {
|
||||
"toolchain": "go1.25.7",
|
||||
"environment": {
|
||||
"GOTOOLCHAIN": "go1.25.7",
|
||||
"CGO_ENABLED": "0",
|
||||
"GOOS": "linux",
|
||||
"GOARCH": "arm64"
|
||||
},
|
||||
"command": "go build -trimpath -o localsend-core .",
|
||||
"elfMachine": 183,
|
||||
"architecture": "aarch64",
|
||||
"staticallyLinked": true,
|
||||
"sha256": "e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9",
|
||||
"rebuildSha256Matches": true
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6 ARM64",
|
||||
"sharedLockOwner": "kp0z857q58-live-steam-reads-20261026",
|
||||
"helpExitCode": 0,
|
||||
"officialBackendFlagsAllRecognized": true,
|
||||
"helpFlags": [
|
||||
"-doNotMakeSessionFolder",
|
||||
"-log",
|
||||
"-scanTimeout",
|
||||
"-skipNotify",
|
||||
"-useAlias",
|
||||
"-useAutoSave",
|
||||
"-useAutoSaveFromFavorites",
|
||||
"-useConfigPath",
|
||||
"-useDefaultUploadFolder",
|
||||
"-useDownload",
|
||||
"-useHttp",
|
||||
"-useMultcastAddress",
|
||||
"-useMultcastPort",
|
||||
"-usePin",
|
||||
"-useReferNetworkInterface",
|
||||
"-useWebOutPath"
|
||||
],
|
||||
"runtimeExitCode": 0,
|
||||
"isolation": "unshare -Urn; loopback and private veth pair only; no external interface/default route",
|
||||
"receiving": "-useAutoSave=false and -useAutoSaveFromFavorites=false; no upload/transfer requests",
|
||||
"notifications": "-skipNotify",
|
||||
"readOnlyRequest": "GET http://127.0.0.1:53317/api/localsend/v2/info",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"protocolVersion": "2.0",
|
||||
"deviceType": "headless",
|
||||
"responseKeys": [
|
||||
"alias",
|
||||
"deviceModel",
|
||||
"deviceType",
|
||||
"download",
|
||||
"fingerprint",
|
||||
"version"
|
||||
],
|
||||
"aliasMatches": true
|
||||
},
|
||||
"cleanup": {
|
||||
"helperExited": true,
|
||||
"filesCreated": [
|
||||
"config.yaml",
|
||||
"helper.log"
|
||||
]
|
||||
},
|
||||
"privateDirectoryAbsent": true,
|
||||
"servicesInstalledOrStarted": false,
|
||||
"powerChanges": false,
|
||||
"sharedLockReleasedAfterTests": true,
|
||||
"independentReacquireAudit": "Deferred: another job had acquired the shared lock; it was left untouched. The helper process exited and TemporaryDirectory removal completed within the original locked probe."
|
||||
},
|
||||
"failedProbe": {
|
||||
"interface": "loopback only",
|
||||
"result": "Upstream rejects lo as a discovery interface and exits. Repeated with a veth pair wholly inside the private namespace; no source changes."
|
||||
},
|
||||
"unverified": [
|
||||
"Exact source correspondence to shipped official ELF",
|
||||
"Unmodified official Python backend invoking this ARM64 artifact",
|
||||
"HTTPS, discovery on real network, pairing and file transfers",
|
||||
"Framey frontend/proxy protocol integration",
|
||||
"Worn VR/controller/keyboard"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# LocalSend ARM64 build and isolated Frame probe
|
||||
|
||||
The official Decky Localsend archive remains **broken/blocked**: its helper is
|
||||
x86_64. A source rebuild is now proven to execute on the ARM64 Frame and answer
|
||||
the LocalSend 2.0 info endpoint. This is a core runtime result, not a working
|
||||
official-plugin or file-transfer claim. [Machine-readable evidence](framey-localsend-arm64-2026-10-26.json)
|
||||
records the exact scope and cleanup.
|
||||
|
||||
## Reproduce the build
|
||||
|
||||
The helper is MIT-licensed; the separate official Python plugin is BSD-3-Clause.
|
||||
Use the pinned helper source and retain its license for any redistribution.
|
||||
No binary is redistributed in Frame Control, and no installer digest or
|
||||
architecture safeguard was changed.
|
||||
|
||||
```sh
|
||||
framey_build_dir=$(mktemp -d)
|
||||
git clone https://github.com/MoYoez/LocalSend-Go "$framey_build_dir/source"
|
||||
git -C "$framey_build_dir/source" checkout --detach 66edc509a28b9f877e7374504892dcbc653a4da4
|
||||
cd "$framey_build_dir/source"
|
||||
env GOTOOLCHAIN=go1.25.7 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
||||
go build -trimpath -o "$framey_build_dir/localsend-core" .
|
||||
sha256sum "$framey_build_dir/localsend-core"
|
||||
```
|
||||
|
||||
Two builds produced the same static aarch64 ELF SHA-256:
|
||||
`e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9`.
|
||||
The official ELF uses Go 1.25.7 too, but embeds no VCS revision, so the exact
|
||||
source correspondence is still unproven. The rebuilt helper recognizes every
|
||||
CLI flag supplied by the official Python backend.
|
||||
|
||||
## Real Frame scope
|
||||
|
||||
Tests ran under the normal atomic `/tmp/frame-test.lock` with a private temporary
|
||||
directory. `-h` exited before configuration or networking. For the health probe,
|
||||
`unshare -Urn` created a private user/network namespace with loopback and a veth
|
||||
pair entirely inside it: no external interface or default route. The veth used
|
||||
the documentation-only `192.0.2.1/24` address. Upstream rejects a loopback-only
|
||||
discovery interface, which is why the second isolated attempt used a veth.
|
||||
|
||||
The helper used a private config/upload path, `-skipNotify`,
|
||||
`-useAutoSave=false`, `-useAutoSaveFromFavorites=false`, `-useHttp=true`,
|
||||
`-useReferNetworkInterface probe0` and `-scanTimeout 1`. The test client sent only
|
||||
`GET /api/localsend/v2/info` over loopback; it returned version `2.0`, device type
|
||||
`headless` and the private test alias. No upload, transfer, pairing or external
|
||||
discovery request ran. The helper exited; its private config/log and executable
|
||||
directory were removed; no service or power setting changed. The lock was
|
||||
released. An independent cleanup reacquisition found another job's lock and
|
||||
left it alone; cleanup success comes from the original probe's process wait and
|
||||
temporary-directory exit, not that deferred audit.
|
||||
|
||||
Before enabling an ARM64 official package, verify the unmodified Python backend
|
||||
and frontend/proxy protocol with this source revision, then HTTPS, discovery and
|
||||
an explicitly safe private transfer. The official store must publish a matching
|
||||
ARM64 archive or Frame Control must gain a separately reviewed, digest-scoped
|
||||
build/package path. Compiling the core does not justify accepting the current
|
||||
x86 archive or marking the full plugin works.
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"timeUtc": "2026-10-25T23:35:22.022586+00:00",
|
||||
"method": "Real Frame ARM64 Decky 3.3.0, isolated removable user service; explicit read-only plugin methods only",
|
||||
"plugins": [
|
||||
{
|
||||
"id": 14,
|
||||
"version": "1.3.4",
|
||||
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
|
||||
"method": "get_setting",
|
||||
"result": {
|
||||
"result": "default"
|
||||
},
|
||||
"errors": [
|
||||
"Transient CEF reconnect during Decky frontend injection; succeeded on retry."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 123,
|
||||
"version": "1.2.3",
|
||||
"sha256": "9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e",
|
||||
"method": "get_settings",
|
||||
"result": {
|
||||
"keys": [
|
||||
"enable_notification_antiflood",
|
||||
"enable_notifications",
|
||||
"gateway_api_key",
|
||||
"gateway_url",
|
||||
"refresh_interval_seconds",
|
||||
"show_history",
|
||||
"show_regions",
|
||||
"show_trending_games"
|
||||
],
|
||||
"defaultRefreshInterval": 120
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"service": {
|
||||
"created": true
|
||||
},
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"passed": true,
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"lockReleased": true,
|
||||
"cleanupErrors": []
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:40:00Z",
|
||||
"scope": "Actual SteamClient parameterless reads through Steam CEF on ARM64 Frame. Official frontends ran in local Chromium with plugin backend/network calls denied. Private loopback relay used the production read bridge with fixture installed-plugin status and the real frame_steam module supplied in memory because the optional headset service was not deployed. This does not verify a deployed real-Frame HTTP endpoint.",
|
||||
"allowlist": [
|
||||
"User.GetIPCountry",
|
||||
"InstallFolder.GetInstallFolders",
|
||||
"System.GetSystemInfo"
|
||||
],
|
||||
"probeResults": {
|
||||
"GetIPCountry": {
|
||||
"ok": true,
|
||||
"type": "string",
|
||||
"length": 2,
|
||||
"value": "redacted"
|
||||
},
|
||||
"GetInstallFolders": {
|
||||
"ok": true,
|
||||
"folders": 1,
|
||||
"apps": 29,
|
||||
"pathsAndTitles": "redacted"
|
||||
},
|
||||
"GetSystemInfo": {
|
||||
"ok": true,
|
||||
"fields": 27,
|
||||
"identifiers": "redacted"
|
||||
}
|
||||
},
|
||||
"officialPanels": {
|
||||
"IsThereAnyDeal for Deck": {
|
||||
"observation": {
|
||||
"id": 81,
|
||||
"name": "IsThereAnyDeal for Deck",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"settings_load",
|
||||
"settings_save"
|
||||
]
|
||||
},
|
||||
"countryDropdownMatchesLiveFrameValue": true,
|
||||
"backendSettingsSave": "attempted by official frontend; denied; no setting written",
|
||||
"dealLookups": "not run"
|
||||
},
|
||||
"Deck Settings": {
|
||||
"observation": {
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: TypeError: Cannot read properties of undefined (reading 'allGamesCollection')"
|
||||
}
|
||||
],
|
||||
"methods": []
|
||||
},
|
||||
"remainingBlocker": "collectionStore.allGamesCollection"
|
||||
}
|
||||
},
|
||||
"boundariesTested": [
|
||||
"Only three explicit zero-argument APIs",
|
||||
"Installed store plugin required",
|
||||
"Headset-service gate",
|
||||
"Iframe source and current session required",
|
||||
"Late replies dropped after reopen",
|
||||
"256 KiB UTF-8 response cap and socket cleanup"
|
||||
],
|
||||
"deviceState": {
|
||||
"sharedLock": "owned normally; released after testing",
|
||||
"servicesInstalledOrStarted": false,
|
||||
"powerChanges": false,
|
||||
"deviceFilesForSteamProbe": false
|
||||
},
|
||||
"unverified": [
|
||||
"Deployed headset HTTP endpoint",
|
||||
"Plugin backend settings/deal lookups",
|
||||
"Steam page patches and live library context",
|
||||
"Worn VR/controller/keyboard"
|
||||
],
|
||||
"catalogRefresh": {
|
||||
"source": "https://plugins.deckbrew.xyz/plugins",
|
||||
"checkedAtUtc": "2026-10-26T00:44:00Z",
|
||||
"visiblePlugins": 116,
|
||||
"idVersionDigestChanges": 0
|
||||
},
|
||||
"localRegressionAudit": {
|
||||
"exactArchivedFrontends": 116,
|
||||
"ready": 98,
|
||||
"failed": 18,
|
||||
"initializationRegressions": 0,
|
||||
"pluginBackendAndNetworkActions": "denied, including live Steam read APIs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:29:00Z",
|
||||
"scope": "Exact archived frontends only. Production Framey host with bounded SteamClient JSON cache, image fallback and inert Steam subscriptions/context. Opaque iframe; all backend calls and network denied. No headset or plugin backends used in this follow-up.",
|
||||
"baseHostCommit": "52a0974",
|
||||
"before": {
|
||||
"ready": 96,
|
||||
"failed": 20
|
||||
},
|
||||
"after": {
|
||||
"ready": 98,
|
||||
"failed": 18
|
||||
},
|
||||
"regressions": [],
|
||||
"observations": [
|
||||
{
|
||||
"id": 13,
|
||||
"name": "Pause Games",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"load_settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"name": "Tailscale Control",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_tailscale_state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_CurrentUser')",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_mapApps')",
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"fetch_playtime_information",
|
||||
"statistics_for_last_two_weeks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 120,
|
||||
"name": "Reroll",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_setting",
|
||||
"version"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tailscaleFocusedCheck": {
|
||||
"rendered": [
|
||||
"Settings",
|
||||
"Toggle Tailscale",
|
||||
"Exit Node IP",
|
||||
"Toggle LAN Access",
|
||||
"Advanced Settings",
|
||||
"Device Status",
|
||||
"Tailscale Setup"
|
||||
],
|
||||
"assetError": "Hardcoded localhost:1337 QR SVG blocked by image CSP",
|
||||
"actions": "Inspected only; no toggles, exit nodes, installs or connection actions"
|
||||
},
|
||||
"pauseGamesFocusedCheck": {
|
||||
"rendered": [
|
||||
"Pause before Suspend",
|
||||
"Pause on focus loss"
|
||||
],
|
||||
"actions": "Inspected only; no process signaling or suspend actions"
|
||||
},
|
||||
"storageChecks": [
|
||||
"Per-plugin cache survives panel reopen",
|
||||
"Missing key rejects Not found",
|
||||
"JSON serialization and cache quota enforced",
|
||||
"Malformed and stale-session cache updates rejected"
|
||||
],
|
||||
"unverified": [
|
||||
"Real backends for these plugins",
|
||||
"Tailscale network state/actions",
|
||||
"Live games and pause/resume",
|
||||
"Worn VR/controller/keyboard"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T01:52:44.513151+00:00",
|
||||
"testedMainCommit": "c62bffc8efe88e107a82e2f2ef481228021acc88",
|
||||
"change": "uncommitted standby/proximity preflight in this PR",
|
||||
"preflight": {
|
||||
"activity": 3,
|
||||
"proximityRaw": [
|
||||
6.3125
|
||||
],
|
||||
"batteryPercent": [
|
||||
99
|
||||
],
|
||||
"pause": "power.pauseCompositorOnStandby=false",
|
||||
"screenTimeout": "power.turnOffScreensTimeout=5.000000"
|
||||
},
|
||||
"result": {
|
||||
"passed": true,
|
||||
"overlayVisible": true,
|
||||
"stereoChanged": true,
|
||||
"mirrorCaptured": true,
|
||||
"trustedSyntheticOpenVRPointer": true,
|
||||
"liveDeckySavedSetting": {
|
||||
"enabled": true
|
||||
},
|
||||
"settingPersistedAfterReopen": true,
|
||||
"quickActionObserved": true,
|
||||
"sliderValue": "50",
|
||||
"textValue": "framey-ci",
|
||||
"nativeScrollMoved": false
|
||||
},
|
||||
"cleanup": {
|
||||
"batteryPercent": [
|
||||
99
|
||||
],
|
||||
"proximityRaw": [
|
||||
6.1875
|
||||
],
|
||||
"pause": "power.pauseCompositorOnStandby=false",
|
||||
"screenTimeout": "power.turnOffScreensTimeout=5.000000",
|
||||
"powerRecoveryRemoved": true,
|
||||
"lockReleased": true,
|
||||
"errors": []
|
||||
},
|
||||
"limits": {
|
||||
"controllerHardwareVerified": false,
|
||||
"wornVisualComfortVerified": false,
|
||||
"vrKeyboardVerified": false
|
||||
},
|
||||
"combinedHeadChecks": [
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:01:05.938830+00:00",
|
||||
"head": "bc72c41be5aa4bc80192145afefb1c6fe31a47fc",
|
||||
"passed": false,
|
||||
"overlayVisible": false,
|
||||
"inputPassed": false,
|
||||
"reason": "Timed out waiting for live UI: 0",
|
||||
"retaggedTestWindow": true,
|
||||
"batteryBefore": [
|
||||
100
|
||||
],
|
||||
"batteryAfter": [
|
||||
99
|
||||
],
|
||||
"powerRestored": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:02:44.600755+00:00",
|
||||
"head": "bc72c41be5aa4bc80192145afefb1c6fe31a47fc",
|
||||
"passed": false,
|
||||
"overlayVisible": false,
|
||||
"inputPassed": false,
|
||||
"reason": "Timed out waiting for live UI: 0",
|
||||
"retaggedTestWindow": true,
|
||||
"batteryBefore": [
|
||||
99
|
||||
],
|
||||
"batteryAfter": [
|
||||
99
|
||||
],
|
||||
"powerRestored": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:05:00.560460+00:00",
|
||||
"head": "bc72c41be5aa4bc80192145afefb1c6fe31a47fc",
|
||||
"passed": true,
|
||||
"overlayVisible": true,
|
||||
"inputPassed": true,
|
||||
"reason": null,
|
||||
"retaggedTestWindow": false,
|
||||
"batteryBefore": [
|
||||
99
|
||||
],
|
||||
"batteryAfter": [
|
||||
99
|
||||
],
|
||||
"powerRestored": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:07:28.654641+00:00",
|
||||
"head": "db079eec9f64a865fca8a1a51b5304e071596383",
|
||||
"localChange": "bounded overlay wait on this PR branch",
|
||||
"passed": true,
|
||||
"overlayVisible": true,
|
||||
"inputPassed": true,
|
||||
"reason": null,
|
||||
"retaggedTestWindow": false,
|
||||
"batteryBefore": [
|
||||
100
|
||||
],
|
||||
"batteryAfter": [
|
||||
99
|
||||
],
|
||||
"powerRestored": true,
|
||||
"lockReleased": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Framey unworn exercise with standby gate
|
||||
|
||||
At 2026-10-26 01:52 UTC, this PR's `scripts/framey-vr-check.py --exercise`
|
||||
acquired the shared lock on a real Frame running the `c62bffc8` main bundle.
|
||||
The new read-only preflight found SteamVR activity 3 (standby), proximity raw
|
||||
6.3125 and battery 99% before allowing temporary installation or power changes.
|
||||
The lock owner was unique to this run.
|
||||
|
||||
The documented unworn exercise passed: the Frame Control overlay was visible,
|
||||
stereo compositor pixels changed, `/dev/video99` was captured, a synthetic
|
||||
trusted OpenVR pointer reached the sample, a live Decky setting was saved and
|
||||
persisted after a fresh panel reopen, and the quick action, slider and text
|
||||
field produced their expected values. Native scroll did not move. The test
|
||||
retagged only its temporary panel window once to recover focus. Public numeric
|
||||
results are in [framey-vr-standby-gate-2026-10-26.json](framey-vr-standby-gate-2026-10-26.json).
|
||||
Room-containing stereo and mirror captures remain private in `/tmp`.
|
||||
|
||||
Cleanup returned battery 99%, `power.pauseCompositorOnStandby=false` and
|
||||
`power.turnOffScreensTimeout=5.000000`, matching the preflight. The temporary
|
||||
power recovery unit was removed, no cleanup errors were reported, and only
|
||||
this run's lock was released. Local tests also checked that activity 1, 2 or
|
||||
unknown refuses both `--wake` and `--exercise` before any installation or
|
||||
power change.
|
||||
|
||||
Synthetic pointer events and captures do not prove physical controller use,
|
||||
wearer-visible readability, optical comfort or VR keyboard behavior. Those
|
||||
remain unverified.
|
||||
|
||||
## Combined v0.4.39 candidate reruns
|
||||
|
||||
After #278 and #279 joined main, the combined `bc72c41` branch ran the same
|
||||
locked exercise three times. The first two runs (02:01 and 02:02 UTC) loaded
|
||||
the sample UI in Chromium, but SteamVR kept its overlay hidden even after the
|
||||
existing test-window focus retry. Synthetic input reached no panel control;
|
||||
both runs failed. The third run (02:05 UTC) showed the overlay without that
|
||||
retry and passed the sample setting, input, stereo and mirror checks. Every run
|
||||
restored the original power settings, removed its recovery timer and released
|
||||
its own lock with no cleanup errors. The [numeric summary](framey-vr-standby-gate-2026-10-26.json)
|
||||
records all three outcomes.
|
||||
|
||||
The intermittent hidden-overlay condition remains a reliability limit. The
|
||||
runner now records a small read-only diagnostic and refuses to send synthetic
|
||||
input if the overlay stays hidden after its focus retry. A successful Chromium
|
||||
panel alone is not treated as VR visibility.
|
||||
|
||||
At 02:07 UTC, a fourth combined-head run with a bounded overlay wait passed
|
||||
without a focus retry. The sample setting/input and stereo checks passed; power
|
||||
settings were restored, the recovery timer was removed, and the owned lock was
|
||||
released. This single pass does not prove that the wait eliminates intermittent
|
||||
hidden overlays. Battery changed from 100% to 99% during the run.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
@@ -0,0 +1,112 @@
|
||||
# Physical Mac desktop check, 26 October 2026
|
||||
|
||||
Host: **MacBook Air (MacBookAir9,1), Intel Core i3 1.1 GHz, macOS 15.7.7
|
||||
(24G720), x86_64**. This is physical hardware. The console is an unattended
|
||||
server with display/automation restrictions. No Apple Silicon runtime result
|
||||
is inferred from this test.
|
||||
|
||||
## Released installers: verified, installed, cannot run on Intel
|
||||
|
||||
GitHub's latest release was v0.4.35 at the start; v0.4.36 became latest during
|
||||
the check. Each DMG and its SHA256SUMS were downloaded from that exact release
|
||||
with `gh release download`. Both passed `shasum -a 256 -c SHA256SUMS
|
||||
--ignore-missing` (only the downloaded asset was checked):
|
||||
|
||||
| Release | Asset | SHA-256 |
|
||||
|---|---|---|
|
||||
| v0.4.35 | Frame-Control-mac-arm64.dmg | `eda670748679ca02fcf242870cd53954b22cf8b93af47a193c9903bd08a8334f` |
|
||||
| v0.4.36 | Frame-Control-mac-arm64.dmg | `4d13068f0cfd3616823d63c82333afa7e368c1d57d375e9564f5f7ed4f0ebbd7` |
|
||||
|
||||
Each read-only DMG was mounted with `hdiutil attach -readonly -nobrowse`.
|
||||
There was no existing `/Applications/Frame Control.app`; the app was copied
|
||||
there with `ditto`, the command-line equivalent of the DMG's drag to
|
||||
Applications. `open "/Applications/Frame Control.app"` returned exactly:
|
||||
|
||||
> The application cannot be opened because it has an incorrect executable format.
|
||||
|
||||
`file` identified the executable as `Mach-O 64-bit executable arm64`.
|
||||
`codesign -dv` identified an ad hoc signature with no TeamIdentifier. The
|
||||
v0.4.35 `spctl -a -vv` assessment returned `rejected`. The v0.4.36 assessment
|
||||
stalled and is not counted as a completed assessment.
|
||||
|
||||
**No Gatekeeper warning dialog was observed.** LaunchServices rejected the
|
||||
architecture first. The CLI download had no `com.apple.quarantine` attribute,
|
||||
so this was not a browser-quarantine test. Neither right-click Open, Open
|
||||
Anyway nor removing quarantine was tested. On this Intel Mac there is no
|
||||
honest opening workaround for either released ARM64 app. Rosetta does not
|
||||
run ARM64 apps on Intel. The README now separates that fact from Apple's
|
||||
recommended Open Anyway flow for compatible Macs and labels the latter
|
||||
unverified here. No global Gatekeeper setting was changed.
|
||||
|
||||
## Separate Intel candidate: partial native runtime pass
|
||||
|
||||
[PR #262](https://github.com/saphid/frame-control/pull/262), commit `6ea9eb2`,
|
||||
adds native x64 DMG/ZIP builds, pinned x64 Python, an x86_64 Swift helper, and
|
||||
CI architecture checks. Swift 6.2.4 rejected an ambiguous
|
||||
`Data.withUnsafeMutableBytes` overload in the helper; an explicit
|
||||
`UnsafeMutableRawBufferPointer` annotation compiled successfully on this Air.
|
||||
|
||||
`npm run dist:mac:x64` completed locally, producing an ad hoc signed app,
|
||||
ZIP and DMG. The unpacked candidate was copied to
|
||||
`/Applications/Frame Control Intel Test.app` and launched with telemetry off,
|
||||
a task-local server data directory and a temporary loopback debug port.
|
||||
This is **not** the published v0.4.36 binary.
|
||||
|
||||
The first launch stalled for several minutes before app code ran. A process
|
||||
sample showed only `_dyld_start`, a 12 KiB footprint and no loaded image
|
||||
list, while `syspolicyd` used about 89% CPU. It later proceeded without any
|
||||
system-service restart; the app window and server loaded. The initial stall
|
||||
is a host observation, not proof of a Frame Control bug. Restarting that
|
||||
security daemon was rejected by automatic approval review, and was not done.
|
||||
|
||||
| Check | Observed result and limit |
|
||||
|---|---|
|
||||
| Native window and IPC bridge | The Electron renderer displayed Apps and the offline identity warning; `window.frameApp` was present. Native renderer inspected through its temporary loopback CDP port; this does not prove every macOS permission dialog or physical click |
|
||||
| Settings | Native preference read returned `{}`. Writing `mvSeparate="0"` read back correctly. The original preference file was restored byte-for-byte (or removed when originally absent) in a finally block. Persistence across an app restart is not claimed |
|
||||
| Open at Login | Source is opt-in and focused login-item tests passed. Native menu read via System Events timed out; an OS login session and actual checkbox state remain unverified. No login item was enabled |
|
||||
| Logs | `~/Library/Logs/Frame Control/server.log` exists. Its startup line proves the candidate used `Contents/Resources/python/bin/python3` and its bundled `ui/server.py`; HTTP reads and offline failures were logged. Show Server Log menu interaction was not verified |
|
||||
| Update check | Actual `frameApp.update.check()` completed with `status="none"`, `current="0.4.36"`, latest `0.4.36`, and `error=null`. No update was installed; native dialog dismissal was not verified |
|
||||
| OBS setup and lifecycle | Native status reported supported/idle, Start on port 15123 reported starting, Stop reported stopping, then idle. The Frame's SSH identity rejection prevented a headset stream. This checks local lifecycle only: no OBS app, audio/video delivery or receiver tested |
|
||||
| Decky store | The exact released UI/server resources, separately served by this Mac's existing Python 3.9.6, loaded 116 official entries in local Chromium. Forty rows rendered; searching ProtonDB found ProtonDB Badges 1.3.4; all Install buttons stayed disabled while offline. No loader or plugin was installed. [Browser screenshot](mac-decky-browser-2026-10-26.png) |
|
||||
| Other UI | Released Apps, Settings, Privacy & updates, Tools, Capture & Live and Decky views rendered. The privacy page correctly reported telemetry blocked by `FRAME_CONTROL_TELEMETRY=0`; sharing/contact choices were left alone. The activity log displayed the offline failure. The browser path hides native OBS buttons as designed; it is not a substitute for the native lifecycle check above |
|
||||
| Framey preview | No standalone local preview control was found in the desktop UI. The Framey panel belongs to the headset runtime; no VR or plugin-backend result is claimed |
|
||||
|
||||
The released server refused a stale SSH host identity rather than silently
|
||||
trusting it. That safety check was preserved; no host pin was replaced just
|
||||
to make the test pass. Capture, media, game launches, power controls, installs,
|
||||
sign-ins and notifications were not triggered without a working connection.
|
||||
|
||||
## Headset: read-only relay checks, no changes
|
||||
|
||||
`frame.local` resolved on the Air, but direct TCP/SSH did not connect. The
|
||||
coordinator host lxso2 could reach it. The app's read-only `frame_status.py`
|
||||
was piped through lxso2 to the Frame and returned:
|
||||
|
||||
- SteamOS **0.5.6**, build **20261022.6153909**, variant `vr`.
|
||||
- Battery **99%, Charging**.
|
||||
- Home storage: 996,984,774,656 bytes total, 769,023,275,008 bytes free.
|
||||
- A read-only search counted **14** Steam screenshot JPEGs; none was copied.
|
||||
|
||||
The shared `/tmp/frame-test.lock` was owned by another Framey test on each
|
||||
check. This job did not acquire, remove or replace it. File upload/delete,
|
||||
OBS on the device and every other headset mutation were skipped. These relay
|
||||
reads do not prove the native Mac app's direct SSH or file-transfer path.
|
||||
The headset was never wiped or rebooted.
|
||||
|
||||
## Validation and follow-up
|
||||
|
||||
A disposable Linux checkout of exact commit `6ea9eb2` on lxso2 passed:
|
||||
**1,590 server tests (16 skipped)** and **46 app tests**. On the physical Air,
|
||||
**14 focused login-item/preferences/OBS tests** and **18 website tests** passed.
|
||||
Full Mac server/release suites were attempted but spawned helper programs
|
||||
stalled and timed out during the policy-service problem; they are not a pass.
|
||||
Node 22's complete app run cancelled sign-in tests around an unreferenced
|
||||
timeout; the exact same app suite passed on lxso2. CI uses Node 24.
|
||||
|
||||
Required next steps: pass exact-head Mac packaging/architecture CI, publish
|
||||
an Intel asset through coordinator job mppkd9qczt, then repeat a normal
|
||||
browser-download/quarantine installation of that release on this Air. Verify
|
||||
Open Anyway and Open at Login through an authorized interactive console.
|
||||
Acquire the shared headset lock before a temporary transfer or real OBS test.
|
||||
Preserve all unverified labels until those checks actually pass. PR #113 was
|
||||
not changed. No social post was made.
|
||||
@@ -0,0 +1,24 @@
|
||||
# v0.4.37 release smoke on a real Frame
|
||||
|
||||
On 26 October 2026 at 00:27 UTC, a physical Linux host ran the release
|
||||
`scripts/frame-smoke.sh` against a real Steam Frame. The smoke scripts were
|
||||
byte-identical to tag `v0.4.37` (`5239dae1a82c35635ef89f59eac9721c5014e2ed`).
|
||||
The headset reported Frame OS 0.5.6, build `20261022.6153909`.
|
||||
|
||||
The test acquired `/tmp/frame-test.lock` with a unique owner marker before
|
||||
reading the headset. SteamVR activity stayed at 3 (standby); initial proximity
|
||||
raw was 6.3125 and battery was 99%. The test released only its own lock after
|
||||
cleanup. Battery read 100% afterward.
|
||||
|
||||
All **13 of 13** smoke steps passed. ARM64 and Windows test titles installed,
|
||||
launched, and were removed. The x86_64 title installed and was removed; its
|
||||
launch reported that Steam Linux Runtime 4.0 was found but not installed, so
|
||||
this result does **not** establish x86_64 game execution. Devkit properties and
|
||||
status reads passed. No key was paired. The final cleanup removed the reserved
|
||||
test titles and processes. The private result remains under
|
||||
`/tmp/frame-v0437-smoke-results/`; this summary omits headset identifiers and
|
||||
device status details.
|
||||
|
||||
Because the headset was in standby, this test did not verify wearer-visible
|
||||
pixels, physical controller input, a Framey panel, or official Decky plugin
|
||||
settings in VR. Those checks remain on the [worn checklist](../framey-vr-check.md).
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"tag": "v0.4.38",
|
||||
"commit": "d87b863aa3c61c9833045b29ba79c7534a351ef4",
|
||||
"startedUtc": "2026-10-26T01:47:23Z",
|
||||
"frameOSVersion": "0.5.6",
|
||||
"frameOSBuild": "20261022.6153909",
|
||||
"preflight": {
|
||||
"steamVRActivity": 3,
|
||||
"proximityRaw": 6.3125,
|
||||
"batteryPercent": 100
|
||||
},
|
||||
"postflight": {
|
||||
"steamVRActivity": 3,
|
||||
"proximityRaw": 6.25,
|
||||
"batteryPercent": 100
|
||||
},
|
||||
"smoke": {
|
||||
"passed": 13,
|
||||
"failed": 0,
|
||||
"arm64": "running",
|
||||
"x86_64": "runtime missing",
|
||||
"windowsExe": "running",
|
||||
"finalCleanupPassed": true,
|
||||
"ownedProcessesAfterCleanup": 0
|
||||
},
|
||||
"sharedLock": "unique owner released",
|
||||
"limits": [
|
||||
"No worn headset",
|
||||
"No physical controller input",
|
||||
"No official Decky panel tested in this smoke"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# v0.4.38 real Frame release smoke
|
||||
|
||||
On 2026-10-26 at 01:47 UTC, the exact `v0.4.38` tag
|
||||
`d87b863aa3c61c9833045b29ba79c7534a351ef4` passed `scripts/frame-smoke.sh`
|
||||
on a real Frame running SteamOS 0.5.6 build `20261022.6153909`. The wrapper
|
||||
atomically acquired `/tmp/frame-test.lock` with a unique owner, then read
|
||||
SteamVR activity 3 (standby), proximity raw 6.3125 and battery 100%. It ran
|
||||
the smoke only after that preflight.
|
||||
|
||||
All 13 smoke steps passed. The ARM64 test title ran. The x86_64 title correctly
|
||||
reported its runtime missing. The Windows executable ran through the ARM64
|
||||
Proton path. The final cleanup found no test processes and removed the reserved
|
||||
smoke titles. The read-only postflight still found activity 3 and battery 100%;
|
||||
the wrapper released only its own lock. The public numeric summary is
|
||||
[v0438-release-smoke-2026-10-26.json](v0438-release-smoke-2026-10-26.json).
|
||||
Private raw logs and process paths remain in `/tmp` and were not published.
|
||||
|
||||
The new built-in shared-lock preflight on this PR's branch separately passed
|
||||
the same 13/13 smoke steps at 01:46 UTC, with activity 3, proximity raw 6.3125
|
||||
and battery 100% before the run. The lock was released after cleanup. The local
|
||||
`tests.test_smoke_cleanup` suite passed 20 tests, including refusal on a busy
|
||||
lock and release after a failed preflight. The built-in preflight was **not**
|
||||
part of the v0.4.38 tag; that release used the external lock wrapper described
|
||||
above.
|
||||
|
||||
These checks did not involve a worn headset or prove Framey panel visibility,
|
||||
physical controller interaction, or optical comfort.
|
||||
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"tag": "v0.4.39",
|
||||
"tagCommit": "fd0b0a946f11d92fd1cc480cd9d1a83be795bf71",
|
||||
"releaseUrl": "https://github.com/saphid/frame-control/releases/tag/v0.4.39",
|
||||
"releaseId": 423799494,
|
||||
"releaseWorkflow": {
|
||||
"runId": 40258561639,
|
||||
"conclusion": "success",
|
||||
"jobsPassed": 6
|
||||
},
|
||||
"publishedLatest": true,
|
||||
"assets": [
|
||||
{
|
||||
"name": "Frame-Control-linux-amd64.deb",
|
||||
"size": 131444552,
|
||||
"digest": "sha256:9ca74551fe47ea244f500e1b4119c02307d185d1acea6287a02a5f6c055c674e"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.AppImage",
|
||||
"size": 156170292,
|
||||
"digest": "sha256:66bd8f644f6a0b06f1e46ff635c5b50e53d3f2a9c6736f12b737ee0ad1667335"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-arm64.deb",
|
||||
"size": 118529048,
|
||||
"digest": "sha256:f6b476a2f3163028d0862a13d0c8890760bdc8fe52c1006f106d9e540de32a6a"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-linux-x86_64.AppImage",
|
||||
"size": 164587947,
|
||||
"digest": "sha256:240023c89bea049f0864da75f83ec235c138375c4fd1c4612953a4c5863610ca"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.dmg",
|
||||
"size": 162169626,
|
||||
"digest": "sha256:c0243317555975b1a5588640ec6fe71db5014788858ff00dd45914b77c1fe99a"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-arm64.zip",
|
||||
"size": 162054585,
|
||||
"digest": "sha256:08191f80d6c78ac8576e2e2fe7937f5c1689ebed780528e63b6fb41b3c90d823"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.dmg",
|
||||
"size": 169230398,
|
||||
"digest": "sha256:f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-mac-x64.zip",
|
||||
"size": 169141014,
|
||||
"digest": "sha256:bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-Setup-x64.exe",
|
||||
"size": 136500288,
|
||||
"digest": "sha256:44a9f4c072b8c10ec81b060484764af10607763c69d55ecdad6bd0b103b7207e"
|
||||
},
|
||||
{
|
||||
"name": "Frame-Control-win-x64.zip",
|
||||
"size": 183446427,
|
||||
"digest": "sha256:14f3dae327f054431ecb7ed204144dc529e04a03b35b646925bd9812dc54c504"
|
||||
}
|
||||
],
|
||||
"sha256sumsDigest": "sha256:59cff0ab59e2a681656c0b15b3445ca0baa0546d03196f8c4ae58eae8785db25",
|
||||
"updateJsonDigest": "sha256:afef87fa3869be3acf06dfaa4831a97b29ce9c5d275940a2202bc958c62f4d67",
|
||||
"publisherDryRun": {
|
||||
"passed": true,
|
||||
"installerDigests": 10,
|
||||
"digestAndChecksumChecks": 11,
|
||||
"completeNotesCharacters": 3837
|
||||
},
|
||||
"downloadedAppImage": {
|
||||
"name": "Frame-Control-linux-x86_64.AppImage",
|
||||
"sha256": "240023c89bea049f0864da75f83ec235c138375c4fd1c4612953a4c5863610ca",
|
||||
"isolatedXvfbHttpStatus": 200,
|
||||
"serverStopped": true,
|
||||
"serverHeaderPrefix": "FrameControl/1"
|
||||
},
|
||||
"downloadedIntelMacZip": {
|
||||
"name": "Frame-Control-mac-x64.zip",
|
||||
"sha256": "bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7",
|
||||
"machOCpu": "x86_64",
|
||||
"inspected": [
|
||||
"app executable",
|
||||
"bundled Python 3.12",
|
||||
"Electron helper"
|
||||
],
|
||||
"physicalRuntime": "unverified"
|
||||
},
|
||||
"realFrameSmoke": {
|
||||
"sourceTag": "v0.4.39",
|
||||
"started": "2026-10-26T02:20:45Z",
|
||||
"buildId": "20261022.6153909",
|
||||
"preflight": {
|
||||
"activity": 3,
|
||||
"proximity": [
|
||||
6.3125
|
||||
],
|
||||
"battery": [
|
||||
100
|
||||
]
|
||||
},
|
||||
"passed": 13,
|
||||
"failed": 0,
|
||||
"steps": [
|
||||
{
|
||||
"name": "cleanup: leftovers from earlier runs",
|
||||
"passed": true,
|
||||
"seconds": 8.2
|
||||
},
|
||||
{
|
||||
"name": "devkit service: properties.json",
|
||||
"passed": true,
|
||||
"seconds": 0.0
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"passed": true,
|
||||
"seconds": 0.6
|
||||
},
|
||||
{
|
||||
"name": "arm64: install",
|
||||
"passed": true,
|
||||
"seconds": 8.0
|
||||
},
|
||||
{
|
||||
"name": "arm64: launch",
|
||||
"passed": true,
|
||||
"seconds": 3.5
|
||||
},
|
||||
{
|
||||
"name": "arm64: remove",
|
||||
"passed": true,
|
||||
"seconds": 6.8
|
||||
},
|
||||
{
|
||||
"name": "x86_64: install",
|
||||
"passed": true,
|
||||
"seconds": 7.9
|
||||
},
|
||||
{
|
||||
"name": "x86_64: launch",
|
||||
"passed": true,
|
||||
"seconds": 3.5
|
||||
},
|
||||
{
|
||||
"name": "x86_64: remove",
|
||||
"passed": true,
|
||||
"seconds": 6.6
|
||||
},
|
||||
{
|
||||
"name": "exe: install",
|
||||
"passed": true,
|
||||
"seconds": 8.0
|
||||
},
|
||||
{
|
||||
"name": "exe: launch",
|
||||
"passed": true,
|
||||
"seconds": 3.4
|
||||
},
|
||||
{
|
||||
"name": "exe: remove",
|
||||
"passed": true,
|
||||
"seconds": 6.5
|
||||
},
|
||||
{
|
||||
"name": "cleanup: everything this run installed",
|
||||
"passed": true,
|
||||
"seconds": 8.1
|
||||
}
|
||||
],
|
||||
"ownedLockReleased": true
|
||||
},
|
||||
"postReleasePr282": {
|
||||
"headCommit": "ce6e8f807f44b614e5bbd5c5a5a6a2093236d917",
|
||||
"mergedCommit": "67b9151513bc9fb550f5dfb2d2eec925df390ab3",
|
||||
"exactHeadCiPassed": 11,
|
||||
"unwornVrExercise": {
|
||||
"passed": true,
|
||||
"beforeActivity": 3,
|
||||
"beforeBattery": [
|
||||
99
|
||||
],
|
||||
"afterBattery": [
|
||||
99
|
||||
],
|
||||
"overlayVisible": true,
|
||||
"compositorChanged": true,
|
||||
"stereoCaptureSha256": "c7edc11877aca35dd3aebe480feee93253d04825fe9bd75d02136bb7e3510b97",
|
||||
"mirrorCaptureSha256": "8e5932fb0046b218a1781a6b06cfaa6e3c13dc7874799e287066ed6e43640cd4",
|
||||
"syntheticPointerInputPassed": true,
|
||||
"liveSettingReopened": true,
|
||||
"nativeScrollMoved": false,
|
||||
"powerRecoveryRemoved": true,
|
||||
"ownedLockReleased": true,
|
||||
"cleanupErrors": [],
|
||||
"physicalControllerVerified": false,
|
||||
"wornVisualVerified": false,
|
||||
"officialPluginsInVrVerified": false
|
||||
},
|
||||
"realFrameSmoke": {
|
||||
"passed": 13,
|
||||
"failed": 0,
|
||||
"beforeActivity": 3,
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"buildId": "20261022.6153909",
|
||||
"ownedLockReleased": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# v0.4.39 release gate, 26 October 2026
|
||||
|
||||
The [published release](https://github.com/saphid/frame-control/releases/tag/v0.4.39) is GitHub's latest release. Its tag resolves to main commit `fd0b0a946f11d92fd1cc480cd9d1a83be795bf71`, after PRs #280 and #281 merged. The [tagged workflow](https://github.com/saphid/frame-control/actions/runs/40258561639) passed all six jobs: draft, Apple Silicon Mac, Intel Mac, Windows, Linux and checksums. The release has ten installers, `SHA256SUMS` and `update.json`. [Sanitized numeric results](v0439-release-2026-10-26.json) contain the exact asset sizes and GitHub digests.
|
||||
|
||||
| Gate | Result |
|
||||
|---|---|
|
||||
| `scripts/publish-release.sh --dry-run v0.4.39` from the exact tag | Passed: all ten installer digests, regenerated `SHA256SUMS` digest, complete update notes and ten-asset `update.json` |
|
||||
| Downloaded Linux x86_64 AppImage | SHA-256 matched `240023c89bea049f0864da75f83ec235c138375c4fd1c4612953a4c5863610ca`; the tagged isolated Xvfb check received HTTP 200 with a `FrameControl/1` server header prefix and confirmed the server stopped |
|
||||
| Downloaded Intel Mac ZIP | SHA-256 matched `bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7`; ZIP inspection found x86_64 Mach-O app executable, bundled Python 3.12 and Electron helper |
|
||||
| Exact tagged `scripts/frame-smoke.sh` on the real Frame | **13/13** steps passed on SteamOS build `20261022.6153909`; locked preflight found SteamVR activity 3, proximity raw 6.3125 and battery 100%. Reserved test titles were removed and the script released its owner-token lock. |
|
||||
| Published release readback | GitHub reports `draft=false`, `prerelease=false`, latest tag `v0.4.39`; downloaded `update.json` lists ten unique assets, the stable release URL and complete safety notes |
|
||||
|
||||
The first local AppImage launch attempt stopped before app startup because the execution sandbox denied Electron's sandbox host (`Operation not permitted`). Repeating the same tagged check with host permissions passed. The bundled release CI also ran its own AppImage launch check.
|
||||
|
||||
The unworn VR runner's candidate evidence is [documented separately](framey-vr-standby-gate-2026-10-26.md). Its successful stereo and `/dev/video99` panel captures and synthetic OpenVR input are not a worn or physical-controller test. Two other attempts found an intermittently hidden overlay and failed safely. No person wore the headset for this release check. Physical controller input, optical comfort, VR keyboard, native scroll and official-plugin wearer-visible behavior remain unverified.
|
||||
|
||||
The physical Intel MacBook Air ran an earlier candidate, but was unreachable for this exact tagged installer. The architecture and hash checks above do not prove physical Mac runtime. Physical Windows PC and macOS OBS/headset capture also remain unverified. No private room pixels or raw Steam log lines are included here.
|
||||
|
||||
## Post-release Framey library change (#282)
|
||||
|
||||
After v0.4.39 publication, [PR #282](https://github.com/saphid/frame-control/pull/282) merged at `67b9151513bc9fb550f5dfb2d2eec925df390ab3`. All eleven exact-head checks passed on `ce6e8f807f44b614e5bbd5c5a5a6a2093236d917`. This commit is **not** in v0.4.39. The [library-context evidence](framey-library-context-2026-10-26.md) covers its three newly partial official plugins and separate backend limits.
|
||||
|
||||
The exact #282 source ran `scripts/framey-vr-check.py --exercise` under the shared lock from standby (activity 3, battery 99%). The sample panel became visible; stereo compositor pixels changed and `/dev/video99` mirror bytes were captured. Trusted synthetic OpenVR pointer input reached the repository sample's live Decky setting and the saved value survived reopening. Native scroll did not move. The runner removed its recovery timer, restored original power values, reported no cleanup errors and released its owner-token lock. Private images and room pixels remain outside this repository. The source came from an exact commit archive, so the runner's `sourceCommit` field was empty; the archive object ID was verified before extraction.
|
||||
|
||||
Once the Frame returned to standby, the exact #282 source passed `scripts/frame-smoke.sh` **13/13** on the real SteamOS build `20261022.6153909`, with battery 99% at preflight. Test titles were removed and the lock was released. These checks cover the Framey sample and generic Frame smoke, not Steamback, PlayTime or Deck Settings in VR. No wearer or physical controller was involved; those limits remain open.
|
||||
@@ -66,8 +66,9 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `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 |
|
||||
| `SteamClient` subscriptions and `DFL.Router.MainRunningApp` | Explicit inert registration methods return disposable handles; no events or attached running game. Mutating Steam/power methods are unavailable. | Host contract tests; these are safe fallbacks, not live Steam functionality |
|
||||
| `DialogCheckbox`, `DialogButtonPrimary` | Native checkbox and button controls in the panel | Host unit test; Storage Cleaner empty-data browser check |
|
||||
| Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. |
|
||||
| Optional `settings`, `frameyQuickActions` | Separate settings tab when content also exists; settings-only plugins have no empty Panel tab; up to eight actions with result text | Host unit test; these fields are Framey additions |
|
||||
| `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e; real ARM64 Notebook save/reopen across backend restart and Simple Timer settings/recents persistence via local Chromium. No worn-VR claim. |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -75,7 +76,7 @@ none of these frontend controls has passed a worn-headset check yet.
|
||||
| `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. |
|
||||
| Steam library/store patches, CEF tab injection | Unavailable; no matching target in the isolated panel | Frame CEF inspection |
|
||||
| `fetchNoCors`, Decky event listeners, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
| `fetchNoCors`, file picker, root calls | Unavailable | Not supported by Framey |
|
||||
|
||||
Keep routes local to the panel and give every operation a visible result. A
|
||||
plugin that calls `Navigation.Navigate('/decky/store')` will not open the
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
# Official Decky store compatibility with Framey
|
||||
|
||||
Snapshot: 2026-10-25T23:19:48.854504+00:00; 116 current visible official plugins from https://plugins.deckbrew.xyz/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: 1 · Partial: 48 · Broken: 56 · 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.
|
||||
- **Broken** means a concrete archive, frontend or Steam dependency blocks the audited version. An artificial backend/network denial alone is not evidence that the real backend is broken.
|
||||
- **N/A** means the plugin targets hardware or a display/UI surface that Framey does not provide. The store disables installation for these versions.
|
||||
|
||||
## Verification limits
|
||||
|
||||
All exact archives were hash-checked and statically inspected. The local Chromium host used Framey commit `779f9c5`, React 18.3.1 and an opaque `allow-scripts` iframe. Each frontend was attempted for 1.2 seconds, including React effects. All plugin backend calls, fetches and websockets were denied. Some plugins attempt mutating actions on mount; none reached a backend. Initialization (`ready`) does not guarantee React rendered, and delayed errors/actions may occur after the observation window. The JSON preserves initializer errors, capped console observations and attempted method names.
|
||||
|
||||
The optional full-catalog fake-Frame audit exercises the real HTTP/SSH installer, receipt, frontend bytes and removal, or expected rejection. It uses a private disposable Compose project with all plugin backend execution stubbed. It checks archive transport and safeguards, not Python/native execution. Run it with:
|
||||
|
||||
```sh
|
||||
python3 scripts/framey-audit-store.py --cache /tmp/framey-archives --output /tmp/framey-observations.json
|
||||
python3 scripts/framey-audit-fake.py --cache /tmp/framey-archives --output /tmp/framey-fake/results.json
|
||||
python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives
|
||||
```
|
||||
|
||||
Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.
|
||||
|
||||
The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.
|
||||
|
||||
After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, countdown/completion events, a visible toast and persisted recents/subtle mode. Only private test settings were written. Simple Timer remains partial because its own expired-timer startup raises a missing `timer_task` error; sound/suspend are unsupported. A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, independent server readbacks and Mixer filter persistence across a runtime restart passed. These three stay partial: real ARM64 Frame audio dependencies/session mapping remain unverified because the shared headset lock was occupied. 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. Other official backends remain unexecuted. The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. No worn-VR official-plugin result is claimed. A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings with all backend/network actions denied; it does not verify process pausing or Tailscale changes. A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, install folders and system information, plus IsThereAnyDeal's country dropdown in local Chromium. The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), [Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), [Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and [legacy MusicControl](evidence/framey-legacy-2026-10-25.md).
|
||||
|
||||
## 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.
|
||||
|
||||
| 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 |
|
||||
| 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 | Legacy media controls initialize; player discovery and playback are unverified. | static archive, local browser, fake-Frame |
|
||||
| 13: Pause Games 1.0.3 | 1 / 2 | partial | Settings render locally with inert system-key subscriptions and empty detached Steam context. Live-game discovery, process pause/resume and suspend integration are unsupported/unverified; no backend actions ran. | static archive, local browser, fake-Frame |
|
||||
| 14: ProtonDB Badges 1.3.4 | 2 / 2 | partial | Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 16: Audio Loader 1.6.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: Cannot read properties of null (reading 'm_GamepadUIAudioStore'). | 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 |
|
||||
| 19: Notebook 0.1.5 | legacy / legacy | works | Default notebook drawing, save and reopen verified in Chromium with real Decky 3.3.0 ARM64, including backend restart. Per-game auto-selection and background artwork unavailable; worn-VR input unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 21: PowerTools 2.0.3 | legacy / legacy | N/A | Not supported on Frame: Targets AMD Ryzen power controls; Frame uses an ARM64 SoC. | static archive, local browser, fake-Frame |
|
||||
| 22: Memory Deck 0.1.8 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: memory-deck/bin/libscanmem.so.1.0.0. | static archive, local browser, fake-Frame |
|
||||
| 23: Animation Changer 1.3.2 | legacy / legacy | partial | Initializer succeeds; forced backend failure prevents loading animations. Boot-video changes untested. | static archive, local browser, fake-Frame |
|
||||
| 24: RadiYo! 1.3 | legacy / legacy | partial | Initializer succeeds; radio discovery and playback remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 26: HLTB for Deck 2.0.12 | 1 / 2 | partial | Settings render without the prior IndexedDB SecurityError; Steam library badges have no patch target, and live lookup is unverified. | static archive, local browser, fake-Frame |
|
||||
| 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 |
|
||||
| 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 | Frontend audit failed: DFL.createModuleMapping is not a function. | static archive, local browser, fake-Frame |
|
||||
| 37: Discord Status 1.5.0 | 1 / 2 | broken | Detached app overviews are now available, but RunningApps/lifetime observations and Steam artwork helpers still lack a live mapping. Discord activity integration remains unavailable/unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | partial | Local Chromium discovers and changes a private silent stream through the actual Decky 3.3.0 Linux backend, with independent Pulse readback. Frame ARM64 libraries/audio session and Bluetooth playback controls remain unverified. | static archive, local browser, fake-Frame, isolated Linux backend |
|
||||
| 40: Controller Tools 2.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: ControllerTools/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 43: Tailscale Control 0.1.3 | legacy / legacy | partial | Settings render locally after the SuspensefulImage fallback. Tailscale daemon/state, connection and exit-node changes unverified; bundled localhost QR asset remains unavailable. No network settings were changed. | static archive, local browser, fake-Frame |
|
||||
| 44: DeckMTP 1.0.5 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: DeckMTP/bin/umtprd. | static archive, local browser, fake-Frame |
|
||||
| 46: Steamback 1.0.0-1 | legacy / legacy | partial | Panel opens using the real Frame current-user/library snapshot. Account setup and empty save-list reads pass through actual Decky 3.3.0 amd64 in isolation. ARM64 backend, supported-save discovery, automatic backups and restore remain unverified; no backup/restore ran. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 48: Quick Launch 1.2.1 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 49: Decky Cloud Save 1.4.2 | legacy / legacy | broken | Unsafe plugin archive member. | static archive, local browser, fake-Frame |
|
||||
| 50: Storage Cleaner 1.4.0 | 1 / 2 | partial | Empty-data panel rendered; cleanup was deliberately not run because it can remove local data. | static archive, local browser, fake-Frame |
|
||||
| 52: Game Theme Music 1.7.1-1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'type'). | static archive, local browser, fake-Frame |
|
||||
| 53: Shotty 0.1.0 | legacy / legacy | broken | Library snapshot fixes the frontend allApps crash. Actual published Decky 3.3.0 amd64 backend fails importing click.get_app_dir (ModuleNotFoundError); the archive does not bundle click. Screenshot aggregation/rescue was not run. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 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 | partial | Local Chromium sync/separate sliders, numeric volume entry and cache reopen pass through the actual Decky 3.3.0 backend on a private virtual stereo sink. Frame pactl/default sink, boost above 100% and physical audio remain unverified. | static archive, local browser, fake-Frame, isolated Linux backend |
|
||||
| 59: Web Browser 1.4.3 | legacy / legacy | broken | Frontend audit failed: deckyFrontendLib.getReactRoot is not a function. | static archive, local browser, fake-Frame |
|
||||
| 60: DeckyFileServer 1.2.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: DeckyFileServer/bin/backend. | static archive, local browser, fake-Frame |
|
||||
| 61: TabMaster 2.15.1 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio'). | 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 | broken | Unsafe plugin archive member. | 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. | 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. ARM64 backend, live MobX/running-game observations and automatic recording remain unverified/unsupported. | 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 |
|
||||
| 68: Wine Cellar 0.2.1 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-wine-cellar/bin/backend. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 70: MicroSDeck 0.11.2 | legacy / 2 | broken | Plugin contains a non-ARM64 Linux binary: MicroSDeck/bin/backend. | static archive, local browser, fake-Frame |
|
||||
| 72: ScreenshotUploader 0.0.3 | legacy / legacy | partial | Initializer succeeds; screenshot upload requires external credentials/network and was not run. | static archive, local browser, fake-Frame |
|
||||
| 73: EmuDecky 1.0.2-1 | legacy / legacy | broken | Plugin contains a non-ARM64 Linux binary: EmuDecky/bin/hello. | static archive, local browser, fake-Frame |
|
||||
| 74: KDE Connect 0.1.0-1 | legacy / legacy | partial | Initializer succeeds; KDE Connect installation/pairing/file transfers unverified. | static archive, local browser, fake-Frame |
|
||||
| 75: Reshadeck 0.2.0 | legacy / legacy | partial | Initializer succeeds; ReShade deployment into Proton games is unverified. | static archive, local browser, fake-Frame |
|
||||
| 76: CheatDeck 1.1.6 | 1 / 2 | broken | Frontend audit failed: Cannot convert undefined or null to object. | static archive, local browser, fake-Frame |
|
||||
| 77: BT Wake Control 1.2.0 | 1 / 2 | partial | Initializer succeeds; backend uses DMI board paths and Bluetooth wake permissions, unverified on ARM64 Frame. | static archive, local browser, fake-Frame |
|
||||
| 78: Brightness Bar 1.0.4 | legacy / legacy | N/A | Not supported on Frame: Targets Steam/QAM brightness shortcuts and a global Steam overlay; neither is attached to the Framey panel. | static archive, local browser, fake-Frame |
|
||||
| 79: Decky Notifications 1.0.1 | legacy / legacy | partial | Initializer succeeds; device/account setup and notification delivery unverified. | static archive, local browser, fake-Frame |
|
||||
| 80: MagicBlack 2.0.2 | 1 / 2 | N/A | Not supported on Frame: Targets a global Steam Deck screen-black overlay; the isolated panel cannot black out the Frame compositor. | static archive, local browser, fake-Frame |
|
||||
| 81: IsThereAnyDeal for Deck 1.1.1 | legacy / legacy | partial | Country settings render and select the real Frame Steam country through the read-only bridge. Settings writes, deal lookups and Steam page integration remain unverified; plugin backend calls were denied. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 82: MagicPods 2.0.21 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: MagicPodsDecky/bin/magicpodscore. Frontend audit failed: Audit blocks WebSocket. | static archive, local browser, fake-Frame |
|
||||
| 84: Junk-Store 1.1.13 | legacy / legacy | broken | Route registration succeeds; rendering fails on a missing Steam ContainerBackground component. | static archive, local browser, fake-Frame |
|
||||
| 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 |
|
||||
| 90: Picture in Picture 1.0.0 | 1 / 2 | partial | Initializer succeeds; global picture-in-picture injection cannot escape the isolated panel. | static archive, local browser, fake-Frame |
|
||||
| 91: PlayCount 1.7 | 1 / 2 | partial | Initializer succeeds; Steam game-page play-count patch has no supported VR target. | static archive, local browser, fake-Frame |
|
||||
| 92: Crosshair 1.1.0 | 1 / 2 | partial | Initializer succeeds; global crosshair injection has no supported compositor target. | static archive, local browser, fake-Frame |
|
||||
| 93: Deck Settings 1.2.6 | 1 / 2 | partial | Game picker renders with detached real Frame library/install-folder reads; search modal opens in local Chromium. Snapshot refreshes on panel reopen. Remote reports/proxy fetch, settings changes and ARM64 backend remain unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 94: Decky DoT DNS 0.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 95: Decky-SpoofDPI 0.12.0 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: decky-spoof-dpi/bin/spoofdpi. | static archive, local browser, fake-Frame |
|
||||
| 96: Simple Timer 1.0.0 | 1 / 2 | partial | Live start/cancel, countdown events, completion toast, persisted recents/subtle mode verified in Chromium with real ARM64 Decky. Upstream expired-timer restart raises missing timer_task. Audio/suspend unsupported; alerts require an open panel; worn VR unverified. | static archive, local browser, fake-Frame, real Frame SSH |
|
||||
| 97: Decky-Framegen 0.11.15 | 1 / 2 | broken | Plugin archive size or SHA-256 did not match the store. | static archive, local browser, fake-Frame |
|
||||
| 98: Decky-Lookup 0.1.0 | 1 / 2 | partial | Initializer succeeds; game-page context actions and live lookups remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 99: Decky Zerotier 0.3.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 100: Virtual Surround Sound 0.0.5 | 1 / 2 | partial | Initializer succeeds; avatar proxy is unavailable and live audio-routing backend unverified. | static archive, local browser, fake-Frame |
|
||||
| 101: XIVOmega 1.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 102: DeckSP 1.1.3 | 1 / 2 | broken | Frontend audit failed: Cannot read properties of null (reading 'GamepadUIAudio'). | static archive, local browser, fake-Frame |
|
||||
| 103: Decky Ludusavi 1.2.2 | 1 / 2 | partial | Initializer succeeds; Flatpak Ludusavi discovery and backup/restore unverified. | static archive, local browser, fake-Frame |
|
||||
| 104: Game Sync 1.0.4 | 1 / 2 | partial | Backend get_config is intentionally rejected by the audit before initialization completes; actual backend compatibility is unknown. | static archive, local browser, fake-Frame |
|
||||
| 105: WiFi Locker 1.0.2 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 106: LetMeReShade 1.8 | 1 / 2 | partial | Initializer succeeds; Deck-model assumptions, shader downloads and game modifications unverified. | static archive, local browser, fake-Frame |
|
||||
| 107: Decky Sunshine 2025.10.27-dddf365 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 108: HueSync 3.8.0 | 1 / 2 | N/A | Not supported on Frame: Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. | static archive, local browser, fake-Frame |
|
||||
| 109: MuraDeck 2.1.4 | 1 / 2 | N/A | Not supported on Frame: Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. | static archive, local browser, fake-Frame |
|
||||
| 110: Dimmer Deck 0.0.4 | 1 / 2 | N/A | Not supported on Frame: Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. | static archive, local browser, fake-Frame |
|
||||
| 111: Music Control 1.1.7 | legacy / legacy | partial | Panel initializer succeeds with backend/network blocked; actual controls and backend remain unverified. | static archive, local browser, fake-Frame |
|
||||
| 112: Better Keyboard 0.3.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. Frontend audit failed: window.SteamClient.UI.GetUIMode is not a function. | static archive, local browser, fake-Frame |
|
||||
| 113: Decky LSFG-VK 0.12.2 | 1 / 2 | partial | Initializer succeeds; runtime LSFG/Windows DLL downloads and Vulkan integration are not ARM64-validated. | static archive, local browser, fake-Frame |
|
||||
| 114: Network Sentinel 1.0.4 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 120: Reroll 0.2.1 | 1 / 2 | broken | Frontend now initializes with inert game-action subscriptions, but the archive contains x86_64 memscan.so and requests root. No process-memory access was run. | static archive, local browser, fake-Frame |
|
||||
| 121: DeckPass 1.1.1 | 1 / 2 | partial | Initializer succeeds; KeePass Flatpak discovery and secrets integration were not exercised. | static archive, local browser, fake-Frame |
|
||||
| 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 | Initializer succeeds; timers, reminders, suspend inhibition and audio are unverified. | static archive, local browser, fake-Frame |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 133: Non-Steam Badges 0.1.0 | 1 / 2 | partial | Initializer succeeds; Non-Steam game badges have no supported Steam page injection target. | static archive, local browser, fake-Frame |
|
||||
| 134: Deck Progress Tracker 1.3.6 | 1 / 2 | partial | Initializer succeeds; Steam game/library statistics and page patches unverified. | static archive, local browser, fake-Frame |
|
||||
| 135: Deck Shelves 2.0.0 | 1 / unknown | broken | Frontend audit failed: Failed to resolve module specifier "./chunks/pluginApi-B8sV7970.js". Invalid relative url or base scheme isn't hierarchical.. | static archive, local browser, fake-Frame |
|
||||
| 136: Decky-Clipper 1.0.2 | 1 / 2 | broken | Plugin contains a non-ARM64 Linux binary: Decky-Clipper/bin/gstreamer-1.0/libgstproxy.so. | static archive, local browser, fake-Frame |
|
||||
| 137: Decky Proton Launch 0.9.0 | 1 / 2 | partial | Initializer succeeds; Proton wrapper installation and launch changes were not run. | static archive, local browser, fake-Frame |
|
||||
| 138: Docky 1.4.13 | legacy / legacy | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 139: Output Manager 1.4.0 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 140: IPv6 Enabler Plugin 0.0.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 141: DeckyClocker 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 |
|
||||
| 142: MuteOnSleep 1.0.1 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
| 143: Desktop Apps 0.1.0 | 1 / 2 | partial | Initializer succeeds; Flatpak app discovery and Steam shortcut changes unverified. | static archive, local browser, fake-Frame |
|
||||
| 144: Mixer 0.1.0 | 1 / 2 | partial | Local Chromium numeric stream volume and filter save/reopen pass through the actual Decky 3.3.0 Linux backend; filters survive its restart. Frame pw-dump/wpctl, real audio session and worn VR remain unverified. | static archive, local browser, fake-Frame, isolated Linux backend |
|
||||
| 145: VARTA 1.0.21 | 1 / 2 | broken | This plugin needs root and is not supported by the Frame user service. | static archive, local browser, fake-Frame |
|
||||
|
||||
## API, UI and backend inventory
|
||||
|
||||
DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stores and router/toaster/QAM signals are listed separately. No signal means the static scan did not detect one, not that no dependency exists. Native binaries are classified by ELF machine or Windows MZ header; Python files and source links are preserved in the JSON.
|
||||
|
||||
| Plugin | SteamClient methods | DFL / @decky/ui exports | Other Steam globals / UI surfaces | Backend | Hardware / OS signals |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| XR Gaming | none detected | ButtonItem, ConfirmModal, DialogButton, DialogButtonPrimary, DialogButtonSecondary, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, Spinner, TextField, ToggleField, definePlugin, gamepadDialogClasses, showModal, staticClasses | window.SP_REACT | Python | Requires supported external XR glasses and the Breezy/XRLinuxDriver stack; it does not target Steam Frame displays. |
|
||||
| CSS Loader | none detected | ButtonItem, ColorPickerModal, ConfirmModal, DialogButton, DialogCheckbox, Dropdown, DropdownItem, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SidebarNavigation, SliderField, Tabs, TextField, Toggle, ToggleField, classMap, definePlugin, findModuleChild, gamepadDialogClasses, gamepadSliderClasses, getGamepadNavigationTrees, replacePatch, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| vibrantDeck | none detected | PanelSection, PanelSectionRow, Router, SliderField, ToggleField, definePlugin, staticClasses | Router, window.SP_REACT | Python | Changes X11 gamescope color properties, not the Frame SteamVR compositor. |
|
||||
| Fantastic | none detected | Field, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT, QAM/global tabs | Python + native payload; x86_64 | Targets Steam Deck fan hardware; no Frame fan-control mapping is provided. |
|
||||
| MusicControl | none detected | ButtonItem, DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Pause Games | Apps, Apps.RegisterForGameActionTaskChange, GameSessions.RegisterForAppLifetimeNotifications, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, System.UI.RegisterForFocusChangeEvents, System.UI.RegisterForSystemKeyEvents | Button, Field, Focusable, Marquee, PanelSection, PanelSectionRow, Router, Toggle, ToggleField, beforePatch, sleep | Router, window.SP_REACT, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| ProtonDB Badges | none detected | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, Navigation, PanelSection, PanelSectionRow, ScrollPanel, ScrollPanelGroup, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, definePlugin, findInReactTree, findModuleExport, showModal, staticClasses, useParams | SteamUIStore, appStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Audio Loader | GameSessions.RegisterForAppLifetimeNotifications, d.ts | ButtonItem, DialogButton, Dropdown, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, afterPatch, beforePatch, definePlugin, findModuleChild, gamepadDialogClasses, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Bluetooth | none detected | DialogButton, Field, Focusable, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | none detected | Python | /sys/class/power_supply |
|
||||
| Notebook | System.UI.RegisterForFocusChangeEvents | DialogButton, Dropdown, Marquee, PanelSection, PanelSectionRow, Router, definePlugin, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| PowerTools | Apps.RegisterForGameActionEnd, Apps.RegisterForGameActionStart, GameSessions.RegisterForAppLifetimeNotifications, User.GetLoginUsers, User.RegisterForCurrentUserChanges | ButtonItem, DialogButton, Dropdown, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, QuickAccessTab, Router, SliderField, Spinner, TextField, ToggleField, definePlugin, showModal, staticClasses | Router, appStore, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam stores | Python + native payload; x86_64 | Targets AMD Ryzen power controls; Frame uses an ARM64 SoC. |
|
||||
| Memory Deck | none detected | ButtonItem, DialogButton, DropdownItem, Focusable, PanelSection, PanelSectionRow, Router, SteamSpinner, definePlugin, gamepadDialogClasses, joinClassNames, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python + native payload; x86_64 | gamescope |
|
||||
| Animation Changer | none detected | ButtonItem, DialogButton, Dropdown, DropdownItem, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, Spinner, Tabs, TextField, ToggleField, definePlugin, findModule, findSP, showModal, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| RadiYo! | none detected | ConfirmModal, DialogButton, Dropdown, Focusable, ModalRoot, Navigation, PanelSection, QuickAccessTab, Router, SliderField, Spinner, TextField, definePlugin, showModal, staticClasses | Router, window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| HLTB for Deck | none detected | ButtonItem, DialogButtonPrimary, DropdownItem, MenuItem, Navigation, PanelSection, PanelSectionRow, SliderField, SteamSpinner, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, definePlugin, fakeRenderComponent, findInReactTree, findInTree, findModuleChild, removeHookStubs, staticClasses | routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| AutoSuspend | Downloads.RegisterForDownloadItems, Input.RegisterForControllerInputMessages, Settings.RegisterForSettingsChanges, System, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, System.ShutdownPC, URL.ExecuteSteamURL, User.GetLoginUsers, User.RegisterForShutdownDone | ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DropdownItem, Field, Focusable, Navigation, SidebarNavigation, SliderField, TextField, ToggleField, definePlugin, findModuleChild, showModal, sleep, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Emuchievements | Apps.RegisterForAppDetails, GameSessions.RegisterForAppLifetimeNotifications, User.RegisterForLoginStateChange | ButtonItem, Dropdown, Field, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarItem, ProgressBarWithInfo, Router, SidebarNavigation, TextField, ToggleField, afterPatch, beforePatch, callOriginal, definePlugin, findInTree, findModuleChild, replacePatch, sleep, staticClasses | Router, appDetailsStore, appStore, collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| TunnelDeck | none detected | ButtonItem, Field, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | systemctl |
|
||||
| AutoFlatpaks | InstallFolder.GetInstallFolders | ConfirmModal, DialogButton, Dropdown, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, ScrollPanel, SteamSpinner, Tabs, TextField, Toggle, ToggleField, definePlugin, findModuleChild, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | flatpak; x86_64 |
|
||||
| MoonDeck | Apps.AddShortcut, Apps.CancelGameAction, Apps.GetResolutionOverrideForApp, Apps.RegisterForAppDetails, Apps.RegisterForGameActionEnd, Apps.RegisterForGameActionStart, Apps.RegisterForGameActionTaskChange, Apps.RemoveShortcut, Apps.RunGame, Apps.SetAppLaunchOptions, Apps.SetAppResolutionOverride, Apps.SetShortcutName, Apps.SetThirdPartyControllerConfiguration, Apps.TerminateApp, Apps.ToggleOverrideResolutionForInternalDisplay, GameSessions.RegisterForAppLifetimeNotifications, System.Audio.GetDevices, System.DisplayManager.RegisterForStateChanges, User.RegisterForLoginStateChange, User.StartRestart | Button, ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogButtonPrimary, DialogButtonSecondary, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, Menu, MenuItem, ModalRoot, NavEntryPositionPreferences, Navigation, PanelSection, PanelSectionRow, SidebarNavigation, Spinner, TextField, ToggleField, afterPatch, appDetailsClasses, appDetailsHeaderClasses, basicAppDetailsSectionStylerClasses, createReactTreePatcher, findInReactTree, findModuleExport, findSP, joinClassNames, playSectionClasses, showContextMenu, showModal, sleep, staticClasses | appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | /dev/input; /sys/socket.h; amd64; flatpak; x86_64 |
|
||||
| SteamGridDB | Apps.ClearCustomArtworkForApp, Apps.RegisterForAppDetails, Apps.SetCustomArtworkForApp, Apps.SetShortcutName, User.StartRestart | ConfirmModal, ControlsList, DialogBody, DialogBodyText, DialogButton, DialogButtonPrimary, DialogCheckbox, DialogControlsSection, DialogControlsSectionHeader, DialogFooter, DialogHeader, Dropdown, Field, Focusable, IconsModule, Marquee, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBar, ReorderableList, SliderField, Spinner, Tabs, TextField, ToggleField, afterPatch, appDetailsHeaderClasses, callOriginal, createModuleMapping, definePlugin, fakeRenderComponent, findClassModule, findInReactTree, findInTree, findModule, findModuleByExport, findModuleExport, findSP, joinClassNames, quickAccessMenuClasses, replacePatch, showContextMenu, showModal, useParams, wrapReactType | appDetailsStore, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Discord Status | Apps.RegisterForAppDetails, Apps.RunGame, GameSessions.RegisterForAppLifetimeNotifications, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | ButtonItem, DropdownItem, Field, PanelSection, PanelSectionRow, Router, Spinner, sleep, staticClasses | Router, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Volume Mixer | none detected | DialogButton, Focusable, PanelSection, PanelSectionRow, SliderField, definePlugin, staticClasses | window.SP_REACT | Python | PulseAudio/PipeWire, libpulse/libdbus and D-Bus; optional Bluetooth playback |
|
||||
| Controller Tools | none detected | ButtonItem, PanelSection, PanelSectionRow, ToggleField, definePlugin, gamepadDialogClasses, joinClassNames, staticClasses | toaster, window.SP_REACT, toasts | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Tailscale Control | none detected | ButtonItem, ConfirmModal, DropdownItem, Field, Focusable, PanelSection, PanelSectionRow, SuspensefulImage, TextField, ToggleField, definePlugin, showModal, staticClasses | window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| DeckMTP | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Steamback | GameSessions.RegisterForAppLifetimeNotifications, InstallFolder.GetInstallFolders | ButtonItem, ConfirmModal, Navigation, PanelSection, PanelSectionRow, Router, definePlugin, showModal, staticClasses | Router, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Quick Launch | Apps.AddShortcut, Apps.RunGame, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Apps.SpecifyCompatTool | ButtonItem, ConfirmModal, DialogButton, Dropdown, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, ToggleField, definePlugin, showModal, staticClasses | appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | flatpak |
|
||||
| Decky Cloud Save | GameSessions.RegisterForAppLifetimeNotifications, Settings, Settings.GetCurrentLanguage | ButtonItem, ConfirmModal, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField, definePlugin, showContextMenu, showModal, sleep, staticClasses | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Storage Cleaner | Apps.RegisterForAppDetails | ButtonItem, ConfirmModal, DialogCheckbox, PanelSection, PanelSectionRow, definePlugin, showModal, staticClasses | window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Game Theme Music | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SliderField, SteamSpinner, Tabs, TextField, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, fakeRenderComponent, findInReactTree, findInTree, findModuleChild, showContextMenu, showModal, staticClasses, useParams | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Shotty | GameSessions.RegisterForScreenshotNotification | ButtonItem, PanelSection, PanelSectionRow, Router, definePlugin, staticClasses | Router, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | gamescope |
|
||||
| MangoPeel | none detected | ButtonItem, ConfirmModal, DropdownItem, Field, Focusable, Marquee, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, definePlugin, findModuleChild, showModal, sleep, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Volume Boost | none detected | PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | PulseAudio/PipeWire and pactl; hard-coded /run/user/1000 |
|
||||
| Web Browser | GameSessions.RegisterForAppLifetimeNotifications, Input.ControllerKeyboardSetKeyState, Overlay.RegisterForOverlayActivated, System, System.RegisterForOnResumeFromSuspend | ButtonItem, ConfirmModal, DialogBodyText, DialogButton, DialogButtonSecondary, DialogSubHeader, DropdownItem, Field, Focusable, GamepadButton, Marquee, Menu, MenuGroup, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SimpleModal, SteamSpinner, Tabs, TextField, ToggleField, afterPatch, definePlugin, findClassModule, findInReactTree, findModuleChild, gamepadContextMenuClasses, gamepadDialogClasses, gamepadTabbedPageClasses, getFocusNavController, getReactRoot, quickAccessControlsClasses, replacePatch, showContextMenu, showModal, sleep, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| DeckyFileServer | none detected | ButtonItem, DialogBody, DialogButton, DialogFooter, DialogHeader, DialogSubHeader, DropdownItem, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, Toggle, ToggleField, definePlugin, showModal, staticClasses | window.SP_REACT | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| TabMaster | Apps.GetStoreTagLocalization, GameSessions.RegisterForAppLifetimeNotifications, InstallFolder.RegisterForInstallFolderChanges, System, System.RegisterForOnResumeFromSuspend, System.RestartPC, User.RegisterForLoginStateChange | Button, ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogCheckbox, DialogControlsSection, DialogHeader, Dropdown, DropdownItem, Field, Focusable, GamepadButton, Marquee, Menu, MenuGroup, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, ReorderableList, ScrollPanelGroup, SidebarNavigation, SliderField, TextField, ToggleField, achievementClasses, afterPatch, findInReactTree, findInTree, findModuleExport, gamepadContextMenuClasses, gamepadDialogClasses, gamepadSliderClasses, gamepadTabbedPageClasses, joinClassNames, mainMenuAppRunningClasses, quickAccessControlsClasses, quickAccessMenuClasses, replacePatch, scrollPanelClasses, showContextMenu, showModal, sleep, wrapReactType | DeckyPluginLoader, appStore, collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| TS3 QuickAccess | none detected | DialogBody, DialogButton, DialogFooter, DialogHeader, Dropdown, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, SliderField, TextField, definePlugin, showModal | none detected | Python + native payload; x86_64 | flatpak |
|
||||
| Free Loader | none detected | ButtonItem, ConfirmModal, DialogButton, Focusable, Navigation, PanelSection, PanelSectionRow, QuickAccessTab, SliderField, SteamSpinner, ToggleField, showModal | DeckyPluginLoader, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Syncthing | none detected | DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBarItem, QuickAccessTab, Router, SidebarNavigation, Spinner, SteamSpinner, TextField, Toggle, definePlugin, showModal, sleep, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python + native payload; x86_64 | flatpak; gamescope |
|
||||
| Decky Terminal | none detected | DialogButton, Dropdown, Field, Focusable, GamepadButton, PanelSection, Router, SidebarNavigation, SteamSpinner, TextField, ToggleField, definePlugin, staticClasses, useParams | Router, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| PlayTime | Apps.RegisterForAppDetails, Input.RegisterForControllerInputMessages, Storage.GetJSON, Storage.SetObject, System, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, User, User.RegisterForLoginStateChange, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | ButtonItem, ConfirmModal, DialogButton, Dropdown, Field, Focusable, Menu, MenuGroup, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SidebarNavigation, Tabs, TextField, afterPatch, definePlugin, findClass, findSP, showContextMenu, showModal, staticClasses, useParams | SteamUIStore, appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Battery Tracker | none detected | PanelSection, PanelSectionRow, Router, SliderField, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | /sys/class/power_supply/BAT1/capacity; /sys/class/power_supply/BAT1/current_now; /sys/class/power_supply/BAT1/status; /sys/class/power_supply/BAT1/voltage_now |
|
||||
| Wine Cellar | Apps.GetAvailableCompatTools, Apps.SpecifyCompatTool, InstallFolder.GetInstallFolders, Settings.GetGlobalCompatTools, User.StartRestart | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Dropdown, Field, Focusable, GamepadButton, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBar, Router, ScrollPanelGroup, SidebarNavigation, TextField, definePlugin, showContextMenu, showModal, staticClasses | Router, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| MicroSDeck | none detected | ConfirmModal, DialogButton, DialogCheckbox, Field, Focusable, Menu, MenuItem, ModalPosition, Navigation, PanelSection, ReorderableList, ScrollPanelGroup, SideMenu, SidebarNavigation, SimpleModal, TextField, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, definePlugin, findInReactTree, findModule, findModuleChild, gamepadDialogClasses, quickAccessControlsClasses, showContextMenu, showModal, staticClasses | collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| ScreenshotUploader | GameSessions.RegisterForScreenshotNotification, Screenshots.UploadLocalScreenshot | DropdownItem, Field, 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. |
|
||||
| EmuDecky | none detected | ButtonItem, DialogButton, Dropdown, Menu, MenuItem, PanelSection, PanelSectionRow, Router, SteamSpinner, ToggleField, definePlugin, showContextMenu, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| KDE Connect | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Reshadeck | Input.RegisterForControllerStateChanges, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest | ButtonItem, Dropdown, PanelSection, PanelSectionRow, definePlugin, staticClasses | toaster, window.SP_REACT, toasts | Python | gamescope |
|
||||
| CheatDeck | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions | DialogBodyText, DialogButton, DialogHeader, Dropdown, Field, Focusable, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, SidebarNavigation, TextField, ToggleField, afterPatch, definePlugin, fakeRenderComponent, findInReactTree, findInTree, findModuleByExport, showModal, staticClasses, useParams | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| BT Wake Control | none detected | ButtonItem, IconsModule, PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | toaster, window.SP_REACT, toasts | Python | /sys/devices/virtual/dmi/id/board_name; /sys/devices/virtual/dmi/id/board_vendor; bluetoothctl |
|
||||
| Brightness Bar | System.Display.RegisterForBrightnessChanges | ButtonItem, ColorPickerModal, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup | Python | Targets Steam/QAM brightness shortcuts and a global Steam overlay; neither is attached to the Framey panel. |
|
||||
| Decky Notifications | none detected | ButtonItem, Menu, MenuItem, PanelSection, PanelSectionRow, definePlugin, showContextMenu, staticClasses | DeckyPluginLoader, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| MagicBlack | System.Audio.GetDevices, System.Audio.SetDeviceVolume | DialogButton, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, findModuleChild, findModuleExport, gamepadDialogClasses, joinClassNames, quickAccessMenuClasses, showModal, staticClasses | SteamUIStore, routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | Targets a global Steam Deck screen-black overlay; the isolated panel cannot black out the Frame compositor. |
|
||||
| IsThereAnyDeal for Deck | User.GetIPCountry | DialogButton, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, ToggleField, definePlugin, findModuleChild, showModal, staticClasses | routerHook, window.SP_REACT, local/Steam routes, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| MagicPods | Input.RegisterForControllerStateChanges, System.Audio.GetDevices, System.Audio.RegisterForDeviceVolumeChanged, System.Audio.SetDeviceVolume, System.Bluetooth, System.Bluetooth.RegisterForStateChanges, System.Bluetooth.SetEnabled | ButtonItem, DialogButton, Field, Focusable, Menu, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, ToggleField, afterPatch, definePlugin, findClassModule, findModuleChild, findModuleDetailsByExport, findSP, gamepadDialogClasses, gamepadSliderClasses, getGamepadNavigationTrees, joinClassNames, quickAccessMenuClasses, showContextMenu, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Junk-Store | Apps.AddShortcut, Apps.GetAvailableCompatTools, Apps.RemoveShortcut, Apps.RunGame, Apps.SetAppLaunchOptions, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Apps.SetShortcutStartDir, Apps.SpecifyCompatTool, GameSessions.RegisterForAppLifetimeNotifications, Input.RegisterForControllerInputMessages, Installs.OpenInstallWizard | Button, ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogLabel, Dropdown, Field, Focusable, GamepadButton, Marquee, Menu, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, ProgressBar, ScrollPanelGroup, SidebarNavigation, SliderField, Spinner, SteamSpinner, Tabs, TextField, ToggleField, afterPatch, definePlugin, findClassModule, gamepadDialogClasses, gamepadTabbedPageClasses, joinClassNames, quickAccessControlsClasses, quickAccessMenuClasses, scrollPanelClasses, showContextMenu, showModal, sleep, staticClasses, useParams | Router, SteamUIStore, appDetailsStore, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | sudo |
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Deck Settings | InstallFolder.GetInstallFolders, System.GetSystemInfo | ButtonItem, ConfirmModal, DialogBody, DialogButton, Dropdown, Field, Focusable, GamepadButton, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, ScrollPanelGroup, SliderField, Spinner, TextField, ToggleField, gamepadDialogClasses, scrollPanelClasses, showModal, staticClasses | Router, collectionStore, toaster, window.SP_REACT, toasts, QAM/global tabs, Steam stores | Python | /sys/class/dmi/id/sys_vendor; /sys/class/dmi/id/sys_vendor, |
|
||||
| Decky DoT DNS | none detected | ButtonItem, Dropdown, PanelSection, PanelSectionRow, TextField, ToggleField, staticClasses | window.SP_REACT | Python | systemctl |
|
||||
| Decky-SpoofDPI | User.StartRestart | ButtonItem, PanelSection, PanelSectionRow, TextField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Simple Timer | none detected | Button, ButtonItem, ConfirmModal, Focusable, PanelSection, PanelSectionRow, ToggleField, findModuleChild, showModal, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky-Framegen | none detected | ButtonItem, ConfirmModal, PanelSection, PanelSectionRow, showModal | toaster, window.SP_REACT, toasts | Python + native payload; Windows | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky-Lookup | none detected | ButtonItem, PanelSection, PanelSectionRow, Router, staticClasses | Router, window.SP_REACT | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky Zerotier | none detected | ConfirmModal, DialogBody, DialogButton, DialogSubHeader, Field, Focusable, PanelSection, PanelSectionRow, TextField, ToggleField, showModal | Router, toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Virtual Surround Sound | none detected | ConfirmModal, DialogButton, Dropdown, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, ToggleField, showModal, staticClasses | Router, window.SP_REACT, QAM/global tabs | Python | pactl |
|
||||
| XIVOmega | none detected | ButtonItem, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, Spinner, TextField, ToggleField, showContextMenu, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | /sys/net/ethertypes.h; /sys/net/if_pflog.h; /sys/net/pfvar.h; /sys/socket.h.auto.html; /sys/sys/socket.h; sudo; x86_64 |
|
||||
| DeckSP | Input.ControllerKeyboardSetKeyState, System, System.Audio.GetDevices, System.Audio.RegisterForDeviceAdded, UI.RegisterForUIModeChanged, User.RegisterForShutdownStart | ButtonItem, ConfirmModal, DialogButton, Dropdown, DropdownItem, EUIMode, Field, Focusable, GamepadButton, Marquee, Menu, MenuGroup, MenuItem, ModalPosition, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanelGroup, SimpleModal, SliderField, TextField, ToggleField, findModuleExport, gamepadContextMenuClasses, gamepadDialogClasses, quickAccessControlsClasses, quickAccessMenuClasses, scrollPanelClasses, showContextMenu, showModal, sleep | SteamUIStore, appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | flatpak; systemctl |
|
||||
| Decky Ludusavi | Apps.GetLaunchOptionsForApp, GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, DialogButton, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField, showModal | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python | flatpak |
|
||||
| Game Sync | GameSessions.RegisterForAppLifetimeNotifications, GameSessions.RegisterForScreenshotNotification, Screenshots.DeleteLocalScreenshot | ButtonItem, ConfirmModal, DialogButton, Field, Focusable, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, ReorderableList, SidebarNavigation, TextField, ToggleField, afterPatch, fakeRenderComponent, findInReactTree, findInTree, findModuleByExport, showContextMenu, showModal, sleep, useParams | appStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | amd64 |
|
||||
| WiFi Locker | none detected | ButtonItem, Field, PanelSection, PanelSectionRow, staticClasses | toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| LetMeReShade | Apps.SetAppLaunchOptions | ButtonItem, ConfirmModal, DropdownItem, PanelSection, PanelSectionRow, ToggleField, showModal | window.SP_REACT | Python | /sys/devices/virtual/dmi/id/board_name; /sys/devices/virtual/dmi/id/product_name; /sys/devices/virtual/dmi/id/sys_vendor |
|
||||
| Decky Sunshine | none detected | ButtonItem, DialogButton, DialogButtonPrimary, Field, ModalRoot, PanelSection, PanelSectionRow, Spinner, TextField, definePlugin, showModal | window.SP_REACT | Python | flatpak |
|
||||
| HueSync | Settings.GetCurrentLanguage, System.GetSystemInfo, System.RegisterForBatteryStateChanges, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest, User.RegisterForPrepareForSystemSuspendProgress, User.RegisterForResumeSuspendedGamesProgress | DialogButton, DropdownItem, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, TextField, ToggleField, definePlugin, findModuleChild, gamepadSliderClasses, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | Targets handheld RGB/LED controllers (AYANEO/Ally/MSI), not Frame hardware. |
|
||||
| MuraDeck | System.Display.RegisterForBrightnessChanges | ButtonItem, ConfirmModal, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SideMenu, SidebarNavigation, SliderField, ToggleField, quickAccessControlsClasses, showModal | SteamUIStore, routerHook, window.SP_REACT, local/Steam routes, Steam stores | Python | Calibration is specifically for defective Samsung Steam Deck OLED panels, not Frame displays. |
|
||||
| Dimmer Deck | Settings.GetCurrentLanguage, System.Display.RegisterForBrightnessChanges, System.Display.SetBrightness | PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts, Steam stores | Python | Changes gamescope/X11 dimming properties; no mapping to Frame VR display brightness exists. |
|
||||
| Music Control | none detected | ButtonItem, DialogButton, Focusable, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, SteamSpinner, definePlugin, staticClasses | Router, routerHook, window.SP_REACT, local/Steam routes | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Better Keyboard | Settings.GetCurrentLanguage, UI.GetUIMode, UI.RegisterForUIModeChanged | ButtonItem, EUIMode, PanelSection, PanelSectionRow, ToggleField, staticClasses | SteamUIStore, window.SP_REACT, Steam stores | Python | /dev/input |
|
||||
| Decky LSFG-VK | none detected | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, DialogHeader, Dropdown, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, SliderField, Spinner, TextField, Toggle, ToggleField, showModal, staticClasses | Router, toaster, window.SP_REACT, toasts | Python | flatpak; x86_64 |
|
||||
| Network Sentinel | none detected | ButtonItem, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, staticClasses | toaster, window.SP_REACT, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky Clash | Settings.GetCurrentLanguage | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Dropdown, DropdownItem, Field, Focusable, GamepadButton, Menu, MenuItem, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, Spinner, TextField, ToggleField, showContextMenu, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Muon | System, System.RegisterForOnResumeFromSuspend, System.RegisterForOnSuspendRequest | ButtonItem, DialogButton, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, Toggle, findModuleExport, showModal, staticClasses | toaster, window.SP_REACT, toasts, Steam page patches/module lookup | Python | sudo; systemctl |
|
||||
| DeckyParty File Manager | none detected | PanelSection, PanelSectionRow, ToggleField, definePlugin, staticClasses | window.SP_REACT | Python | sudo |
|
||||
| Speed Test | none detected | ButtonItem, PanelSection, PanelSectionRow, staticClasses | none detected | no Python/native backend found | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky Clipboard | none detected | ButtonItem, DialogBody, DialogButton, DialogControlsSection, DialogControlsSectionHeader, Field, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, SliderField, TextField, ToggleField, staticClasses | Router, routerHook, toaster, local/Steam routes, toasts | Python + native payload; x86_64 | gamescope; sudo |
|
||||
| Reroll | Apps.RegisterForGameActionUserRequest, GameSessions.RegisterForAppLifetimeNotifications, Settings.GetCurrentLanguage | ButtonItem, DialogButton, Field, Focusable, Marquee, ModalRoot, PanelSection, PanelSectionRow, SliderField, libraryAssetImageClasses, mainMenuAppRunningClasses, quickAccessMenuClasses, showModal | SteamUIStore, QAM/global tabs, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| DeckPass | Apps.AddShortcut, Apps.RunGame, Apps.SetCustomArtworkForApp, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Input.ControllerKeyboardSendText | Button, ButtonItem, DialogButton, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, Router, TextField, findSP, showModal, sleep, staticClasses | Router, appDetailsStore, appStore, toaster, window.SP_REACT, toasts, Steam stores | Python | flatpak |
|
||||
| Steam Status | none detected | ButtonItem, ConfirmModal, DialogButton, Field, Focusable, Navigation, PanelSection, PanelSectionRow, SliderField, ToggleField, definePlugin, showModal, staticClasses | toaster, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| AlarMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, Dropdown, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBarWithInfo, SidebarNavigation, SliderField, TextField, ToggleField, findModuleChild, showModal, staticClasses | routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky Localsend | none detected | ButtonItem, DialogBody, DialogButton, DialogButtonPrimary, DialogFooter, DialogHeader, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SidebarNavigation, TextField, ToggleField, showModal, staticClasses | Router, routerHook, toaster, local/Steam routes, toasts, Steam stores | Python + native payload; x86_64 | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Launch Options | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions | ButtonItem, ConfirmModal, DialogBody, DialogButton, DialogButtonPrimary, DialogControlsSection, DialogControlsSectionHeader, DialogFooter, DialogHeader, Dropdown, Field, Focusable, MenuItem, ModalRoot, Navigation, PanelSection, PanelSectionRow, ScrollPanel, SidebarNavigation, SteamSpinner, Tabs, TextField, Toggle, ToggleField, afterPatch, createReactTreePatcher, fakeRenderComponent, findInReactTree, findInTree, findModule, findModuleByExport, showModal, staticClasses, useParams | SteamUIStore, routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | Python + native payload; x86_64 | flatpak; gamescope |
|
||||
| Decky Translator | User.RegisterForPrepareForSystemSuspendProgress | ButtonItem, DialogButton, Dropdown, DropdownItem, Field, Focusable, ModalRoot, Navigation, PanelSection, PanelSectionRow, Router, SliderField, Tabs, TextField, ToggleField, findModuleChild, showModal, staticClasses | Router, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup | Python | /dev/hidraw; /sys/class/hidraw/hidraw{i}; /sys/class/hidraw/hidraw{i}/device/uevent; x86_64 |
|
||||
| Quick User Switcher | none detected | ButtonItem, ConfirmModal, Focusable, PanelSection, PanelSectionRow, ToggleField, afterPatch, showModal, staticClasses, wrapReactType | Router, routerHook, local/Steam routes, Steam page patches/module lookup | Python | sudo |
|
||||
| ShadowDeck | none detected | ButtonItem, DialogButton, DropdownItem, Focusable, PanelSection, PanelSectionRow, TextField, Toggle, showModal, staticClasses | toaster, toasts | Python | amd64 |
|
||||
| Games Prefix Manager | none detected | ButtonItem, DialogButton, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, definePlugin, showModal, staticClasses | none detected | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Download All | Downloads, Downloads.RegisterForDownloadItems | ButtonItem, PanelSection, PanelSectionRow, SliderField | appStore, toaster, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| SuggestMe | GameSessions.RegisterForAppLifetimeNotifications | ButtonItem, ConfirmModal, DropdownItem, Focusable, Navigation, PanelSection, PanelSectionRow, ProgressBar, SidebarNavigation, SliderField, Spinner, TextField, ToggleField, showModal, staticClasses | appStore, collectionStore, routerHook, toaster, window.SP_REACT, local/Steam routes, toasts, QAM/global tabs, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Non-Steam Badges | none detected | DialogButton, DropdownItem, Navigation, PanelSection, PanelSectionRow, ToggleField, afterPatch, appDetailsClasses, createReactTreePatcher, findInReactTree, getGamepadNavigationTrees | collectionStore, routerHook, local/Steam routes, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Deck Progress Tracker | Apps.GetAllApps, Apps.GetMyAchievementsForApp | ButtonItem, DialogButton, Focusable, Navigation, PanelSection, PanelSectionRow, afterPatch, appDetailsClasses, appDetailsHeaderClasses, createReactTreePatcher, findInReactTree, staticClasses | appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Deck Shelves | Apps.RegisterForAppDetails, System.GetOSVersion, System.GetSystemInfo | showContextMenu | SteamUIStore, appDetailsStore, appStore, collectionStore, routerHook, toaster, local/Steam routes, toasts, QAM/global tabs, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Decky-Clipper | none detected | ButtonItem, ModalRoot, Navigation, PanelSection, Router, ToggleField, showModal, staticClasses | Router | Python + native payload; x86_64 | gamescope |
|
||||
| Decky Proton Launch | Apps.RegisterForAppDetails, Apps.SetAppLaunchOptions, User.StartRestart | ConfirmModal, DialogButton, DropdownItem, Focusable, GamepadButton, ModalRoot, PanelSection, PanelSectionRow, TextField, ToggleField, showModal, staticClasses | toaster, toasts, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| Docky | none detected | ButtonItem, ConfirmModal, DialogButton, DropdownItem, Field, Focusable, ModalRoot, PanelSection, PanelSectionRow, TextField, ToggleField, definePlugin, showModal | toaster, window.SP_REACT, toasts | Python | /sys/bus/usb/devices/; /sys/bus/usb/devices/*; /sys/bus/usb/drivers/usb/; /sys/bus/usb/drivers/usb/%s; /sys/class/drm/*/status; /sys/class/drm/card*-*/; /sys/class/hwmon/hwmon*; /sys/class/hwmon/hwmonN; /sys/class/power_supply/*/online; /sys/class/thermal; /sys/class/thermal/thermal_zone*/temp; amdgpu; flatpak; gamescope; hwmon; pactl; sudo; systemctl |
|
||||
| Output Manager | none detected | ButtonItem, Navigation, PanelSection, PanelSectionRow, TextField, ToggleField, staticClasses | none detected | Python | /sys/class/drm; /sys/class/drm/card{card_number}/device; /sys/kernel/debug/dri; pactl |
|
||||
| IPv6 Enabler Plugin | none detected | DropdownItem, Field, PanelSection, PanelSectionRow, staticClasses | toaster, toasts | Python | /sys/net/ipv6/conf |
|
||||
| DeckyClocker | none detected | ButtonItem, Field, PanelSection, PanelSectionRow, SliderField | toaster, toasts | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
| MuteOnSleep | none detected | PanelSection, PanelSectionRow, ToggleField | Steam stores | Python | pactl; sudo |
|
||||
| Desktop Apps | Apps.AddShortcut, Apps.RemoveShortcut, Apps.RunGame, Apps.SetShortcutExe, Apps.SetShortcutLaunchOptions, Apps.SetShortcutName, Apps.SetShortcutStartDir, GameSessions.RegisterForAppLifetimeNotifications | DialogButton, Focusable, Menu, MenuItem, PanelSection, PanelSectionRow, showContextMenu, staticClasses | appStore, toaster, toasts, Steam stores | Python | flatpak |
|
||||
| Mixer | none detected | ButtonItem, ConfirmModal, DialogButton, Focusable, PanelSection, PanelSectionRow, SliderField, TextField, showModal, staticClasses | toaster, toasts | Python | PipeWire, pw-dump and wpctl |
|
||||
| VARTA | Store.SetWishlist, StoreItems.SetWishlist, User, User.GetSteamID | ButtonItem, DropdownItem, Field, PanelSection, PanelSectionRow, Spinner, ToggleField, afterPatch, appDetailsClasses, beforePatch, createReactTreePatcher, findInReactTree, findModule, findModuleByExport, findModuleExport, staticClasses, useParams | routerHook, toaster, local/Steam routes, toasts, Steam page patches/module lookup, Steam stores | Python | No specific hardware requirement detected statically; runtime requirements unverified. |
|
||||
|
||||
## 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.
|
||||
|
||||
[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.
|
||||
|
||||
| Plugin | Native payloads | ARM64 result / remaining blocker |
|
||||
| --- | --- | --- |
|
||||
| Fantastic | bin/backend | Fan backend/driver is Deck-specific; an ARM64 rebuild would not provide compatible Frame hardware. |
|
||||
| PowerTools | bin/libryzenadj.so, bin/ryzenadj, bin/backend | RyzenAdj and AMD power backend cannot control an ARM64 Frame SoC. |
|
||||
| Memory Deck | bin/libscanmem.so.1.0.0 | libscanmem must be rebuilt for aarch64 and validated against target process architecture; memory writes were not tested. |
|
||||
| Emuchievements | bin/hash | The bin/hash helper needs an ARM64 build and cross-platform hashing tests; no ARM64 artifact is published. |
|
||||
| MoonDeck | python/externals/yarl/_quoting_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_parser.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/reader_c.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_websocket/mask.cpython-313-x86_64-linux-gnu.so, python/externals/aiohttp/_http_writer.cpython-313-x86_64-linux-gnu.so, … 26 total; full list in JSON | Rebuild vendored CPython 3.13 extension wheels for the headset Python ABI and ARM64; buddy/helper dependencies also need ports. |
|
||||
| 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 | rclone needs an ARM64 artifact; this ZIP also fails safe member validation. Cloud writes were not exercised. |
|
||||
| DeckyFileServer | bin/backend | Native file-server backend needs ARM64 dependencies; root requirement also blocks the user service. |
|
||||
| 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. |
|
||||
| MicroSDeck | bin/backend | Native backend needs an ARM64 build and block-device/mount permission audit; no storage actions were run. |
|
||||
| EmuDecky | bin/hello | Published bin/hello is x86_64 even if unused; upstream must remove it or publish an ARM64 archive, plus remove root dependency. |
|
||||
| MagicPods | bin/magicpodscore | magicpodscore requires an upstream ARM64 artifact/source and protocol tests; frontend uses a direct websocket. |
|
||||
| Decky-Undervolt | bin/libryzenadj.so, bin/ryzenadj | RyzenAdj is AMD-specific and cannot be ported to control the Frame SoC. |
|
||||
| Decky-SpoofDPI | bin/spoofdpi | SpoofDPI is a separate upstream helper; needs an ARM64 artifact/protocol and network-permission validation. |
|
||||
| Decky-Framegen | bin/nvngx.dll, bin/dlssg_to_fsr3_amd_is_better.dll, bin/nvapi64.dll | Windows DLL payload targets x86 game translation rather than a native ARM backend; archive is 75,140,408 bytes, beyond the 50 MiB installer limit. |
|
||||
| HueSync | bin/hello, py_modules/portio.so | portio and handheld LED backends depend on x86 I/O/unsupported hardware; ARM64 compilation alone is insufficient. |
|
||||
| Decky Clipboard | bin/xclip | xclip needs ARM64 X11 libraries/server; root and gamescope assumptions also block this archive. |
|
||||
| Reroll | bin/memscan.so | memscan.so needs ARM64 process-memory support and ABI testing; root requirement remains. Frontend initializes but no memory access was tested. |
|
||||
| Decky Localsend | bin/localsend-core | MIT LocalSend-Go 66edc509a28b9f877e7374504892dcbc653a4da4 reproducibly cross-built with Go 1.25.7/CGO_ENABLED=0 for ARM64; SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Help/official backend CLI flags and protocol 2.0 info endpoint passed on real Frame in an isolated network namespace. No file transfer or full plugin/CEF integration tested; exact source match to shipped helper unproven. Official ZIP stays x86_64 and blocked. |
|
||||
| Launch Options | bin/hello | Remove the template x86 bin/hello or publish an ARM64 rebuild; missing Steam module mapping also prevents frontend initialization. |
|
||||
| Decky-Clipper | bin/gstreamer-1.0/libgstproxy.so, bin/gstreamer-1.0/libgstmidi.so, bin/gstreamer-1.0/libgstmpegtsmux.so, bin/gstreamer-1.0/gst-plugin-scanner, bin/gstreamer-1.0/libgstivfparse.so, … 115 total; full list in JSON | Rebuild all bundled GStreamer libraries/plugins for ARM64 and verify capture/codec ABI against Frame SteamVR; gamescope capture differs. |
|
||||
|
||||
## Evidence and maintenance
|
||||
|
||||
The canonical JSON contains full archive hashes, native paths, source URLs, frontend observations and fake-Frame outcomes. Store compatibility is advisory except known installation/hardware blockers; removal always remains available. A panel retains its compatibility reason after the initializer reports ready. Upstream fixes require a new digest-scoped audit. Do not mark a plugin works based on archive installation, fake backend replies or initializer completion.
|
||||
+46
-2
@@ -6,6 +6,8 @@ user services under `~/.local/share/frame-control`; neither needs root or a
|
||||
read-write SteamOS root filesystem. Decky's standard Quick Access menu still
|
||||
does not appear in the separate SteamVR CEF target.
|
||||
|
||||
See the [complete official-store compatibility table](framey-compatibility.md) for version-specific statuses, blockers and verification limits. Both stores and installed panels show the same digest-scoped results; a new published archive starts unverified.
|
||||
|
||||
## Use in the headset
|
||||
|
||||
Install **Frame Control in VR** and **Decky Loader on Frame** from Frame Control
|
||||
@@ -49,6 +51,25 @@ restores the cache when a panel reopens if the parent's browser storage is
|
||||
available. If storage is denied or cleared, the cache lasts only for the current
|
||||
panel. Use the plugin backend for settings that must survive cache loss.
|
||||
|
||||
Decky API v1/v2 `addEventListener` and `removeEventListener` receive real backend
|
||||
events while the panel is open. The parent resolves the plugin name from its
|
||||
installed store receipt, polls Steam's existing Decky event connection, and sends
|
||||
only subscribed events to that panel session. A subscription is established
|
||||
before a backend call can emit its response. The bridge allows 32 event names,
|
||||
queues at most 64 events and 48 KiB, and drops any event whose arguments exceed
|
||||
8 KiB. Polling occurs about once a second, so this is unsuitable for high-rate
|
||||
input/audio streams. Closing/reopening a panel removes its subscriptions;
|
||||
abandoned CEF subscriptions expire after 15 seconds. This does not deliver
|
||||
global notifications while a Framey panel is closed.
|
||||
|
||||
The [26 October live checks](evidence/framey-events-backend-2026-10-26.json)
|
||||
used the actual official Notebook and Simple Timer Python backends in a private
|
||||
removable ARM64 Decky service. Notebook's default notebook saved a pointer stroke
|
||||
and loaded it after a backend restart. Simple Timer's start/cancel controls,
|
||||
countdown, completion toast and persisted recents/subtle mode worked. Simple
|
||||
Timer remains partial: its own expired-timer startup raises a missing
|
||||
`timer_task` error, sound/suspend are unsupported, and worn VR was not checked.
|
||||
|
||||
The host currently supplies sections, rows, buttons, toggles, text fields,
|
||||
sliders, grouped dropdowns,
|
||||
scroll containers, a modal, locale information, local panel routes and toasts,
|
||||
@@ -92,6 +113,28 @@ work. IndexedDB is hidden because the browser exposes it but denies access in
|
||||
an opaque iframe; localForage can then select Framey's bounded localStorage.
|
||||
Direct IndexedDB users remain unsupported.
|
||||
|
||||
`SuspensefulImage` renders a native image. SteamClient `Storage.GetJSON` and
|
||||
`SetObject` use the same bounded per-plugin cache, including panel reopen
|
||||
persistence; they do not read Steam's account/global JSON store. Missing keys
|
||||
reject with `Not found`. Detached `SteamUIStore.RunningApps` is a frozen empty
|
||||
list, and the suspend store has no methods. Optional Steam app/account globals
|
||||
are undefined. These fallbacks let settings render without inventing live
|
||||
games or account identity. [The exact-bundle follow-up](evidence/framey-ui-fallbacks-2026-10-26.json)
|
||||
verifies Pause Games and Tailscale Control settings in local Chromium; their
|
||||
primary backend actions remain unverified. PlayTime still requires Steam's
|
||||
real app store and game observers.
|
||||
|
||||
Three parameterless SteamClient reads use Steam's actual CEF context:
|
||||
`User.GetIPCountry`, `InstallFolder.GetInstallFolders` and `System.GetSystemInfo`.
|
||||
The authenticated headset-only endpoint checks that the plugin is installed,
|
||||
selects an expression from a fixed allowlist, and caps replies at 256 KiB.
|
||||
The iframe cannot supply a Steam property path or executable source, and replies
|
||||
are discarded if the panel session changed. Unsupported APIs remain unavailable.
|
||||
[Locked Frame checks](evidence/framey-steam-reads-2026-10-26.json) verified all
|
||||
three reads and IsThereAnyDeal's country dropdown in local Chromium. Its backend
|
||||
settings/deal calls stayed denied; Deck Settings still needs real Steam library
|
||||
context. A deployed headset HTTP endpoint and worn-VR behavior remain unverified.
|
||||
|
||||
The fake Frame integration test now writes a Decky setting through Framey's
|
||||
scoped backend endpoint, reads it in a new CEF request, updates the plugin,
|
||||
then checks it again. It also confirms removal blocks method calls and a
|
||||
@@ -142,13 +185,14 @@ and the fake Frame flow before asking users to install it.
|
||||
|
||||
An API v2 plugin's normal `content` is its default VR panel. Framey also shows
|
||||
an optional `settings` React element under a **Settings** tab and an optional
|
||||
`titleView` above it. For one-tap headset controls, return
|
||||
`titleView` above it. A plugin with only `settings` opens that view directly;
|
||||
Framey shows tabs only when both views exist. For one-tap headset controls, return
|
||||
`frameyQuickActions: [{label: 'Refresh data', onClick: refreshData}]` from the
|
||||
plugin initializer. Framey displays up to eight actions and reports a pending,
|
||||
success, or failure state. These descriptor fields are Framey additions; Decky
|
||||
ignores them. Use a plugin backend method through `@decky/api` for work needing
|
||||
persistent state, and keep a quick action safe to repeat.
|
||||
See the [API v2 compatibility table](frame-plugin-author.md#framey-api-v2-compatibility)
|
||||
See the [Decky API compatibility table](frame-plugin-author.md#framey-decky-api-compatibility)
|
||||
for tested behavior and remaining limits.
|
||||
|
||||
The [Frame Control extension API](extensions.md) is an alternative for a
|
||||
|
||||
@@ -16,8 +16,10 @@ pixels. Review/redact them before committing or sharing. JSON reports have
|
||||
screenshot hashes and no UI access keys.
|
||||
|
||||
The runner acquires `/tmp/frame-test.lock` by atomic `mkdir`, writes a random
|
||||
owner, then reads battery and SteamVR state. A foreign lock prevents sensor
|
||||
reads, wake, installation and input. Unknown battery or below 20% aborts.
|
||||
owner, then reads battery, proximity and SteamVR state. A foreign lock prevents
|
||||
sensor reads, wake, installation and input. Unknown battery or below 20% aborts.
|
||||
`--wake` and `--exercise` also require SteamVR activity 3 (standby); awake or
|
||||
unknown activity exits before power changes, service installation or input.
|
||||
Battery is read again before release. The default check never wakes or
|
||||
installs anything; it returns nonzero when the panel is absent/hidden.
|
||||
|
||||
@@ -58,9 +60,12 @@ CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input,
|
||||
not proof of a physical controller or the SteamVR virtual keyboard.
|
||||
|
||||
SteamVR can leave a recreated panel hidden with a stale focus request. The
|
||||
exercise retries once by removing and reapplying `STEAM_GAME` on its own test
|
||||
exercise waits briefly for the new overlay, then retries once by removing and
|
||||
reapplying `STEAM_GAME` on its own test
|
||||
browser window, then requests focus again. It never retags another application.
|
||||
The report records whether this recovery was needed. Registration, dashboard
|
||||
If the overlay stays hidden, it records a small read-only registration/service
|
||||
diagnostic and stops before synthetic input. The report records whether this
|
||||
recovery was needed. Registration, dashboard
|
||||
visibility, actual panel visibility, and pixel capture are separate checks.
|
||||
A visible docked panel can report `IsDashboardVisible=false` and
|
||||
`IsActiveDashboardOverlay=false`. A zero-sized main texture or failed image
|
||||
|
||||
+3
-2
@@ -41,8 +41,9 @@ frontend host is browser-tested, but wearer-visible VR use remains unverified.
|
||||
The panel also lists Decky plugins and can install, update, or remove compatible
|
||||
plugins from the official store when the opt-in managed loader is running.
|
||||
Each change asks for confirmation. The Frame VR shell does not show Decky's
|
||||
Steam Deck Quick Access Menu, so Decky plugin pages, settings, routes, and
|
||||
toasts are not available in this panel yet. The [compatibility table](decky-frame.md)
|
||||
Steam Deck Quick Access Menu. Framey's separate panel can show compatible
|
||||
plugin pages, settings, local routes, and local toasts, but these controls
|
||||
still need a worn-headset check. The [compatibility table](decky-frame.md)
|
||||
separates backend loading from VR use. Authors can use the
|
||||
[Frame plugin and extension guide](frame-plugin-author.md).
|
||||
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
```
|
||||
|
||||
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
|
||||
attaches everything to a **draft** release for that tag. After all eight
|
||||
attaches everything to a **draft** release for that tag. After all ten
|
||||
installers upload, the workflow also attaches `SHA256SUMS` built from
|
||||
GitHub's SHA-256 digests of those uploaded bytes. Nobody is
|
||||
offered a draft. Editing a draft while installers are uploading can make
|
||||
@@ -39,9 +39,9 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
scripts/publish-release.sh v0.4.0
|
||||
```
|
||||
|
||||
The script checks that the tag is on GitHub and that all eight installers
|
||||
The script checks that the tag is on GitHub and that all ten installers
|
||||
are attached, each with the SHA-256 digest GitHub records. It also checks
|
||||
that the uploaded `SHA256SUMS` digest matches a fresh list of those eight
|
||||
that the uploaded `SHA256SUMS` digest matches a fresh list of those ten
|
||||
installer digests. It attaches
|
||||
`update.json` (the version, the notes, the release page and each
|
||||
installer's digest), then publishes the release and marks it latest. It
|
||||
|
||||
@@ -208,6 +208,13 @@ It resolves the checkout path independently of the current working directory.
|
||||
`/path/to/checkout/scripts/frame-smoke.sh --help` is safe to run offline and
|
||||
does not contact the headset.
|
||||
|
||||
Before reading headset status or changing titles, the smoke script atomically
|
||||
acquires the shared `/tmp/frame-test.lock` with a unique owner token. It reads
|
||||
SteamVR activity, proximity and battery while locked. An awake headset, unknown
|
||||
activity, battery below 20%, or a busy lock stops the run without installing
|
||||
anything. It releases only its own lock after cleanup. The JSON result includes
|
||||
the read-only preflight for completed smoke runs.
|
||||
|
||||
It checks `properties.json` and the status, then installs, launches and
|
||||
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||
|
||||
@@ -4,7 +4,9 @@ This is a small Decky API v2 plugin for authors. Its panel uses the official
|
||||
`@decky/api`, `@decky/ui`, and `@decky/rollup` packages. A toggle calls a Python
|
||||
backend method, stores one boolean under `decky.DECKY_PLUGIN_SETTINGS_DIR`,
|
||||
and reads it back when the panel opens. The backend rejects other keys and
|
||||
values. The frontend also shows a local toast after a successful save. A
|
||||
values. The frontend also shows a local toast with a React body after a
|
||||
successful save. The toast stays inside the Framey panel; it is not a Steam
|
||||
system notification. A
|
||||
Framey settings tab exposes the same toggle, and a read-only quick action
|
||||
fetches the saved value without changing it. These two fields are Framey
|
||||
additions; Decky's own menu still uses the main panel content. The quick
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ function Content() {
|
||||
await setSetting('enabled', value);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
toaster.toast({ title: 'Framey sample', body: 'Setting saved' });
|
||||
toaster.toast({ title: 'Framey sample',
|
||||
body: SP_JSX.jsxs("span", { children: ["Setting saved: ", SP_JSX.jsx("strong", { children: value ? 'on' : 'off' })] }) });
|
||||
}
|
||||
catch (error) {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
|
||||
+1
-1
@@ -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, 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\n async function refresh() {\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 setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\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', body: 'Setting saved'});\n } catch (error) {\n setMessage(`Could not save setting: ${String(error)}`);\n } finally {\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 to setting</ButtonItem></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => {\n routerHook.addRoute('/framey-sample/details/:topic?', Details);\n return Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),\n}, {\n // Framey additions are ignored by Decky's own menu. Keep the mainLine 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, Navigation, PanelSection, PanelSectionRow, ToggleField, useParams} from '@decky/ui';\nimport {useEffect, 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\n async function refresh() {\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 setBusy(false);\n }\n }\n\n useEffect(() => {void refresh();}, []);\n\n async function change(value: boolean) {\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 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 to setting</ButtonItem></PanelSectionRow>\n </PanelSection>;\n}\n\nexport default definePlugin(() => {\n routerHook.addRoute('/framey-sample/details/:topic?', Details);\n return Object.assign({\n name: 'Framey Settings Sample',\n icon: <span aria-hidden=\"true\">⚙</span>,\n content: <Content />,\n onDismount: () => routerHook.removeRoute('/framey-sample/details/:topic?'),\n}, {\n // FrLine truncated
|
||||
@@ -31,7 +31,8 @@ function Content() {
|
||||
await setSetting('enabled', value);
|
||||
setEnabled(value);
|
||||
setMessage(`Saved setting: ${value ? 'on' : 'off'}`);
|
||||
toaster.toast({title: 'Framey sample', body: 'Setting saved'});
|
||||
toaster.toast({title: 'Framey sample',
|
||||
body: <span>Setting saved: <strong>{value ? 'on' : 'off'}</strong></span>});
|
||||
} catch (error) {
|
||||
setMessage(`Could not save setting: ${String(error)}`);
|
||||
} finally {
|
||||
|
||||
@@ -249,7 +249,7 @@ final class WebSocket {
|
||||
var payload = Data(buffer[(start + off)..<(start + off + len)])
|
||||
if masked {
|
||||
let mask = [UInt8](buffer[(start + maskOff)..<(start + maskOff + 4)])
|
||||
payload.withUnsafeMutableBytes { p in
|
||||
payload.withUnsafeMutableBytes { (p: UnsafeMutableRawBufferPointer) in
|
||||
for i in 0..<len { p[i] ^= mask[i & 3] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Builds the Mac streaming agent into mac/bin/frame-mac-view (arm64, macOS 14+).
|
||||
# Builds the Mac streaming agent into mac/bin/frame-mac-view (arm64 or x86_64, macOS 14+).
|
||||
# Frame Control runs it for "Mac in the headset"; the Electron app bundles it.
|
||||
set -eu
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
out=${1:-$here/../bin/frame-mac-view}
|
||||
arch=${FRAME_MAC_ARCH:-$(uname -m)}
|
||||
case "$arch" in arm64|x86_64) ;; *) echo "unsupported Mac architecture: $arch" >&2; exit 1 ;; esac
|
||||
mkdir -p "$(dirname "$out")"
|
||||
xcrun swiftc -O -swift-version 5 -target arm64-apple-macos14.0 \
|
||||
xcrun swiftc -O -swift-version 5 -target "$arch-apple-macos14.0" \
|
||||
-import-objc-header "$here/Sources/CGVirtualDisplay.h" \
|
||||
-o "$out" "$here"/Sources/*.swift
|
||||
echo "built $out"
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve exact official frontends to a local Chromium audit, with all actions denied.
|
||||
|
||||
Open the printed loopback URL with T3's collaborative browser. Run `runAudit()`
|
||||
in the page, then inspect `results` or download its JSON. This is initialization
|
||||
evidence only. No official Python/native backend is imported or executed.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT/'ui'))
|
||||
import framey_panel
|
||||
|
||||
PRELUDE = '''<script>
|
||||
const report=(kind,message)=>parent.postMessage({type:'audit:log',kind,message:String(message)},'*');
|
||||
addEventListener('error',event=>report('error',event.message));
|
||||
addEventListener('unhandledrejection',event=>report('rejection',event.reason?.stack||event.reason));
|
||||
for(const key of ['error','warn']){const original=console[key];console[key]=(...args)=>{
|
||||
report(key,args.map(String).join(' '));original(...args);};}
|
||||
window.fetch=()=>Promise.reject(Error('Audit blocks plugin network'));
|
||||
window.WebSocket=class{constructor(){throw Error('Audit blocks WebSocket');}};
|
||||
</script>'''
|
||||
|
||||
PAGE = '''<!doctype html><meta charset=utf-8><title>Framey catalog frontend audit</title>
|
||||
<h1>Framey catalog frontend audit</h1>
|
||||
<p>Exact archived frontend code runs in an opaque iframe. All backend calls and network requests are denied.
|
||||
Ready means initializer completion, not useful plugin behavior. No plugin backend executes.</p>
|
||||
<button id=run>Run catalog</button> <button id=download>Download observations</button><p id=progress></p>
|
||||
<iframe id=panel sandbox="allow-scripts" style="width:1100px;height:740px"></iframe>
|
||||
<script>
|
||||
const plugins=CATALOG_DATA, librarySnapshot=LIBRARY_DATA;
|
||||
window.results=[];let active=null,running=false;
|
||||
addEventListener('message',event=>{
|
||||
if(event.source!==panel.contentWindow||!active)return;
|
||||
const data=event.data;
|
||||
if(data.type==='audit:log'&&active.logs.length<8)active.logs.push({kind:data.kind,message:data.message.split('\\n')[0].slice(0,400)});
|
||||
if(data.type==='framey:error')active.error=String(data.message).slice(0,500);
|
||||
if(data.type==='framey:ready')active.ready=true;
|
||||
if(data.type==='framey:events')event.source.postMessage({type:'framey:reply',id:data.id,result:{events:[],cursor:0}},'*');
|
||||
if(data.type==='framey:steam')event.source.postMessage({type:'framey:reply',id:data.id,
|
||||
...(data.method==='GetLibrarySnapshot'&&librarySnapshot?
|
||||
{result:librarySnapshot}:{error:'Read-only compatibility audit: live Steam API blocked'})},'*');
|
||||
if(data.type==='framey:call'){
|
||||
if(!active.methods.includes(data.method))active.methods.push(data.method);
|
||||
event.source.postMessage({type:'framey:reply',id:data.id,error:'Read-only compatibility audit: backend method blocked'},'*');}
|
||||
if(data.type==='framey:cache:init')event.source.postMessage({type:'framey:cache:seed',session:data.session,entries:{}},'*');
|
||||
});
|
||||
window.runAudit=async(ids)=>{
|
||||
if(running)throw Error('Audit already running');running=true;run.disabled=true;
|
||||
try{for(const plugin of plugins.filter(plugin=>!ids||ids.includes(plugin.id))){
|
||||
active={id:plugin.id,name:plugin.name,ready:false,error:null,logs:[],methods:[],
|
||||
library:librarySnapshot?'snapshot replay':'unavailable'};
|
||||
progress.textContent=plugin.id+' '+plugin.name;panel.src='/panel/'+plugin.id;
|
||||
await new Promise(resolve=>setTimeout(resolve,1200));results.push(active);active=null;
|
||||
panel.src='about:blank';await new Promise(resolve=>setTimeout(resolve,30));
|
||||
}}finally{running=false;run.disabled=false;progress.textContent='Done: '+results.length;}
|
||||
return results;
|
||||
};
|
||||
run.onclick=()=>runAudit();
|
||||
download.onclick=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify(results,null,2)],{type:'application/json'}));
|
||||
const anchor=document.createElement('a');anchor.href=url;anchor.download='framey-browser-audit.json';anchor.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
</script>'''
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--cache', type=Path, required=True)
|
||||
parser.add_argument('--catalog', type=Path, default=ROOT/'ui/framey-compatibility.json')
|
||||
parser.add_argument('--port', type=int, default=18765)
|
||||
parser.add_argument('--library-snapshot', type=Path,
|
||||
help='Optional detached Steam library JSON; replay only, not live Steam/backend evidence')
|
||||
args = parser.parse_args()
|
||||
plugins = json.loads(args.catalog.read_text())['plugins']
|
||||
records = {p['id']: p for p in plugins}
|
||||
library = None
|
||||
if args.library_snapshot:
|
||||
raw = args.library_snapshot.read_bytes()
|
||||
if len(raw) > 2*1024*1024:
|
||||
parser.error('library snapshot exceeds 2 MiB')
|
||||
library = json.loads(raw)
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split('?')[0]
|
||||
content_type = 'text/html; charset=utf-8'
|
||||
if path == '/':
|
||||
# Only names/IDs are needed by the UI; never embed raw executable source.
|
||||
catalog = json.dumps([{'id': p['id'], 'name': p['name']} for p in plugins]).replace('<', '\\u003c')
|
||||
body = PAGE.replace('CATALOG_DATA', catalog).replace(
|
||||
'LIBRARY_DATA', json.dumps(library).replace('<', '\\u003c')).encode()
|
||||
elif path.startswith('/panel/'):
|
||||
try:
|
||||
record = records[int(path.split('/')[-1])]
|
||||
data = (args.cache/(record['sha256']+'.zip')).read_bytes()
|
||||
if hashlib.sha256(data).hexdigest() != record['sha256']:
|
||||
raise ValueError('Archive hash mismatch')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
bundle = archive.read(record['folder']+'/dist/index.js')
|
||||
with patch.object(framey_panel, 'local_bundle', return_value=bundle):
|
||||
body = framey_panel.panel(record['id']).replace(
|
||||
b'<script id="framey-bundle"', PRELUDE.encode()+b'<script id="framey-bundle"')
|
||||
except (KeyError, ValueError, OSError, zipfile.BadZipFile):
|
||||
self.send_error(404, 'Verified frontend archive unavailable')
|
||||
return
|
||||
else:
|
||||
files = {'/framey-runtime/react.js': 'ui/vendor/react-18.3.1.min.js',
|
||||
'/framey-runtime/react-dom.js': 'ui/vendor/react-dom-18.3.1.min.js',
|
||||
'/framey-runtime/host.js': 'ui/framey-host.js'}
|
||||
if path not in files:
|
||||
self.send_error(404)
|
||||
return
|
||||
body = (ROOT/files[path]).read_bytes()
|
||||
content_type = 'text/javascript'
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline' blob:; style-src 'unsafe-inline'; img-src data:; frame-src 'self'; connect-src 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
print(f'Frontend audit: http://127.0.0.1:{args.port}', flush=True)
|
||||
ThreadingHTTPServer(('127.0.0.1', args.port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit a verified catalog cache in a private disposable fake-Frame project."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--cache', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
cache = args.cache.resolve()
|
||||
output = args.output.resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
records = json.loads((root/'ui/framey-compatibility.json').read_text())['plugins']
|
||||
if not all((cache/(p['sha256']+'.zip')).is_file() for p in records):
|
||||
parser.error('cache must contain all exact catalog archives; run framey-audit-store.py first')
|
||||
# Unique project avoids tearing down another job's fake Frame or its volumes.
|
||||
project = 'framey-catalog-'+uuid.uuid4().hex[:10]
|
||||
subprocess.run(['docker', 'build', '-q', '-t', 'fakeframe-frame', '-f',
|
||||
str(root/'tests/fakeframe/Containerfile'), str(root/'tests/fakeframe')], check=True)
|
||||
subprocess.run(['docker', 'build', '-q', '-t', 'fakeframe-host', '-f',
|
||||
str(root/'tests/fakeframe/host.Containerfile'), str(root/'tests/fakeframe')], check=True)
|
||||
with tempfile.TemporaryDirectory(prefix='framey-catalog-') as temp:
|
||||
override = Path(temp)/'compose.json'
|
||||
override.write_text(json.dumps({'services': {'host': {
|
||||
'volumes': [f'{cache}:/audit-cache:ro', f'{output.parent}:/audit-output'],
|
||||
'environment': {'FRAMEY_AUDIT_CACHE': '/audit-cache',
|
||||
'FRAMEY_AUDIT_OUTPUT': '/audit-output/'+output.name}}}}))
|
||||
compose = ['docker', 'compose', '-p', project, '-f', str(root/'tests/fakeframe/compose.yaml'), '-f', str(override)]
|
||||
try:
|
||||
subprocess.run(compose+['up', '-d', '--wait'], check=True)
|
||||
subprocess.run(compose+['exec', '-T', '-w', '/repo/tests/e2e', 'host', 'python3', '-m',
|
||||
'unittest', '-v', 'test_framey_catalog.CatalogAudit'], check=True)
|
||||
finally:
|
||||
subprocess.run(compose+['down', '-v', '--remove-orphans'], check=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect official Decky archives without importing or executing plugin code.
|
||||
|
||||
Archives stay in a caller-selected cache. SHA-256 is checked before inspection.
|
||||
The resulting observations are static evidence, not compatibility guarantees.
|
||||
"""
|
||||
import argparse
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
STORE = 'https://plugins.deckbrew.xyz/plugins'
|
||||
CDN = 'https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/'
|
||||
INIT = '__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit'
|
||||
|
||||
|
||||
def fetch(url, limit):
|
||||
request = urllib.request.Request(url, headers={'User-Agent': 'Frame-Control-compatibility-audit'})
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
if int(response.headers.get('Content-Length', '0')) > limit:
|
||||
raise ValueError('download exceeds audit size limit')
|
||||
data = response.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
raise ValueError('download exceeds audit size limit')
|
||||
return data
|
||||
|
||||
|
||||
def inspect(item, cache):
|
||||
version = item['versions'][0]
|
||||
digest = version['hash']
|
||||
result = {'id': item['id'], 'name': item['name'], 'version': version['name'],
|
||||
'sha256': digest, 'tags': item.get('tags', [])}
|
||||
try:
|
||||
path = cache / (digest + '.zip')
|
||||
data = path.read_bytes() if path.exists() else fetch(CDN + digest + '.zip', 200 * 1024 * 1024)
|
||||
if hashlib.sha256(data).hexdigest() != digest:
|
||||
raise ValueError('official archive SHA-256 mismatch')
|
||||
path.write_bytes(data)
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
entries = archive.infolist()
|
||||
if len(entries) > 6000 or sum(e.file_size for e in entries) > 500 * 1024 * 1024:
|
||||
raise ValueError('archive exceeds audit size limit')
|
||||
manifests = [e.filename for e in entries if e.filename.count('/') == 1 and e.filename.endswith('/plugin.json')]
|
||||
if len(manifests) != 1:
|
||||
raise ValueError('expected one top-level plugin.json')
|
||||
manifest = json.loads(archive.read(manifests[0]))
|
||||
folder = manifests[0].split('/')[0]
|
||||
frontend_path = folder + '/dist/index.js'
|
||||
frontend = archive.read(frontend_path).decode('utf-8', errors='replace') if frontend_path in archive.namelist() else ''
|
||||
result.update(folder=folder, archiveBytes=len(data), api=manifest.get('api_version', 'legacy'), flags=manifest.get('flags', []),
|
||||
modernBridge=INIT in frontend, frontend=bool(frontend),
|
||||
frontendApi=('legacy' if re.match(r'\s*\(function\s*\(', frontend) else
|
||||
next(iter(re.findall(r'API_VERSION\s*=\s*(\d+)', frontend)), 'unknown')))
|
||||
python_files, native, sources = [], [], []
|
||||
for entry in entries:
|
||||
if entry.is_dir():
|
||||
continue
|
||||
with archive.open(entry) as stream:
|
||||
header = stream.read(20)
|
||||
if header[:4] == b'\x7fELF':
|
||||
machine = int.from_bytes(header[18:20], 'little' if header[5] == 1 else 'big')
|
||||
native.append({'file': entry.filename, 'elfMachine': machine,
|
||||
'arch': {62: 'x86_64', 183: 'aarch64', 3: 'x86', 40: 'arm'}.get(machine, str(machine))})
|
||||
elif header[:2] == b'MZ':
|
||||
native.append({'file': entry.filename, 'arch': 'Windows'})
|
||||
if entry.filename.endswith('.py') and entry.file_size < 2 * 1024 * 1024:
|
||||
python_files.append(entry.filename)
|
||||
sources.append(archive.read(entry).decode('utf-8', errors='replace'))
|
||||
backend = '\n'.join(sources)
|
||||
metadata = json.dumps(manifest)
|
||||
for suffix in ('/package.json', '/README.md'):
|
||||
name = folder + suffix
|
||||
if name in archive.namelist() and archive.getinfo(name).file_size < 1024 * 1024:
|
||||
metadata += '\n' + archive.read(name).decode('utf-8', errors='replace')
|
||||
repositories = set(re.findall(r'https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', metadata + '\n' + backend))
|
||||
repositories.update('https://github.com/' + match for match in re.findall(
|
||||
r'https://raw\.githubusercontent\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)', metadata))
|
||||
|
||||
result.update(pythonFiles=python_files, native=native,
|
||||
steamClient=sorted(set(re.findall(r'SteamClient\.([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)', frontend))),
|
||||
dfl=sorted(set(re.findall(r'(?:DFL|__DFL__|deckyFrontendLib)\.([A-Za-z_$][\w$]*)', frontend))),
|
||||
globals=sorted(set(re.findall(r'\b(?:Router|routerHook|toaster|SteamUIStore|appStore|appDetailsStore|collectionStore|achievementStore|window\.SP_[A-Z_]+|DeckyPluginLoader|webpackChunksteamui)\b', frontend))),
|
||||
uiSurfaces=[label for label, pattern in (
|
||||
('local/Steam routes', r'routerHook|addRoute|removeRoute'),
|
||||
('toasts', r'\btoaster\b|\.toast\('),
|
||||
('QAM/global tabs', r'quickAccessMenu|addGlobalComponent|addTab|QAM'),
|
||||
('Steam page patches/module lookup', r'afterPatch|beforePatch|findModule|createModuleMapping'),
|
||||
('Steam stores', r'\b\w+Store\b'),
|
||||
) if re.search(pattern, frontend)],
|
||||
hardwareSignals=sorted(set(re.findall(r'(?:/sys/[^\s\x22\x27)]+|/dev/(?:hidraw|input|i2c|drm)[\w/*-]*|\b(?:ryzenadj|intel_pstate|amdgpu|hwmon|gamescope|pactl|bluetoothctl|flatpak|systemctl|sudo|x86_64|amd64)\b)', backend))),
|
||||
sourceURLs=sorted(repositories)[:20])
|
||||
except Exception as failure:
|
||||
result['auditError'] = str(failure)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--catalog', type=Path, help='saved official catalog (otherwise fetch current)')
|
||||
parser.add_argument('--cache', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
records = json.loads(args.catalog.read_bytes() if args.catalog else fetch(STORE, 4 * 1024 * 1024))
|
||||
records = [r for r in records if r.get('visible') is not False and r.get('versions')]
|
||||
args.cache.mkdir(parents=True, exist_ok=True)
|
||||
with ThreadPoolExecutor(max_workers=6) as workers:
|
||||
plugins = list(workers.map(lambda item: inspect(item, args.cache), records))
|
||||
args.output.write_text(json.dumps({'checkedAtUtc': datetime.now(timezone.utc).isoformat(),
|
||||
'source': STORE, 'method': 'SHA-256-verified official archives; static inspection only; plugin code was not executed.',
|
||||
'plugins': plugins}, indent=2) + '\n')
|
||||
print(f"Audited {len(plugins)} official plugins; {sum('auditError' in p for p in plugins)} errors. {args.output}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the documentation from the same digest-scoped data used by the store."""
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def cell(value):
|
||||
if isinstance(value, list):
|
||||
value = ', '.join(str(v) for v in value) or 'none detected'
|
||||
return str(value).replace('|', '|').replace('\n', ' ').replace('<', '<')
|
||||
|
||||
|
||||
def render(data):
|
||||
counts = Counter(p['status'] for p in data['plugins'])
|
||||
lines = ['# Official Decky store compatibility with Framey', '',
|
||||
f"Snapshot: {data['checkedAtUtc']}; {len(data['plugins'])} current visible official plugins from {data['source']}.", '',
|
||||
'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.', '',
|
||||
f"**Works: {counts['works']} · Partial: {counts['partial']} · Broken: {counts['broken']} · N/A: {counts['na']}.**", '',
|
||||
'- **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.',
|
||||
'- **Broken** means a concrete archive, frontend or Steam dependency blocks the audited version. An artificial backend/network denial alone is not evidence that the real backend is broken.',
|
||||
'- **N/A** means the plugin targets hardware or a display/UI surface that Framey does not provide. The store disables installation for these versions.', '',
|
||||
'## Verification limits', '',
|
||||
f"All exact archives were hash-checked and statically inspected. The local Chromium host used Framey commit `{data['hostCommit']}`, React 18.3.1 and an opaque `allow-scripts` iframe. "
|
||||
'Each frontend was attempted for 1.2 seconds, including React effects. All plugin backend calls, fetches and websockets were denied. '
|
||||
'Some plugins attempt mutating actions on mount; none reached a backend. Initialization (`ready`) does not guarantee React rendered, and delayed errors/actions may occur after the observation window. '
|
||||
'The JSON preserves initializer errors, capped console observations and attempted method names.', '',
|
||||
'The optional full-catalog fake-Frame audit exercises the real HTTP/SSH installer, receipt, frontend bytes and removal, '
|
||||
'or expected rejection. It uses a private disposable Compose project with all plugin backend execution stubbed. '
|
||||
'It checks archive transport and safeguards, not Python/native execution. Run it with:', '',
|
||||
'```sh',
|
||||
'python3 scripts/framey-audit-store.py --cache /tmp/framey-archives --output /tmp/framey-observations.json',
|
||||
'python3 scripts/framey-audit-fake.py --cache /tmp/framey-archives --output /tmp/framey-fake/results.json',
|
||||
'python3 scripts/framey-audit-browser.py --cache /tmp/framey-archives',
|
||||
'```', '',
|
||||
'Open the browser audit URL in local Chromium, run `runAudit()` and inspect/download `results`. All backend/network actions remain denied.', '',
|
||||
'The current published digests are pinned in the JSON; if the store changed, retain/download those exact archives for reproduction. '
|
||||
'Audit downloads permit larger ZIPs for inspection; production installer limits remain 50 MiB ZIP / 150 MiB unpacked / 30 MiB per member.', '',
|
||||
'After the VR job released the shared lock, this audit acquired it and used a private temporary Decky 3.3.0 ARM64 user service. '
|
||||
'Read-only ProtonDB `get_setting` and Steam Status `get_settings` calls succeeded through the live Steam CEF websocket. '
|
||||
'The service and private base were removed and the lock released; normal managed services stayed inactive, battery was 100 → 99, '
|
||||
'and no power setting was changed. [Redacted results](evidence/framey-official-backend-2026-10-25.json) '
|
||||
'record exactly what ran. A [2026-10-26 follow-up](evidence/framey-events-backend-2026-10-26.json) '
|
||||
'verified default Notebook drawing/save/reopen across a real backend restart and Simple Timer start/cancel, '
|
||||
'countdown/completion events, a visible toast and persisted recents/subtle mode. '
|
||||
'Only private test settings were written. Simple Timer remains partial because its own expired-timer startup '
|
||||
'raises a missing `timer_task` error; sound/suspend are unsupported. '
|
||||
'A [local audio follow-up](evidence/framey-audio-backends-2026-10-26.md) exercised Volume Mixer, Volume Boost and Mixer '
|
||||
'through real Decky 3.3.0 amd64 backends on private silent virtual audio targets. Sliders, numeric volume entry, '
|
||||
'independent server readbacks and Mixer filter persistence across a runtime restart passed. '
|
||||
'These three stay partial: real ARM64 Frame audio dependencies/session mapping remain unverified because the shared headset lock was occupied. '
|
||||
'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. '
|
||||
'Other official backends remain unexecuted. '
|
||||
'The separate VR job proves Frame Control/sample compositor rendering, not any official plugin. '
|
||||
'No worn-VR official-plugin result is claimed. '
|
||||
'A [local UI follow-up](evidence/framey-ui-fallbacks-2026-10-26.json) rendered Pause Games and Tailscale Control settings '
|
||||
'with all backend/network actions denied; it does not verify process pausing or Tailscale changes. '
|
||||
'A [read-only Steam bridge follow-up](evidence/framey-steam-reads-2026-10-26.json) verified actual Frame country, '
|
||||
'install folders and system information, plus IsThereAnyDeal\'s country dropdown in local Chromium. '
|
||||
'The installed-plugin status was fixture-shaped in that relay; plugin backends stayed denied and the deployed headset HTTP endpoint remains unverified. '
|
||||
'Earlier focused checks cover [ProtonDB](evidence/framey-browser-2026-10-25.md), '
|
||||
'[Steam Status/cache](evidence/framey-cache-browser-2026-10-25.md), '
|
||||
'[Storage Cleaner](evidence/framey-api-v1-browser-2026-10-25.md), and '
|
||||
'[legacy MusicControl](evidence/framey-legacy-2026-10-25.md).', '',
|
||||
'## 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.', '',
|
||||
'| Plugin / version | Manifest / bundle API | Status | Reason | Verified by |',
|
||||
'| --- | --- | --- | --- | --- |']
|
||||
for p in data['plugins']:
|
||||
lines.append('| '+ ' | '.join(map(cell, [f"{p['id']}: {p['name']} {p['version']}",
|
||||
f"{p['api']} / {p['frontendApi']}", 'N/A' if p['status']=='na' else p['status'], p['reason'], p['verification']]))+' |')
|
||||
lines += ['', '## API, UI and backend inventory', '',
|
||||
'DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stores and router/toaster/QAM signals are listed separately. '
|
||||
'No signal means the static scan did not detect one, not that no dependency exists. Native binaries are classified by ELF machine or Windows MZ header; '
|
||||
'Python files and source links are preserved in the JSON.', '',
|
||||
'| Plugin | SteamClient methods | DFL / @decky/ui exports | Other Steam globals / UI surfaces | Backend | Hardware / OS signals |',
|
||||
'| --- | --- | --- | --- | --- | --- |']
|
||||
for p in data['plugins']:
|
||||
native = sorted({n['arch'] for n in p['native']})
|
||||
lines.append('| '+' | '.join(map(cell, [p['name'],p['steamClient'],p['dfl'],p['globals']+p.get('uiSurfaces',[]),
|
||||
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.', '',
|
||||
'[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.', '',
|
||||
'| Plugin | Native payloads | ARM64 result / remaining blocker |', '| --- | --- | --- |']
|
||||
for p in data['plugins']:
|
||||
if p['native']:
|
||||
binaries = [n['file'].removeprefix(p['folder']+'/') for n in p['native']]
|
||||
if len(binaries)>5:
|
||||
binaries = binaries[:5]+[f"… {len(binaries)} total; full list in JSON"]
|
||||
lines.append('| '+' | '.join(map(cell,[p['name'],binaries,p['nativePort']]))+' |')
|
||||
lines += ['', '## Evidence and maintenance', '',
|
||||
'The canonical JSON contains full archive hashes, native paths, source URLs, frontend observations and fake-Frame outcomes. '
|
||||
'Store compatibility is advisory except known installation/hardware blockers; removal always remains available. '
|
||||
'A panel retains its compatibility reason after the initializer reports ready. Upstream fixes require a new digest-scoped audit. '
|
||||
'Do not mark a plugin works based on archive installation, fake backend replies or initializer completion.', '']
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--check', action='store_true')
|
||||
args = parser.parse_args()
|
||||
output = ROOT/'docs/framey-compatibility.md'
|
||||
expected = render(json.loads((ROOT/'ui/framey-compatibility.json').read_text(encoding='utf-8')))
|
||||
if args.check:
|
||||
if not output.exists() or output.read_text(encoding='utf-8') != expected:
|
||||
raise SystemExit('Compatibility documentation differs from canonical store data')
|
||||
else:
|
||||
output.write_text(expected, encoding='utf-8')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -66,12 +66,16 @@ finally: lib.VR_ShutdownInternal()
|
||||
battery=[]
|
||||
for p in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
|
||||
if (p.parent/'type').read_text().strip()=='Battery': battery.append(int(p.read_text()))
|
||||
proximity=[]
|
||||
for p in pathlib.Path('/sys/bus/iio/devices').glob('iio:device*/in_proximity_raw'):
|
||||
try: proximity.append(float(p.read_text().strip()))
|
||||
except (OSError,ValueError): pass
|
||||
def cmd(*args):
|
||||
p=subprocess.run(args,capture_output=True,text=True,timeout=8)
|
||||
if p.returncode: raise RuntimeError(p.stderr or p.stdout)
|
||||
return p.stdout.strip()
|
||||
vr='/opt/steamvr/bin/linuxarm64/vrcmd'
|
||||
print(json.dumps({'battery':battery,'dashboardVisible':dashboard,'stats':json.loads(cmd(vr,'--stats')),
|
||||
print(json.dumps({'battery':battery,'proximity':proximity,'dashboardVisible':dashboard,'stats':json.loads(cmd(vr,'--stats')),
|
||||
'overlays':cmd(vr,'--overlays'),
|
||||
'pause':cmd(vr,'--settings-bool','power.pauseCompositorOnStandby'),
|
||||
'timeout':cmd(vr,'--settings-float','power.turnOffScreensTimeout')}))
|
||||
@@ -95,6 +99,33 @@ finally: vr.close()
|
||||
'''
|
||||
|
||||
|
||||
def wait_overlay(remote, attempts=8):
|
||||
"""Give SteamVR a bounded moment to expose its newly registered panel."""
|
||||
for attempt in range(attempts):
|
||||
snapshot = remote.python(diagnostic_source())
|
||||
if snapshot['state']['visible']:
|
||||
return snapshot
|
||||
if attempt < attempts - 1:
|
||||
time.sleep(1)
|
||||
return snapshot
|
||||
|
||||
|
||||
HIDDEN_OVERLAY_DIAGNOSTIC = '''
|
||||
import json,pathlib,subprocess
|
||||
key='valve.steam.desktopgame.2001947813'
|
||||
p=subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--overlays'],
|
||||
capture_output=True,text=True,timeout=8)
|
||||
lines=[line.strip() for line in p.stdout.splitlines() if "'"+key+"'" in line]
|
||||
service=subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],
|
||||
capture_output=True,text=True,timeout=8)
|
||||
state=pathlib.Path.home()/'.local/share/frame-control/headset/state.json'
|
||||
try: window=json.loads(state.read_text()).get('window')
|
||||
except (OSError,ValueError): window=None
|
||||
print(json.dumps({'overlayLines':lines[:2], 'overlayCommandExit':p.returncode,
|
||||
'serviceActive':service.stdout.strip()=='active', 'windowRegistered':bool(window)}))
|
||||
'''
|
||||
|
||||
|
||||
def debug_bundle(original, profile):
|
||||
"""Only the test-owned browser gets CDP and a disposable profile."""
|
||||
out = io.BytesIO()
|
||||
@@ -151,6 +182,11 @@ def main(argv=None):
|
||||
pause, timeout = settings(before)
|
||||
if not before['battery'] or min(before['battery']) < 20:
|
||||
raise RuntimeError('Battery unknown or below 20%')
|
||||
if args.wake or args.exercise:
|
||||
stats = before.get('stats')
|
||||
if (not isinstance(stats, list) or not stats or
|
||||
not isinstance(stats[0], dict) or stats[0].get('activity_level') != 3):
|
||||
raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched')
|
||||
if args.wake or args.exercise:
|
||||
# Independent systemd timer restores power even if the controller is
|
||||
# killed or SSH disappears. It retains the test lock for recovery.
|
||||
@@ -202,7 +238,7 @@ print(json.dumps({'created':True}))
|
||||
remote(VRCMD + ' --handlewakeup')
|
||||
remote(VRCMD + ' --showdashboard ' + KEY)
|
||||
time.sleep(2)
|
||||
report['overlay'] = remote.python(diagnostic_source())
|
||||
report['overlay'] = wait_overlay(remote) if args.wake or args.exercise else remote.python(diagnostic_source())
|
||||
if args.exercise and not report['overlay']['state']['visible']:
|
||||
report['retaggedTestWindow'] = True
|
||||
remote.python('''
|
||||
@@ -217,7 +253,13 @@ subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--showdashboard','valve.ste
|
||||
time.sleep(2)
|
||||
print('{}')
|
||||
''')
|
||||
report['overlay'] = remote.python(diagnostic_source())
|
||||
report['overlay'] = wait_overlay(remote)
|
||||
if args.exercise and not report['overlay']['state']['visible']:
|
||||
try:
|
||||
report['hiddenOverlay'] = remote.python(HIDDEN_OVERLAY_DIAGNOSTIC)
|
||||
except Exception as exc:
|
||||
report['hiddenOverlayError'] = str(exc)
|
||||
raise RuntimeError('Frame Control overlay stayed hidden after focus retry; no input sent')
|
||||
if args.wake or args.exercise:
|
||||
shot = remote.python((ROOT/'ui/frame_vrshot.py').read_text(), timeout=20)['path']
|
||||
try:
|
||||
|
||||
@@ -15,7 +15,7 @@ dry=""
|
||||
[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
|
||||
tag="${1:?usage: $0 [--dry-run] vX.Y.Z}"
|
||||
repo=saphid/frame-control
|
||||
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
|
||||
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-mac-x64.dmg Frame-Control-mac-x64.zip Frame-Control-Setup-x64.exe
|
||||
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
|
||||
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
|
||||
page="https://github.com/$repo/releases/tag/$tag"
|
||||
@@ -70,7 +70,7 @@ print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent
|
||||
done
|
||||
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
|
||||
|
||||
# The workflow uploads this only after all eight platform builds complete.
|
||||
# The workflow uploads this only after all ten platform builds complete.
|
||||
# Regenerate it from GitHub's installer digests and compare its own GitHub
|
||||
# digest, so a stale or partial checksum file cannot pass the release gate.
|
||||
python3 "$(dirname "$0")/release-checksums.py" < "$tmp/release.json" > "$tmp/SHA256SUMS"
|
||||
@@ -79,7 +79,7 @@ checksum_digest=$(python3 -c 'import json,sys
|
||||
d=json.load(open(sys.argv[1])); matches=[a.get("digest") for a in d["assets"] if a["name"]=="SHA256SUMS"]
|
||||
print(matches[0] if len(matches)==1 else "absent")' "$tmp/release.json")
|
||||
[ "$checksum_digest" = "sha256:$expected_sum" ] || {
|
||||
echo "not publishing: SHA256SUMS is absent or does not match the eight installer digests" >&2; exit 1;
|
||||
echo "not publishing: SHA256SUMS is absent or does not match the ten installer digests" >&2; exit 1;
|
||||
}
|
||||
echo "ok SHA256SUMS"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print SHA256SUMS for the eight release installers from GitHub asset metadata.
|
||||
"""Print SHA256SUMS for the ten release installers from GitHub asset metadata.
|
||||
|
||||
GitHub computes each asset digest from its uploaded bytes. The workflow runs
|
||||
this only after all platform builds finish; publish-release.sh checks that the
|
||||
@@ -13,6 +13,7 @@ import sys
|
||||
INSTALLERS = (
|
||||
"Frame-Control-mac-arm64.dmg",
|
||||
"Frame-Control-mac-arm64.zip",
|
||||
"Frame-Control-mac-x64.dmg", "Frame-Control-mac-x64.zip",
|
||||
"Frame-Control-Setup-x64.exe",
|
||||
"Frame-Control-win-x64.zip",
|
||||
"Frame-Control-linux-x86_64.AppImage",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Optional full-catalog archive/transport audit; no official backend executes."""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
import unittest
|
||||
import zipfile
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from harness import FrameTestCase, ROOT, Server, finished, ok, require, ssh
|
||||
import frame_decky_store
|
||||
|
||||
require()
|
||||
|
||||
|
||||
@unittest.skipUnless(os.environ.get('FRAMEY_AUDIT_CACHE'), 'optional full catalog audit cache not mounted')
|
||||
class CatalogAudit(FrameTestCase):
|
||||
def backend(self):
|
||||
# Only the fake service/CEF stub executes. Official Python/native code
|
||||
# copied by the installer is never imported or launched by this harness.
|
||||
source = '''import json,pathlib
|
||||
h=pathlib.Path.home();b=h/'.local/share/frame-control/decky/.frame-control';b.mkdir(parents=True,exist_ok=True)
|
||||
(b/'receipt.json').write_text(json.dumps({'managedBy':'Frame Control','version':'v3.3.0'}))
|
||||
(b.parent/'plugins').mkdir(exist_ok=True)
|
||||
u=h/'.config/systemd/user/frame-control-decky.service';u.parent.mkdir(parents=True,exist_ok=True);u.write_text('[Service]')'''
|
||||
ssh('python3 -c '+shlex.quote(source))
|
||||
ssh('mkdir -p ~/.local/share/frame-control/headset/bundle/ui')
|
||||
result = subprocess.run(['ssh', 'frame', 'cat > ~/.local/share/frame-control/headset/bundle/ui/frame_steam.py'],
|
||||
input=(ROOT/'ui/frame_steam.py').read_bytes(), capture_output=True, timeout=20)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
records = json.loads((ROOT/'ui/framey-compatibility.json').read_text())['plugins']
|
||||
cache = Path(os.environ['FRAMEY_AUDIT_CACHE'])
|
||||
|
||||
class Fixture(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == '/plugins':
|
||||
body = json.dumps([{'id': p['id'], 'name': p['name'], 'tags': p['tags'],
|
||||
'versions': [{'name': p['version'], 'hash': p['sha256']}]}
|
||||
for p in records]).encode()
|
||||
else:
|
||||
path = cache / self.path.rsplit('/', 1)[-1]
|
||||
if path.suffix != '.zip' or path.name not in {p['sha256']+'.zip' for p in records}:
|
||||
self.send_error(404)
|
||||
return
|
||||
body = path.read_bytes()
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass # Installer stops reading at its archive size limit.
|
||||
|
||||
fixture = ThreadingHTTPServer(('127.0.0.1', 0), Fixture)
|
||||
cls.addClassCleanup(fixture.server_close)
|
||||
cls.addClassCleanup(fixture.shutdown)
|
||||
threading.Thread(target=fixture.serve_forever, daemon=True).start()
|
||||
old = {k: os.environ.get(k) for k in ('FRAMEY_STORE_URL', 'FRAMEY_CDN_URL')}
|
||||
def restore():
|
||||
Server.stop()
|
||||
for key, value in old.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
cls.addClassCleanup(restore)
|
||||
os.environ['FRAMEY_STORE_URL'] = f'http://127.0.0.1:{fixture.server_port}/plugins'
|
||||
os.environ['FRAMEY_CDN_URL'] = f'http://127.0.0.1:{fixture.server_port}/versions/'
|
||||
Server.stop()
|
||||
super().setUpClass()
|
||||
|
||||
def test_all_current_official_archives(self):
|
||||
self.backend()
|
||||
records = json.loads((ROOT/'ui/framey-compatibility.json').read_text())['plugins']
|
||||
cache = Path(os.environ['FRAMEY_AUDIT_CACHE'])
|
||||
catalog = {p['id']: p for p in ok('GET', '/api/decky/store')['plugins']}
|
||||
self.assertEqual(set(catalog), {p['id'] for p in records})
|
||||
results = []
|
||||
for plugin in records:
|
||||
with self.subTest(plugin=plugin['name']):
|
||||
data = (cache/(plugin['sha256']+'.zip')).read_bytes()
|
||||
self.assertEqual(hashlib.sha256(data).hexdigest(), plugin['sha256'])
|
||||
validation = None
|
||||
try:
|
||||
folder = frame_decky_store.archive_info(data, plugin['sha256'], plugin['name'])
|
||||
except ValueError as error:
|
||||
validation = str(error)
|
||||
blocked = catalog[plugin['id']]['root'] or plugin['blockInstall'] or validation
|
||||
job = finished(ok('POST', '/api/decky/plugin', {'action': 'install', 'id': plugin['id'],
|
||||
'sha256': plugin['sha256'], 'acknowledged': True}))
|
||||
record = {'id': plugin['id'], 'sha256': plugin['sha256'],
|
||||
'archiveValidation': validation or 'accepted', 'result': 'blocked' if blocked else 'installed-removed'}
|
||||
if blocked:
|
||||
self.assertTrue(job.get('error'), job)
|
||||
record['reason'] = job['error']
|
||||
self.assertFalse(any(p['storeId'] == plugin['id'] for p in ok('GET', '/api/decky')['plugins']))
|
||||
else:
|
||||
self.assertFalse(job.get('error'), job)
|
||||
installed = f'~/.local/share/frame-control/decky/plugins/{folder}'
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
entry = folder+'/dist/index.js'
|
||||
if entry in archive.namelist():
|
||||
expected = hashlib.sha256(archive.read(entry)).hexdigest()
|
||||
self.assertEqual(ssh(f'sha256sum {installed}/dist/index.js').split()[0], expected)
|
||||
found = next(p for p in ok('GET', '/api/decky')['plugins'] if p['storeId'] == plugin['id'])
|
||||
self.assertEqual((found['version'], found['sha256']), (plugin['version'], plugin['sha256']))
|
||||
removed = finished(ok('POST', '/api/decky/plugin', {'action': 'remove', 'id': plugin['id'], 'acknowledged': True}))
|
||||
self.assertFalse(removed.get('error'), removed)
|
||||
self.assertFalse(any(p['storeId'] == plugin['id'] for p in ok('GET', '/api/decky')['plugins']))
|
||||
results.append(record)
|
||||
Path(os.environ.get('FRAMEY_AUDIT_OUTPUT', '/tmp/framey-catalog-result.json')).write_text(json.dumps(results, indent=2)+'\n')
|
||||
@@ -3,7 +3,7 @@ import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source=readFileSync(new URL('../../ui/framey-host.js',import.meta.url),'utf8');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI;\n})();');
|
||||
const testSource=source.replace(/\n start\(\);\n\}\)\(\);\s*$/, '\n window.__surface=PluginSurface; window.__legacy=legacyServerAPI; window.__library=setLibrarySnapshot;\n})();');
|
||||
assert.notEqual(testSource,source);
|
||||
const states=[];
|
||||
const React={Fragment:'fragment',Component:class {},createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
@@ -20,7 +20,7 @@ const window={React,ReactDOM:{createRoot:node=>{
|
||||
const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)};
|
||||
const context=vm.createContext({window,document:{getElementById:id=>{
|
||||
if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()},
|
||||
navigator:{language:'en'}, parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
navigator:{language:'en'}, parent,DOMException,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(testSource,context);
|
||||
// Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists.
|
||||
@@ -67,6 +67,24 @@ readyInput.props.onChange({target:{value:'New'}});assert.equal(entered,'New');
|
||||
let speed=0;
|
||||
const readySlider=nodes(window.DFL.SliderField({label:'Speed',value:2,onChange:v=>{speed=v;}}),'input')[0];
|
||||
readySlider.props.onChange({target:{value:'4'}});assert.equal(speed,4);
|
||||
// Official Volume Boost and Mixer request editableValue for precise volume entry.
|
||||
const numericSlider=nodes(window.DFL.SliderField({label:'Volume',value:0.4,min:0,max:1,step:0.05,
|
||||
editableValue:true,onChange:v=>{speed=v;}}),'input');
|
||||
assert.equal(numericSlider[0].props.type,'range');
|
||||
assert.equal(numericSlider[1].props.type,'number');
|
||||
assert.equal(numericSlider[1].props['aria-label'],'Volume value');
|
||||
assert.equal(numericSlider[1].props.step,0.05);
|
||||
numericSlider[1].props.onChange({target:{value:'0.65',validity:{valid:true}}});
|
||||
assert.equal(speed,0.65);
|
||||
for(const value of ['', ' ', 'NaN', 'Infinity', '-0.05', '1.05'])
|
||||
numericSlider[1].props.onChange({target:{value,validity:{valid:true}}});
|
||||
numericSlider[1].props.onChange({target:{value:'0.63',validity:{valid:false}}});
|
||||
assert.equal(speed,0.65,'Invalid drafts must not reach a backend');
|
||||
const disabledNumber=nodes(window.DFL.SliderField({label:'Volume',value:40,editableValue:true,
|
||||
disabled:true,onChange:()=>changed++}),'input')[1];
|
||||
assert.equal(disabledNumber.props.disabled,true);
|
||||
disabledNumber.props.onChange({target:{value:'50',validity:{valid:true}}});
|
||||
assert.equal(changed,0);
|
||||
let selected;
|
||||
const readyDropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast',
|
||||
onChange:option=>{selected=option;}}),'select')[0];
|
||||
@@ -87,7 +105,12 @@ assert.equal(nodes(tree,'header').length,1);
|
||||
await buttons[2].props.onClick();
|
||||
assert.equal(called,1);
|
||||
assert.deepEqual(states.slice(-3),[0,'Refresh data…','Fresh',null].slice(-3));
|
||||
assert.equal(nodes(window.__surface({plugin:{settings:{type:'settings'}}}),'nav').length,1);
|
||||
const settingsOnly=window.__surface({plugin:{settings:{type:'settings'}}});
|
||||
assert.equal(nodes(settingsOnly,'nav').length,0);
|
||||
assert.equal(nodes(settingsOnly,'settings').length,1);
|
||||
const panelOnly=window.__surface({plugin:{content:{type:'content'}}});
|
||||
assert.equal(nodes(panelOnly,'nav').length,0);
|
||||
assert.equal(nodes(panelOnly,'content').length,1);
|
||||
|
||||
// API v2 backend calls remain request-scoped and errors return to the caller.
|
||||
const api=window.__DECKY_SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED_deckyLoaderAPIInit.connect(2,'Test');
|
||||
@@ -99,6 +122,17 @@ listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:
|
||||
assert.equal(await reply,'dark');
|
||||
await assert.rejects(api.call('x;alert(1)'),/Invalid backend method/);
|
||||
|
||||
// These APIs use a separate, parameterless allowlisted read bridge.
|
||||
for(const [namespace,method,result] of [['User','GetIPCountry','AU'],
|
||||
['InstallFolder','GetInstallFolders',[{vecApps:[]}]],['System','GetSystemInfo',{sOSName:'Frame OS'}]]){
|
||||
const pendingRead=window.SteamClient[namespace][method]();
|
||||
const request=messages.at(-1);assert.equal(request.type,'framey:steam');assert.equal(request.method,method);
|
||||
assert.equal(request.args.length,0);
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,data:{type:'framey:reply',id:request.id,result}});
|
||||
assert.equal(await pendingRead,result);
|
||||
}
|
||||
assert.equal(window.SteamClient.System.ShutdownPC,undefined);
|
||||
|
||||
const toast=api.toaster.toast({title:'Saved',body:'Mode updated',duration:4000});
|
||||
const toastNode=elements.get('framey-toasts').children[0];
|
||||
assert.equal(toastNode.attributes.role,'status');
|
||||
@@ -175,6 +209,50 @@ assert.equal(unsubscribe.unregister,unsubscribe);
|
||||
unsubscribe.unregister();unsubscribe();assert.equal(eventCount,0);
|
||||
assert.equal(window.SteamClient.System.ShutdownPC,undefined);
|
||||
assert.equal(window.DFL.Router.MainRunningApp,undefined);
|
||||
assert.equal(window.DFL.Router.RunningApps.length,0);
|
||||
assert.equal(window.DFL.Router.RunningApps,window.SteamUIStore.RunningApps);
|
||||
assert.ok(Object.isFrozen(window.SteamUIStore));
|
||||
assert.ok(Object.isFrozen(window.SuspendResumeStore));
|
||||
assert.equal(window.SuspendResumeStore.InitiateSleep,undefined);
|
||||
for(const name of ['App','appStore','appInfoStore','collectionStore'])assert.equal(window[name],undefined);
|
||||
const snapshot={apps:[[10,'Game Ten','ten',1,'10',180,60,1234],
|
||||
[20,'Shortcut','shortcut',1073741824,'85899345920',0,0,0]],gameIds:[10]};
|
||||
window.__library(snapshot);
|
||||
assert.equal(window.appStore.allApps.length,2);
|
||||
assert.equal(window.appStore.GetAppOverviewByAppID('10').display_name,'Game Ten');
|
||||
assert.equal(window.appStore.GetAppOverviewByGameID('85899345920').appid,20);
|
||||
assert.equal(window.appStore.GetAppOverviewByGameID('missing'),undefined);
|
||||
assert.equal(window.collectionStore.allGamesCollection.allApps[0],window.appStore.m_mapApps.get(10));
|
||||
window.appStore.m_mapApps.get(10).display_name='Plugin-local patch';
|
||||
assert.equal(snapshot.apps[0][1],'Game Ten');
|
||||
assert.equal(window.App,undefined); // No fabricated account identity.
|
||||
window.__library({...snapshot,steamId:'76561198000000001'}); // Explicit test fixture, never a fallback.
|
||||
assert.deepEqual(Object.keys(window.App.m_CurrentUser),['strSteamID']);
|
||||
assert.equal(window.App.m_CurrentUser.strSteamID,'76561198000000001');
|
||||
window.appStore.allApps[0].display_name='Plugin-local patch';
|
||||
assert.equal(window.appInfoStore,undefined); // No fake MobX store.
|
||||
for(const invalid of [null,{apps:[],gameIds:[10]},
|
||||
{...snapshot,steamId:'invented'},
|
||||
{...snapshot,apps:[snapshot.apps[0],snapshot.apps[0]]},
|
||||
{...snapshot,apps:[[10,'Name','sort',1,'undefined',0,0,0]]},
|
||||
{...snapshot,apps:[[10,'Name','sort',1,'10',-1,0,0]]},
|
||||
{apps:Array(10001).fill(snapshot.apps[0]),gameIds:[]}]){
|
||||
assert.throws(()=>window.__library(invalid),/Invalid Steam/);
|
||||
assert.equal(window.appStore.allApps[0].display_name,'Plugin-local patch');
|
||||
}
|
||||
for(const register of [window.SteamClient.System.UI.RegisterForSystemKeyEvents,
|
||||
window.SteamClient.Apps.RegisterForGameActionUserRequest]){
|
||||
const dispose=register(()=>eventCount++);assert.equal(dispose.unregister,dispose);dispose();
|
||||
}
|
||||
assert.equal(eventCount,0);
|
||||
const image=window.DFL.SuspensefulImage({src:'data:image/svg+xml,<svg/>',alt:'Setup',style:{width:50}});
|
||||
assert.equal(image.type,'img');assert.equal(image.props.alt,'Setup');assert.equal(image.props.style.width,50);
|
||||
await assert.rejects(window.SteamClient.Storage.GetJSON('missing'),/Not found/);
|
||||
await window.SteamClient.Storage.SetObject('settings',{enabled:false,count:3});
|
||||
assert.equal(await window.SteamClient.Storage.GetJSON('settings'),'{"enabled":false,"count":3}');
|
||||
assert.equal(window.localStorage.getItem('steam-client:settings'),'{"enabled":false,"count":3}');
|
||||
await assert.rejects(window.SteamClient.Storage.SetObject('large','x'.repeat(65537)),/Framey plugin cache is full/);
|
||||
await assert.rejects(window.SteamClient.Storage.SetObject('undefined',undefined),/JSON value/);
|
||||
assert.equal(window.indexedDB,undefined); // localForage must use bounded plugin storage.
|
||||
const unpatch=window.DFL.beforePatch({},'method',()=>{});
|
||||
assert.equal(unpatch.unpatch,unpatch);unpatch.unpatch();
|
||||
@@ -206,3 +284,35 @@ assert.throws(()=>api.routerHook.addRoute('/plugin/:id?/art',GameRoute));
|
||||
removeGame();
|
||||
assert.equal(window.DFL.useParams().appid,undefined);
|
||||
assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0);
|
||||
|
||||
// Event subscription is acknowledged before a method that immediately emits.
|
||||
const onMessage=listeners.find(x=>x.type==='message').handler;
|
||||
const eventValues=[];
|
||||
const callback=(...args)=>eventValues.push(args);
|
||||
assert.equal(api.addEventListener('timer:tick',callback),callback);
|
||||
const subscribe=messages.at(-1);
|
||||
assert.equal(subscribe.type,'framey:events');
|
||||
assert.deepEqual(Array.from(subscribe.args[0]),['timer:tick']);
|
||||
const before=messages.length;
|
||||
const timerCall=api.call('load_remaining_seconds');
|
||||
assert.equal(messages.length,before);
|
||||
// Other windows cannot inject backend replies.
|
||||
onMessage({source:{},data:{type:'framey:reply',id:subscribe.id,result:{events:[],cursor:0}}});
|
||||
await Promise.resolve();assert.equal(messages.length,before);
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:subscribe.id,
|
||||
result:{events:[{event:'timer:tick',args:[12,'seconds']}],cursor:1}}});
|
||||
for(let i=0;i<12;i++)await Promise.resolve();
|
||||
assert.deepEqual(eventValues,[[12,'seconds']]);
|
||||
const timerRequest=messages.at(-1);
|
||||
assert.equal(timerRequest.type,'framey:call');assert.equal(timerRequest.method,'load_remaining_seconds');
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:timerRequest.id,result:null}});await timerCall;
|
||||
api.removeEventListener('timer:tick',callback);
|
||||
const unsubscribeRequest=messages.at(-1);assert.equal(unsubscribeRequest.type,'framey:events');
|
||||
assert.equal(unsubscribeRequest.args[0].length,0);
|
||||
onMessage({source:parent,data:{type:'framey:reply',id:unsubscribeRequest.id,
|
||||
result:{events:[{event:'timer:tick',args:[99]}],cursor:2}}});
|
||||
for(let i=0;i<12;i++)await Promise.resolve();assert.equal(eventValues.length,1);
|
||||
assert.throws(()=>api.addEventListener('bad event',callback),/Invalid Decky event/);
|
||||
assert.throws(()=>api.addEventListener('tick',null),/Invalid Decky event/);
|
||||
listeners.find(x=>x.type==='unload').handler();
|
||||
assert.equal(messages.at(-1).type,'framey:events:close');
|
||||
@@ -38,7 +38,7 @@ function iframe(){
|
||||
for(const message of queued)onParent({source:proxy,data:message});
|
||||
live=true;
|
||||
const send=message=>onParent({source:proxy,data:message});
|
||||
return {storage:frameWindow.localStorage,send};
|
||||
return {storage:frameWindow.localStorage,steamStorage:frameWindow.SteamClient.Storage,send};
|
||||
}
|
||||
let first=iframe();
|
||||
assert.equal(first.storage.getItem('status-cache'),null);
|
||||
@@ -46,6 +46,7 @@ first.storage.setItem('status-cache','{"online":true}');
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
|
||||
first.storage.setItem('temporary','discard');
|
||||
first.storage.removeItem('temporary');
|
||||
await first.steamStorage.SetObject('playtime-settings',{count:3});
|
||||
const oldSession=session;
|
||||
vm.runInContext('openFrameyPanel(14,"Steam Status")',parent);
|
||||
session=vm.runInContext('frameySession',parent);
|
||||
@@ -57,13 +58,15 @@ assert.equal(reopenedURL.searchParams.get('session'),session);
|
||||
assert.equal(reopenedURL.hash.slice(1),session);
|
||||
let reopened=iframe();
|
||||
assert.equal(reopened.storage.getItem('status-cache'),'{"online":true}');
|
||||
assert.equal(reopened.storage.length,1);
|
||||
assert.equal(reopened.storage.length,2);
|
||||
assert.equal(reopened.storage.key(0),'status-cache');
|
||||
assert.equal(await reopened.steamStorage.GetJSON('playtime-settings'),'{"count":3}');
|
||||
assert.throws(()=>reopened.storage.setItem('huge','x'.repeat(65537)),/Framey plugin cache is full/);
|
||||
// A malformed or over-limit message from a plugin must not poison saved data.
|
||||
const savedCache=values.get('framey-plugin-cache-v1:14');
|
||||
reopened.send({type:'framey:cache:save',session,entries:{huge:'x'.repeat(65537)}});
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),savedCache);
|
||||
reopened.send({type:'framey:cache:save',session:oldSession,entries:{poison:'yes'}});
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{"status-cache":"{\\"online\\":true}"}');
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),savedCache);
|
||||
reopened.storage.clear();
|
||||
assert.equal(values.get('framey-plugin-cache-v1:14'),'{}');
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
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 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;
|
||||
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,
|
||||
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
|
||||
vm.runInContext(source,context);
|
||||
vm.runInContext('deckyState={plugins:[{storeId:96}]};openFrameyPanel(96,"Timer")',context);
|
||||
let session=vm.runInContext('frameySession',context);
|
||||
const onMessage=listeners.find(x=>x.type==='message').fn;
|
||||
const send=(data,origin=frame)=>onMessage({source:origin,data});
|
||||
const subscription={type:'framey:events',id:1,session,args:[['tick'],0]};
|
||||
await send({...subscription,session:'old'});
|
||||
await send(subscription,{});
|
||||
await send({...subscription,args:[['bad event'],0]});
|
||||
assert.equal(requests.length,0);
|
||||
const first=send(subscription);
|
||||
assert.equal(requests.length,1);
|
||||
assert.equal(requests[0].path,'/api/framey/events');
|
||||
assert.equal(requests[0].body.id,96);
|
||||
assert.equal(requests[0].body.session,session);
|
||||
// WindowProxy survives navigation: stale same-plugin replies need a session check.
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
const secondSession=vm.runInContext('frameySession',context);
|
||||
assert.notEqual(secondSession,session);
|
||||
assert.equal(requests[1].body.events.length,0); // Closes only the original subscription.
|
||||
assert.equal(requests[1].body.session,session);
|
||||
requests[0].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[42]}],cursor:1})});
|
||||
await first;assert.equal(replies.length,0);
|
||||
await send({type:'framey:call',id:2,session,method:'cancel_timer',args:[]});
|
||||
assert.equal(requests.length,2); // Old document cannot call a newly opened plugin.
|
||||
session=secondSession;
|
||||
const second=send({...subscription,id:3,session});
|
||||
requests[2].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[7]}],cursor:2})});
|
||||
await second;
|
||||
assert.equal(replies.length,1);assert.equal(replies[0].result.events[0].args[0],7);
|
||||
el('frameyClose').onclick();
|
||||
assert.equal(requests[3].body.session,session);assert.equal(requests[3].body.events.length,0);
|
||||
for(const request of requests.slice(1))request.resolve({ok:true,json:async()=>({events:[],cursor:0})});
|
||||
|
||||
// Only explicit zero-argument Steam reads reach the trusted parent endpoint.
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
session=vm.runInContext('frameySession',context);
|
||||
const steamRead={type:'framey:steam',id:10,session,method:'GetIPCountry',args:[]};
|
||||
const initialCount=requests.length;
|
||||
for(const change of [{method:'ShutdownPC'},{method:'User.GetIPCountry'},{args:['injected']},{session:'stale'}])
|
||||
await send({...steamRead,...change});
|
||||
await send(steamRead,{});
|
||||
assert.equal(requests.length,initialCount);
|
||||
const read=send(steamRead);
|
||||
assert.equal(requests.at(-1).path,'/api/framey/steam');assert.equal(requests.at(-1).body.id,96);
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
|
||||
await read;assert.equal(replies.at(-1).result,'AU');
|
||||
const staleRead=send({...steamRead,id:11});
|
||||
vm.runInContext('openFrameyPanel(96,"Timer")',context);
|
||||
const priorReplies=replies.length;
|
||||
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
|
||||
await staleRead;assert.equal(replies.length,priorReplies);
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source=readFileSync(new URL('../../ui/framey-events.js',import.meta.url),'utf8');
|
||||
const handlers=new Set(),timers=new Map();let now=1000,nextTimer=0;
|
||||
const backend={addEventListener(name,listener){assert.equal(name,'loader/plugin_event');handlers.add(listener);},
|
||||
removeEventListener(name,listener){assert.equal(name,'loader/plugin_event');handlers.delete(listener);}};
|
||||
const window={DeckyBackend:backend};
|
||||
const context=vm.createContext({window,Date:{now:()=>now},
|
||||
setInterval:fn=>{timers.set(++nextTimer,fn);return nextTimer;},clearInterval:id=>timers.delete(id)});
|
||||
const poll=(session='one',name='Simple Timer',events=['tick'],cursor=0)=>
|
||||
JSON.parse(JSON.stringify(vm.runInContext(source,context)({session,name,events,cursor})));
|
||||
const emit=(plugin,event,...args)=>{for(const handler of handlers)handler({plugin,event,args});};
|
||||
assert.deepEqual(poll(),{events:[],cursor:0});
|
||||
assert.equal(handlers.size,1);
|
||||
emit('Other plugin','tick',10);emit('Simple Timer','other',99);emit('Simple Timer','tick',42,'seconds');
|
||||
assert.deepEqual(poll().events,[{sequence:1,event:'tick',args:[42,'seconds']}]);
|
||||
assert.equal(handlers.size,1); // Polling never duplicates the backend subscription.
|
||||
assert.deepEqual(poll('one','Simple Timer',['tick'],1).events,[]);
|
||||
assert.equal(poll().events.length,1); // Retry a lost HTTP response without losing its event.
|
||||
assert.throws(()=>poll('one','Another plugin'),/belongs to another plugin/);
|
||||
poll('two','Another plugin');emit('Another plugin','tick',123);
|
||||
assert.deepEqual(poll('two','Another plugin').events[0].args,[123]);
|
||||
assert.equal(poll().events.length,1);
|
||||
emit('Simple Timer','tick','x'.repeat(8193));assert.equal(poll().events.length,1);
|
||||
for(let index=0;index<100;index++)emit('Simple Timer','tick',index);
|
||||
assert.equal(poll().events.length,64);
|
||||
assert.equal(poll().events.at(-1).args[0],99);
|
||||
for(let index=0;index<20;index++)emit('Simple Timer','tick','x'.repeat(8000));
|
||||
assert.ok(JSON.stringify(poll()).length<50*1024);
|
||||
poll('one','Simple Timer',[]);assert.equal(handlers.size,1);
|
||||
now+=16000;for(const timer of [...timers.values()])timer();
|
||||
assert.equal(handlers.size,0);assert.equal(timers.size,0);
|
||||
poll();assert.equal(handlers.size,1);
|
||||
// A Decky frontend reconnect must register against the replacement backend.
|
||||
let replaced=0;
|
||||
window.DeckyBackend={...backend,addEventListener(name,fn){replaced++;backend.addEventListener(name,fn);}};
|
||||
poll();assert.equal(replaced,1);assert.equal(handlers.size,1);
|
||||
poll('one','Simple Timer',[]);assert.equal(handlers.size,0);
|
||||
@@ -9,7 +9,20 @@ assert.match(bundle,/SP_JSX\.jsx/);
|
||||
const React={Fragment:Symbol('fragment'),Component:class {},
|
||||
createElement:(type,props,...children)=>({type,props:props||{},children}),
|
||||
useState:value=>[value,()=>{}],useEffect(){},isValidElement:value=>!!value?.type};
|
||||
const window={React,ReactDOM:{createRoot:()=>({render(){}})},addEventListener(){}};
|
||||
class Element {
|
||||
constructor(){this.children=[];this.textContent='';this.attributes={};}
|
||||
append(...nodes){this.children.push(...nodes);}
|
||||
setAttribute(name,value){this.attributes[name]=value;}
|
||||
remove(){this.removed=true;}
|
||||
}
|
||||
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 root={render(value){this.value=value;},unmount(){this.unmounted=true;}};
|
||||
roots.set(node,root);return root;
|
||||
}},addEventListener(){}};
|
||||
const listeners=[];
|
||||
const calls=[];
|
||||
const parent={postMessage(message){
|
||||
@@ -18,7 +31,7 @@ const parent={postMessage(message){
|
||||
listeners.find(x=>x.type==='message').handler({source:parent,
|
||||
data:{type:'framey:reply',id:message.id,result:true}});
|
||||
}};
|
||||
const context=vm.createContext({window,document:{getElementById:()=>({})},
|
||||
const context=vm.createContext({window,document,
|
||||
navigator:{language:'en'},parent,addEventListener:(type,handler)=>listeners.push({type,handler}),
|
||||
setTimeout:()=>1,clearTimeout(){}});
|
||||
vm.runInContext(host,context);
|
||||
@@ -36,6 +49,19 @@ assert.equal(plugin.frameyQuickActions[0].label,'Read saved setting');
|
||||
assert.equal(await plugin.frameyQuickActions[0].onClick(),'Saved setting is on');
|
||||
assert.equal(calls[0].method,'get_setting');
|
||||
assert.deepEqual(Array.from(calls[0].args),['enabled',false]);
|
||||
// Exercise the built sample's save path through the real Framey toast host.
|
||||
// Its JSX body must reach a React root, rather than stringify to an object.
|
||||
content.children[0][0].children[0].props.onChange(true);
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
const toast=elements.get('framey-toasts').children[0];
|
||||
assert.equal(toast.attributes.role,'status');
|
||||
assert.equal(toast.children[0].textContent,'Framey sample');
|
||||
const toastBody=roots.get(toast.children[1]).value;
|
||||
assert.equal(toastBody.type,'span');
|
||||
assert.match(JSON.stringify(toastBody),/Setting saved/);
|
||||
assert.match(JSON.stringify(toastBody),/on/);
|
||||
toast.children[2].onclick();
|
||||
assert.equal(roots.get(toast.children[1]).unmounted,true);
|
||||
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);
|
||||
|
||||
@@ -67,7 +67,7 @@ ctx.mockApi=async path => path==='/api/decky' ?
|
||||
Promise.reject(Error(`unexpected ${path}`));
|
||||
vm.runInContext('api=mockApi',ctx);
|
||||
await vm.runInContext('refreshDecky()',ctx);
|
||||
assert.equal(el('deckyInstalled').children[0].children[2].disabled,false);
|
||||
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
|
||||
el('deckySearch').value='';el('deckyStoreFilter').value='all';
|
||||
vm.runInContext('showDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
|
||||
@@ -117,3 +117,17 @@ await vm.runInContext('checkDeckyJob()',ctx);
|
||||
assert.match(el('deckyJobStatus').textContent,/app restarted and lost this job result/);
|
||||
assert.equal(el('deckyJobCheck').hidden,true);
|
||||
assert.equal(session.has('frameyPluginJob'),false);
|
||||
|
||||
// Known hardware blockers remain visible, cannot install, and keep their reason in the opened panel.
|
||||
ctx.hardware={id:900,name:'Hardware only',version:'1.0',description:'Fan control',root:false,
|
||||
compatibility:{status:'na',reason:'Not supported on Frame: Deck fan hardware.',verification:['static archive','local browser'],blockInstall:true}};
|
||||
el('deckySearch').value='';el('deckyStoreFilter').value='all';
|
||||
vm.runInContext('deckyStore=[hardware];deckyState={running:true,plugins:[{...hardware,storeId:900}]};showDeckyStore()',ctx);
|
||||
const hardwareRow=el('deckyStoreList').children[0];
|
||||
assert.equal(hardwareRow.children[1].disabled,true);
|
||||
assert.match(hardwareRow.children[0].children[0].textContent,/N\/A: Not supported on Frame/);
|
||||
el('deckyStoreFilter').value='compatible';vm.runInContext('showDeckyStore()',ctx);
|
||||
assert.equal(el('deckyStoreList').children.length,0);
|
||||
vm.runInContext('openFrameyPanel(900,"Hardware only")',ctx);
|
||||
assert.match(el('frameyCompatibility').textContent,/Deck fan hardware/);
|
||||
assert.match(el('frameyCompatibility').textContent,/local browser/);
|
||||
+96
-12
@@ -4,6 +4,11 @@ Runs on your computer through the `frame` SSH alias (or FRAME_ALIAS), with
|
||||
Frame Control's own modules, and records what happened with the headset's
|
||||
BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
|
||||
|
||||
Before any status read or title change, it atomically owns /tmp/frame-test.lock
|
||||
on the Frame and checks SteamVR standby, proximity and battery read-only. A
|
||||
busy lock, awake headset or battery below 20% stops the run. Only this run's
|
||||
lock is released, including after test failures.
|
||||
|
||||
- properties.json from Valve's devkit service names the login user;
|
||||
- status (ui/frame_status.py) reads the build, battery and storage;
|
||||
- three tiny titles (tests/smoke/tiny_programs.py: ARM64 and x86-64 static
|
||||
@@ -28,6 +33,7 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -50,6 +56,60 @@ import stop_processes # noqa: E402
|
||||
|
||||
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
|
||||
PREFIX = 'fc_smoke_'
|
||||
LOCK = '/tmp/frame-test.lock'
|
||||
|
||||
|
||||
def remote(command, *, input=None):
|
||||
"""Use a bounded SSH call for shared-lock operations."""
|
||||
return subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', ALIAS, command],
|
||||
input=input, capture_output=True, text=True, timeout=20)
|
||||
|
||||
|
||||
def acquire_lock(token):
|
||||
"""Atomically reserve the Frame before reading its state or changing titles."""
|
||||
command = (f'mkdir {LOCK} && {{ printf %s {shlex.quote(token)} > {LOCK}/owner || '
|
||||
f'{{ rmdir {LOCK}; exit 1; }}; }}')
|
||||
return remote(command).returncode == 0
|
||||
|
||||
|
||||
def release_lock(token):
|
||||
"""Never remove a lock if its owner changed."""
|
||||
command = (f'test "$(cat {LOCK}/owner)" = {shlex.quote(token)} && '
|
||||
f'rm {LOCK}/owner && rmdir {LOCK}')
|
||||
return remote(command).returncode == 0
|
||||
|
||||
|
||||
PREFLIGHT = r'''
|
||||
import json, pathlib, subprocess
|
||||
r = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd', '--stats'],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
try: activity = json.loads(r.stdout)[0]['activity_level'] if r.returncode == 0 else None
|
||||
except (ValueError, KeyError, IndexError, TypeError): activity = None
|
||||
proximity = []
|
||||
for path in pathlib.Path('/sys/bus/iio/devices').glob('iio:device*/in_proximity_raw'):
|
||||
try: proximity.append(float(path.read_text().strip()))
|
||||
except (OSError, ValueError): pass
|
||||
battery = []
|
||||
for path in pathlib.Path('/sys/class/power_supply').glob('*/capacity'):
|
||||
try:
|
||||
if (path.parent/'type').read_text().strip() == 'Battery':
|
||||
battery.append(int(path.read_text().strip()))
|
||||
except (OSError, ValueError): pass
|
||||
print(json.dumps({'activity': activity, 'proximity': proximity, 'battery': battery}))
|
||||
'''
|
||||
|
||||
|
||||
def preflight():
|
||||
result = remote('python3 -', input=PREFLIGHT)
|
||||
if result.returncode:
|
||||
raise RuntimeError('Locked SteamVR and battery preflight failed: ' + result.stderr.strip())
|
||||
state = json.loads(result.stdout)
|
||||
if state.get('activity') != 3:
|
||||
raise RuntimeError('Frame is awake or SteamVR activity is unknown; active session untouched')
|
||||
battery = state.get('battery')
|
||||
if not battery or min(battery) < 20:
|
||||
raise RuntimeError('Frame battery is unavailable or below the smoke threshold')
|
||||
return state
|
||||
|
||||
|
||||
def stop_titles(ids):
|
||||
@@ -308,17 +368,8 @@ def pair(folder):
|
||||
print(f' removed the smoke key from authorized_keys ({dropped} line(s))', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument('--pair', action='store_true', help='also pair through the devkit service (needs you in the headset)')
|
||||
args = ap.parse_args()
|
||||
|
||||
r = subprocess.run(['ssh', '-o', 'ConnectTimeout=5', '-o', 'BatchMode=yes', ALIAS, 'true'],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(f'{ALIAS} is not reachable over SSH: {r.stderr.strip()}', file=sys.stderr)
|
||||
return 2
|
||||
|
||||
def run_smoke(args, before):
|
||||
"""Run only after the shared lock and read-only preflight succeeded."""
|
||||
smoke = Smoke()
|
||||
started = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
release = os_release()
|
||||
@@ -347,12 +398,45 @@ def main():
|
||||
passed = sum(s['ok'] for s in smoke.steps)
|
||||
RESULTS.mkdir(exist_ok=True)
|
||||
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
|
||||
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'os_release': release, 'paired': args.pair,
|
||||
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'preflight': before,
|
||||
'os_release': release, 'paired': args.pair,
|
||||
'passed': passed, 'failed': len(smoke.steps) - passed,
|
||||
'devkit_utils_there_before': had_utils, 'steps': smoke.steps}, indent=1))
|
||||
print(f'==> {passed}/{len(smoke.steps)} steps passed on build {build}; results in {out}')
|
||||
return 0 if passed == len(smoke.steps) else 1
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument('--pair', action='store_true', help='also pair through the devkit service (needs you in the headset)')
|
||||
args = ap.parse_args()
|
||||
token = 'frame-smoke-' + secrets.token_hex(10)
|
||||
try:
|
||||
locked = acquire_lock(token)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
locked = False
|
||||
if not locked:
|
||||
print('Shared Frame lock busy or unreachable; no headset status read or smoke run.', file=sys.stderr)
|
||||
return 2
|
||||
result = 1
|
||||
try:
|
||||
try:
|
||||
before = preflight()
|
||||
print('Locked read-only preflight: ' + json.dumps(before), flush=True)
|
||||
result = run_smoke(args, before)
|
||||
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as failure:
|
||||
print(str(failure), file=sys.stderr)
|
||||
result = 2
|
||||
finally:
|
||||
try:
|
||||
released = release_lock(token)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
released = False
|
||||
if not released:
|
||||
print('ERROR: could not release owned Frame lock.', file=sys.stderr)
|
||||
result = 1
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -98,14 +98,14 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
|
||||
self.folder.parent.mkdir(parents=True)
|
||||
|
||||
def apply(self, action, data=None):
|
||||
def apply(self, action, data=None, expected='-'):
|
||||
token = secrets.token_hex(12)
|
||||
source = Path('/tmp/frame-control-decky-plugin-' + token)
|
||||
if data is not None:
|
||||
source.write_bytes(data)
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0'],
|
||||
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0', expected],
|
||||
input=frame_decky_store.APPLY, text=True, capture_output=True,
|
||||
env=self.env, timeout=15)
|
||||
finally:
|
||||
@@ -181,6 +181,50 @@ class StoreTransaction(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse(self.folder.exists())
|
||||
|
||||
def test_remote_apply_rechecks_plugin_name_under_transaction_lock(self):
|
||||
data, _ = archive()
|
||||
other = self.folder.parent / 'Other'
|
||||
other.mkdir()
|
||||
(other / 'plugin.json').write_text('{"name":"Example"}')
|
||||
result = self.apply('install', data)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('different plugin with this name', result.stderr)
|
||||
self.assertFalse(self.folder.exists())
|
||||
self.assertEqual((other / 'plugin.json').read_text(), '{"name":"Example"}')
|
||||
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
|
||||
|
||||
(other / 'plugin.json').write_text('{"name":"Distinct"}')
|
||||
self.assertEqual(self.apply('install', data).returncode, 0)
|
||||
(other / 'plugin.json').write_text('{"name":"Example"}')
|
||||
result = self.apply('update', data)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('different plugin with this name', result.stderr)
|
||||
self.assertTrue(self.folder.exists())
|
||||
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
|
||||
|
||||
def test_update_and_remove_reject_stale_installed_receipt(self):
|
||||
first, first_hash = archive()
|
||||
second, _ = archive(extra=('Example/data.txt', 'new'))
|
||||
self.assertEqual(self.apply('install', first).returncode, 0)
|
||||
marker = self.folder / '.frame-control-store.json'
|
||||
self.assertEqual(json.loads(marker.read_text())['sha256'], first_hash)
|
||||
|
||||
# A newer store operation wins before the caller reaches the lock.
|
||||
current = json.loads(marker.read_text())
|
||||
current['sha256'] = 'b' * 64
|
||||
marker.write_text(json.dumps(current))
|
||||
for action, data in [('update', second), ('remove', None)]:
|
||||
with self.subTest(action=action):
|
||||
result = self.apply(action, data, expected=first_hash)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn('Installed plugin changed', result.stderr)
|
||||
self.assertTrue(self.folder.exists())
|
||||
self.assertEqual(json.loads(marker.read_text())['sha256'], 'b' * 64)
|
||||
self.assertEqual(list(self.folder.parent.glob('.frame-control-stage-*')), [])
|
||||
self.assertEqual(list(self.folder.parent.glob('.frame-control-old-*')), [])
|
||||
self.assertEqual(self.apply('remove', expected='b' * 64).returncode, 0)
|
||||
self.assertFalse(self.folder.exists())
|
||||
|
||||
|
||||
class StoreHost(unittest.TestCase):
|
||||
def test_reviewed_archive_changed_before_install_does_not_download(self):
|
||||
@@ -197,7 +241,8 @@ class StoreHost(unittest.TestCase):
|
||||
|
||||
def test_stopped_managed_service_allows_removing_owned_plugin(self):
|
||||
state = {'managed': True, 'running': False, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0',
|
||||
'sha256': 'a' * 64}]}
|
||||
calls = []
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog') as network:
|
||||
@@ -205,6 +250,7 @@ class StoreHost(unittest.TestCase):
|
||||
'remove', 42)
|
||||
self.assertEqual(result, {})
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertTrue(calls[0].endswith(' ' + 'a' * 64))
|
||||
network.assert_not_called()
|
||||
|
||||
def test_renamed_store_plugin_cannot_be_installed_twice(self):
|
||||
@@ -219,6 +265,38 @@ class StoreHost(unittest.TestCase):
|
||||
'install', 42)
|
||||
network.assert_not_called()
|
||||
|
||||
def test_update_rejects_name_taken_by_another_plugin_before_download(self):
|
||||
state = {'managed': True, 'running': True, 'plugins': [
|
||||
{'storeId': 42, 'folder': 'Example', 'name': 'Old Name', 'version': '1.0'},
|
||||
{'storeId': 43, 'folder': 'Other', 'name': 'Example', 'version': '1.0'}]}
|
||||
catalog = [{'id': 42, 'name': 'Example', 'root': False,
|
||||
'sha256': 'a' * 64, 'version': '2.0'}]
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
|
||||
patch.object(frame_decky_store.urllib.request, 'urlopen',
|
||||
side_effect=AssertionError('No download expected')):
|
||||
with self.assertRaisesRegex(ValueError, 'different plugin with this name'):
|
||||
frame_decky_store.change(lambda *args, **kwargs: self.fail('No headset write expected'),
|
||||
'update', 42)
|
||||
|
||||
def test_update_allows_its_own_existing_name(self):
|
||||
data, digest = archive()
|
||||
state = {'managed': True, 'running': True, 'plugins': [
|
||||
{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0',
|
||||
'sha256': 'a' * 64}]}
|
||||
catalog = [{'id': 42, 'name': 'Example', 'root': False,
|
||||
'sha256': digest, 'version': '2.0'}]
|
||||
calls = []
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog', return_value=catalog), \
|
||||
patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(data)):
|
||||
result = frame_decky_store.change(lambda command, **kwargs: calls.append(command) or '{}',
|
||||
'update', 42)
|
||||
self.assertEqual(result, {})
|
||||
self.assertEqual(len(calls), 3) # Upload, apply, and temporary archive cleanup.
|
||||
self.assertIn('update', calls[1])
|
||||
self.assertTrue(calls[1].endswith(' ' + 'a' * 64))
|
||||
|
||||
def test_owned_plugin_can_be_removed_without_store_network(self):
|
||||
state = {'managed': True, 'running': True, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""A compatibility claim must never transfer to a different published archive."""
|
||||
import sandbox # noqa: F401
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'ui'))
|
||||
import framey_compatibility as compatibility
|
||||
import frame_decky_store
|
||||
import frame_decky
|
||||
|
||||
|
||||
class Compatibility(unittest.TestCase):
|
||||
def test_table_matches_store_data_and_complete_browser_and_fake_evidence(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
subprocess.run([sys.executable, str(root/'scripts/framey-compatibility-doc.py'), '--check'], check=True)
|
||||
records = json.loads(compatibility.DATA.read_text())['plugins']
|
||||
self.assertEqual(len({p['id'] for p in records}), len(records))
|
||||
for record in records:
|
||||
self.assertEqual(record['browser']['id'], record['id'])
|
||||
self.assertEqual(record['fakeFrame']['sha256'], record['sha256'])
|
||||
self.assertIn(record['status'], ('works', 'partial', 'broken', 'na'))
|
||||
|
||||
def test_claims_require_exact_id_version_and_digest(self):
|
||||
records = json.loads(compatibility.DATA.read_text())['plugins']
|
||||
for plugin in records:
|
||||
with self.subTest(plugin=plugin['name']):
|
||||
expected = compatibility.lookup(plugin['id'], plugin['sha256'], plugin['version'])
|
||||
self.assertEqual(expected['status'], plugin['status'])
|
||||
for plugin_id, digest, version in (
|
||||
(plugin['id'], '0'*64, plugin['version']),
|
||||
(plugin['id'], plugin['sha256'], plugin['version']+'-new'),
|
||||
(99999, plugin['sha256'], plugin['version']),
|
||||
):
|
||||
result = compatibility.lookup(plugin_id, digest, version)
|
||||
self.assertEqual(result['status'], 'unverified')
|
||||
self.assertFalse(result['blockInstall'])
|
||||
|
||||
def test_installed_receipt_uses_its_digest_not_current_store_version(self):
|
||||
record = json.loads(compatibility.DATA.read_text())['plugins'][0]
|
||||
installed = {'plugins': [{'storeId': record['id'], 'version': record['version'],
|
||||
'sha256': record['sha256']},
|
||||
{'storeId': record['id'], 'version': record['version'], 'sha256': '0'*64}]}
|
||||
result = frame_decky.status(lambda *a, **k: json.dumps(installed))
|
||||
self.assertEqual(result['plugins'][0]['compatibility']['status'], record['status'])
|
||||
self.assertEqual(result['plugins'][1]['compatibility']['status'], 'unverified')
|
||||
|
||||
def test_hardware_block_precedes_any_download_or_ssh_mutation(self):
|
||||
with patch.object(frame_decky, 'status', return_value={'managed': True, 'running': True, 'plugins': []}), \
|
||||
patch.object(frame_decky_store, 'catalog', return_value=[{'id': 42, 'name': 'Hardware only',
|
||||
'sha256': 'a'*64, 'root': False, 'compatibility': {'blockInstall': True, 'reason': 'Not supported on Frame'}}]), \
|
||||
patch.object(frame_decky_store.urllib.request, 'urlopen') as download:
|
||||
def no_ssh(*a, **k):
|
||||
self.fail('must not mutate headset for known hardware blocker')
|
||||
with self.assertRaisesRegex(ValueError, 'Not supported on Frame'):
|
||||
frame_decky_store.change(no_ssh, 'install', 42)
|
||||
download.assert_not_called()
|
||||
|
||||
def test_missing_audit_data_is_unverified(self):
|
||||
with patch.object(compatibility, 'DATA', Path('/nonexistent/framey-audit.json')):
|
||||
self.assertEqual(compatibility.lookup(12, 'a'*64, '1.0')['status'], 'unverified')
|
||||
+130
-4
@@ -20,6 +20,126 @@ import server
|
||||
|
||||
|
||||
class Panel(unittest.TestCase):
|
||||
def test_steam_reads_are_allowlisted_and_installed_only(self):
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck'}]}):
|
||||
for method, expression in framey_panel.STEAM_READS.items():
|
||||
def fake_ssh(command, stdin, **_):
|
||||
self.assertEqual(command, 'python3 -')
|
||||
compile(stdin, '<Steam read>', 'exec')
|
||||
self.assertIn(expression.replace('\n', r'\n'), stdin)
|
||||
self.assertNotIn('__FRAMEY_STEAM_READ__', stdin)
|
||||
return '{"result":"actual read"}'
|
||||
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method)['result'], 'actual read')
|
||||
for plugin_id, method in [(True,'GetIPCountry'), (0,'GetIPCountry'),
|
||||
(82,'GetIPCountry'), (81,'ShutdownPC'), (81,'User.GetIPCountry'),
|
||||
(81,[]), (81,'GetIPCountry();alert(1)')]:
|
||||
with self.subTest(plugin_id=plugin_id, method=method):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method)
|
||||
|
||||
def test_steam_read_response_limit_and_socket_cleanup(self):
|
||||
from types import SimpleNamespace
|
||||
for value, oversized in [('AU',False), ('x'*(256*1024),True)]:
|
||||
closed=[]
|
||||
page=SimpleNamespace(eval=lambda _:value,sock=SimpleNamespace(close=lambda:closed.append(True)))
|
||||
fake_module=SimpleNamespace(Page=lambda:page)
|
||||
script=framey_panel.STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__',repr('safe()')).replace(
|
||||
'__FRAMEY_STEAM_LIMIT__',str(256*1024))
|
||||
with patch.dict(sys.modules, {'frame_steam':fake_module}), patch('builtins.print') as output:
|
||||
if oversized:
|
||||
with self.assertRaisesRegex(ValueError,'too large'):exec(script,{})
|
||||
else:
|
||||
exec(script,{})
|
||||
self.assertEqual(json.loads(output.call_args.args[0]),{'result':'AU'})
|
||||
self.assertEqual(closed,[True])
|
||||
|
||||
def test_library_snapshot_copies_only_bounded_scalar_fields(self):
|
||||
expression = framey_panel.STEAM_READS['GetLibrarySnapshot']
|
||||
script = '''const assert=require('node:assert/strict');
|
||||
const app={appid:10,display_name:'Game',sort_as:'game',app_type:1,gameid:'10',
|
||||
minutes_playtime_forever:60,minutes_playtime_last_two_weeks:20,rt_last_time_played:123,
|
||||
owner_account_id:'PRIVATE',per_client_data:{path:'PRIVATE'}};
|
||||
global.window=global;global.appStore={allApps:[app]};
|
||||
global.App={m_CurrentUser:{strSteamID:'76561198000000001',strAccountBalance:'PRIVATE'}};
|
||||
global.collectionStore={allGamesCollection:{allApps:[app]}};
|
||||
const value=eval(EXPRESSION);
|
||||
assert.deepEqual(value,{apps:[[10,'Game','game',1,'10',60,20,123]],gameIds:[10],steamId:'76561198000000001'});
|
||||
appStore.allApps=Array(10001).fill(app);
|
||||
assert.throws(()=>eval(EXPRESSION),/snapshot limit/);
|
||||
appStore=undefined;
|
||||
assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
|
||||
'''.replace('EXPRESSION',json.dumps(expression))
|
||||
result=subprocess.run(['node','-e',script],capture_output=True,text=True,timeout=10)
|
||||
self.assertEqual(result.returncode,0,result.stderr)
|
||||
|
||||
def test_library_sized_plugin_arguments_remain_bounded(self):
|
||||
# Shotty sends appid/name pairs at startup; a normal library exceeds 8K.
|
||||
args=[{'allapps':[[id,'Game '+str(id)] for id in range(7000)]}]
|
||||
self.assertGreater(len(json.dumps(args)),128*1024)
|
||||
def fake_ssh(command, stdin, **_):
|
||||
import ast
|
||||
self.assertEqual(command,'python3 -')
|
||||
compile(stdin,'<large method>','exec')
|
||||
assignment=next(n for n in ast.parse(stdin).body if isinstance(n,ast.Assign) and
|
||||
any(isinstance(t,ast.Name) and t.id=='payload' for t in n.targets))
|
||||
self.assertEqual(json.loads(ast.literal_eval(assignment.value.args[0]))['args'],args)
|
||||
return '{"result":null}'
|
||||
with patch.object(framey_panel.frame_decky,'status',return_value={
|
||||
'plugins':[{'storeId':53,'name':'Shotty'}]}):
|
||||
self.assertEqual(framey_panel.call_method(fake_ssh,
|
||||
53,'set_id_map_fronend',args,True),{'result':None})
|
||||
with self.assertRaisesRegex(framey_panel.PanelError,'too large'):
|
||||
framey_panel.call_method(lambda *a,**k:self.fail('Unexpected SSH'),
|
||||
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True)
|
||||
|
||||
def test_steam_reads_have_the_headset_service_boundary(self):
|
||||
with patch.object(server,'HEADSET_RUNTIME',False), patch.dict(server.os.environ,{'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):server.framey_steam_action({'id':81,'method':'GetIPCountry'})
|
||||
with patch.object(server,'HEADSET_RUNTIME',True), patch.object(framey_panel,'read_steam',return_value={'result':'AU'}) as read:
|
||||
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry'}),{'result':'AU'})
|
||||
read.assert_called_once_with(server.ssh,81,'GetIPCountry')
|
||||
|
||||
def test_decky_event_queue_scope_bounds_expiry_and_reconnect(self):
|
||||
for name in ('framey_events.mjs', 'framey_event_parent.mjs'):
|
||||
with self.subTest(script=name):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page'/name)],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_events_validate_before_ssh_and_resolve_name_from_store_receipt(self):
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
import shlex
|
||||
payload = json.loads(shlex.split(command)[2])
|
||||
self.assertEqual(payload, {'name':'Simple Timer', 'session':'test-123.45',
|
||||
'events':['timer:tick'], 'cursor':5})
|
||||
compile(stdin, '<event poll>', 'exec')
|
||||
self.assertIn('loader/plugin_event', stdin)
|
||||
return json.dumps({'events':[{'event':'timer:tick', 'args':[42]}], 'cursor':6})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':96,'name':'Simple Timer'}]}):
|
||||
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5)['cursor'], 6)
|
||||
for plugin_id, session, events, cursor in [
|
||||
(True,'test',['tick'],0), (96,'bad session',['tick'],0),
|
||||
(96,'test',['a']*33,0), (96,'test',['bad event'],0),
|
||||
(96,'test',[None],0), (96,'test',['tick'],-1),
|
||||
(96,'test',['tick'],True), (96,'test',['tick'],2**53),
|
||||
(19,'test',['tick'],0),
|
||||
]:
|
||||
with self.subTest(plugin_id=plugin_id, session=session, events=events, cursor=cursor):
|
||||
with self.assertRaises(framey_panel.PanelError):
|
||||
framey_panel.poll_events(lambda *a, **k:self.fail('Unexpected SSH'),
|
||||
plugin_id, session, events, cursor)
|
||||
|
||||
def test_event_action_has_same_headset_boundary_as_methods(self):
|
||||
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
|
||||
with self.assertRaises(server.Failure):
|
||||
server.framey_events_action({'id':96,'session':'test','events':['tick']})
|
||||
with patch.object(server, 'HEADSET_RUNTIME', True), \
|
||||
patch.object(framey_panel, 'poll_events', return_value={'events':[], 'cursor':0}) as poll:
|
||||
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick']})['events'], [])
|
||||
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0)
|
||||
|
||||
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
|
||||
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
@@ -70,7 +190,11 @@ class Panel(unittest.TestCase):
|
||||
def test_legacy_keyword_rpc_is_scoped_and_validated(self):
|
||||
import shlex
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
payload = json.loads(shlex.split(command)[2])
|
||||
import ast
|
||||
self.assertEqual(command,'python3 -')
|
||||
assignment=next(node for node in ast.parse(stdin).body if isinstance(node,ast.Assign) and
|
||||
any(isinstance(target,ast.Name) and target.id=='payload' for target in node.targets))
|
||||
payload=json.loads(ast.literal_eval(assignment.value.args[0]))
|
||||
self.assertEqual(payload, {'name':'Legacy', 'method':'read_setting',
|
||||
'args':[{'key':'mode'}], 'legacy':True})
|
||||
self.assertIn('loader/call_legacy_plugin_method', stdin)
|
||||
@@ -106,8 +230,9 @@ class Panel(unittest.TestCase):
|
||||
|
||||
def test_backend_method_is_scoped_to_store_receipt(self):
|
||||
def fake_ssh(command, stdin=None, **_):
|
||||
self.assertIn('python3 - ', command)
|
||||
self.assertEqual(stdin, framey_panel.METHOD_SCRIPT)
|
||||
self.assertEqual(command, 'python3 -')
|
||||
compile(stdin, '<remote method>', 'exec')
|
||||
self.assertNotIn('__FRAMEY_METHOD_PAYLOAD__',stdin)
|
||||
return json.dumps({'result':{'theme':'dark'}})
|
||||
with patch.object(framey_panel.frame_decky, 'status', return_value={
|
||||
'plugins':[{'storeId':14,'name':'ProtonDB Badges'}]}):
|
||||
@@ -115,7 +240,7 @@ class Panel(unittest.TestCase):
|
||||
{'result':{'theme':'dark'}})
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[])
|
||||
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[])
|
||||
compile(framey_panel.METHOD_SCRIPT, '<remote method>', 'exec')
|
||||
compile(framey_panel.METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__',repr('{}')), '<remote method>', 'exec')
|
||||
|
||||
def test_headset_bundle_contains_runtime_and_licenses(self):
|
||||
packed = frame_headset.bundle()
|
||||
@@ -124,6 +249,7 @@ class Panel(unittest.TestCase):
|
||||
with tarfile.open(fileobj=io.BytesIO(gzip.decompress(packed))) as archive:
|
||||
names = set(archive.getnames())
|
||||
self.assertTrue({'ui/framey_panel.py','ui/framey-host.js',
|
||||
'ui/framey-events.js',
|
||||
'ui/vendor/react-18.3.1.min.js','ui/vendor/react-dom-18.3.1.min.js',
|
||||
'ui/vendor/REACT-LICENSE.txt','ui/vendor/REACT-DOM-LICENSE.txt'} <= names)
|
||||
|
||||
|
||||
@@ -15,6 +15,24 @@ spec.loader.exec_module(check)
|
||||
|
||||
|
||||
class Safety(unittest.TestCase):
|
||||
def test_overlay_wait_is_bounded_and_stops_when_visible(self):
|
||||
from unittest.mock import Mock
|
||||
remote = Mock()
|
||||
remote.python.side_effect = [{'state':{'visible':False}},
|
||||
{'state':{'visible':False}},
|
||||
{'state':{'visible':True}}]
|
||||
with patch.object(check.time, 'sleep') as sleep:
|
||||
self.assertTrue(check.wait_overlay(remote)['state']['visible'])
|
||||
self.assertEqual(remote.python.call_count, 3)
|
||||
self.assertEqual(sleep.call_count, 2)
|
||||
|
||||
remote.python.reset_mock(side_effect=True)
|
||||
remote.python.return_value = {'state':{'visible':False}}
|
||||
with patch.object(check.time, 'sleep') as sleep:
|
||||
self.assertFalse(check.wait_overlay(remote, attempts=3)['state']['visible'])
|
||||
self.assertEqual(remote.python.call_count, 3)
|
||||
self.assertEqual(sleep.call_count, 2)
|
||||
|
||||
def test_foreign_lock_prevents_sensor_reads_and_cleanup(self):
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory:
|
||||
remote = factory.return_value
|
||||
@@ -42,7 +60,8 @@ class Safety(unittest.TestCase):
|
||||
|
||||
def test_power_restore_after_partial_setter_failure(self):
|
||||
state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true',
|
||||
'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False}
|
||||
'timeout':'power.turnOffScreensTimeout=17.000000','dashboardVisible':False,
|
||||
'stats':[{'activity_level':3}], 'proximity':[6.25]}
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(check, 'Remote') as factory:
|
||||
remote = factory.return_value
|
||||
remote.python.return_value = state
|
||||
@@ -58,6 +77,28 @@ class Safety(unittest.TestCase):
|
||||
self.assertTrue(any('--set-settings-float power.turnOffScreensTimeout 17.000000' in c for c in commands))
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', commands[-1])
|
||||
|
||||
def test_awake_or_unknown_activity_refuses_wake_and_install(self):
|
||||
for stats in ([{'activity_level':1}], [{'activity_level':2}], [], None):
|
||||
state = {'battery':[99], 'pause':'power.pauseCompositorOnStandby=true',
|
||||
'timeout':'power.turnOffScreensTimeout=17.000000',
|
||||
'dashboardVisible':False, 'stats':stats, 'proximity':[0.0]}
|
||||
for mode in ('--wake', '--exercise'):
|
||||
with self.subTest(stats=stats, mode=mode), \
|
||||
tempfile.TemporaryDirectory() as folder, \
|
||||
patch.object(check, 'Remote') as factory, \
|
||||
patch.object(check.frame_decky, 'change') as install:
|
||||
remote = factory.return_value
|
||||
remote.python.return_value = state
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(check.main([mode,'--output',folder]), 1)
|
||||
install.assert_not_called()
|
||||
commands = [c.args[0] for c in remote.call_args_list]
|
||||
self.assertEqual(len(commands), 2)
|
||||
self.assertIn('mkdir /tmp/frame-test.lock', commands[0])
|
||||
self.assertIn('rmdir /tmp/frame-test.lock', commands[-1])
|
||||
report = json.loads((Path(folder)/'result.json').read_text())
|
||||
self.assertIn('active session untouched', report['error'])
|
||||
|
||||
def test_exact_linux_event_abi_and_debug_profile(self):
|
||||
import ctypes
|
||||
overlay_spec = importlib.util.spec_from_file_location('framey_openvr', PATH.with_name('framey_openvr.py'))
|
||||
|
||||
@@ -17,7 +17,8 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "publish-release.sh"
|
||||
FAKEGH = ROOT / "tests" / "fakegh"
|
||||
INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip", "Frame-Control-Setup-x64.exe",
|
||||
INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip",
|
||||
"Frame-Control-mac-x64.dmg", "Frame-Control-mac-x64.zip", "Frame-Control-Setup-x64.exe",
|
||||
"Frame-Control-win-x64.zip", "Frame-Control-linux-x86_64.AppImage",
|
||||
"Frame-Control-linux-arm64.AppImage", "Frame-Control-linux-amd64.deb",
|
||||
"Frame-Control-linux-arm64.deb"]
|
||||
|
||||
@@ -17,7 +17,7 @@ class Checksums(unittest.TestCase):
|
||||
for i, name in enumerate(module.INSTALLERS):
|
||||
assets.append({"name": name, "digest": "sha256:" + hashlib.sha256(str(i).encode()).hexdigest()})
|
||||
actual = module.checksums({"assets": assets})
|
||||
self.assertEqual(len(actual.splitlines()), 8)
|
||||
self.assertEqual(len(actual.splitlines()), 10)
|
||||
self.assertEqual(actual.splitlines()[0], hashlib.sha256(b"0").hexdigest() + " " + module.INSTALLERS[0])
|
||||
|
||||
def test_missing_duplicate_and_bad_digest_fail(self):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Smoke cleanup against real local Linux processes, never a headset or SSH."""
|
||||
import sandbox # noqa: F401
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -139,11 +140,61 @@ class Safety(unittest.TestCase):
|
||||
mock.patch.object(frame_smoke, 'cleanup', side_effect=RuntimeError('cannot stop')), \
|
||||
mock.patch.object(frame_smoke, 'title_cycle') as cycle, \
|
||||
mock.patch('builtins.print'):
|
||||
self.assertEqual(frame_smoke.main(), 1)
|
||||
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
|
||||
{'activity':3,'proximity':[],'battery':[100]}), 1)
|
||||
cycle.assert_not_called()
|
||||
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
|
||||
|
||||
|
||||
class SharedSmokeLock(unittest.TestCase):
|
||||
def test_busy_lock_prevents_any_status_read_or_title_change(self):
|
||||
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
|
||||
mock.patch.object(frame_smoke, 'acquire_lock', return_value=False), \
|
||||
mock.patch.object(frame_smoke, 'preflight') as preflight, \
|
||||
mock.patch.object(frame_smoke, 'run_smoke') as run, \
|
||||
mock.patch.object(frame_smoke, 'release_lock') as release:
|
||||
self.assertEqual(frame_smoke.main(), 2)
|
||||
preflight.assert_not_called()
|
||||
run.assert_not_called()
|
||||
release.assert_not_called()
|
||||
|
||||
def test_preflight_requires_standby_and_sufficient_battery(self):
|
||||
for state in ({'activity':1,'proximity':[1],'battery':[100]},
|
||||
{'activity':None,'proximity':[],'battery':[100]},
|
||||
{'activity':3,'proximity':[],'battery':[19]},
|
||||
{'activity':3,'proximity':[],'battery':[]}):
|
||||
with self.subTest(state=state), mock.patch.object(frame_smoke, 'remote',
|
||||
return_value=mock.Mock(returncode=0, stdout=json.dumps(state))):
|
||||
with self.assertRaises(RuntimeError):
|
||||
frame_smoke.preflight()
|
||||
with mock.patch.object(frame_smoke, 'remote', return_value=mock.Mock(
|
||||
returncode=0, stdout=json.dumps({'activity':3,'proximity':[0],'battery':[99]}))):
|
||||
self.assertEqual(frame_smoke.preflight()['battery'], [99])
|
||||
|
||||
def test_owner_checked_on_release_and_preflight_failure_still_releases(self):
|
||||
commands = []
|
||||
def remote(command, **_):
|
||||
commands.append(command)
|
||||
return mock.Mock(returncode=0)
|
||||
with mock.patch.object(frame_smoke, 'remote', side_effect=remote):
|
||||
self.assertTrue(frame_smoke.acquire_lock('frame-smoke-test-token'))
|
||||
self.assertTrue(frame_smoke.release_lock('frame-smoke-test-token'))
|
||||
self.assertIn('mkdir /tmp/frame-test.lock', commands[0])
|
||||
self.assertIn('> /tmp/frame-test.lock/owner', commands[0])
|
||||
self.assertIn('test "$(cat /tmp/frame-test.lock/owner)" = frame-smoke-test-token', commands[1])
|
||||
self.assertLess(commands[1].index('test '), commands[1].index('rm /tmp/frame-test.lock/owner'))
|
||||
|
||||
with mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
|
||||
mock.patch.object(frame_smoke, 'acquire_lock', return_value=True), \
|
||||
mock.patch.object(frame_smoke, 'preflight', side_effect=RuntimeError('awake')), \
|
||||
mock.patch.object(frame_smoke, 'run_smoke') as run, \
|
||||
mock.patch.object(frame_smoke, 'release_lock', return_value=True) as release:
|
||||
self.assertEqual(frame_smoke.main(), 2)
|
||||
run.assert_not_called()
|
||||
release.assert_called_once()
|
||||
self.assertTrue(release.call_args.args[0].startswith('frame-smoke-'))
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'posix', 'local POSIX shell required')
|
||||
class OrphanCleanup(unittest.TestCase):
|
||||
"""Run the real cleanup shell commands in a throwaway home; mock Steam/SSH."""
|
||||
|
||||
+8
-2
@@ -4,6 +4,7 @@ import json
|
||||
import re
|
||||
import secrets
|
||||
import urllib.request
|
||||
import framey_compatibility
|
||||
|
||||
|
||||
REPO = "SteamDeckHomebrew/decky-loader"
|
||||
@@ -38,7 +39,8 @@ def plugins(folder):
|
||||
found.append({'folder': child.name, 'name': str(data.get('name', child.name))[:80],
|
||||
'author': str(data.get('author', ''))[:80],
|
||||
'storeId': owned.get('storeId') if isinstance(owned.get('storeId'), int) else None,
|
||||
'version': str(owned.get('version', ''))[:40]})
|
||||
'version': str(owned.get('version', ''))[:40],
|
||||
'sha256': str(owned.get('sha256', ''))[:64]})
|
||||
except (ValueError, OSError, UnicodeError): pass
|
||||
return found
|
||||
os_release = {}
|
||||
@@ -154,7 +156,11 @@ finally:
|
||||
|
||||
|
||||
def status(ssh):
|
||||
return json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
|
||||
result = json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
|
||||
for plugin in result.get('plugins', []):
|
||||
plugin['compatibility'] = framey_compatibility.lookup(
|
||||
plugin.get('storeId'), plugin.get('sha256'), plugin.get('version'))
|
||||
return result
|
||||
|
||||
|
||||
def release_binary():
|
||||
|
||||
+34
-6
@@ -13,6 +13,7 @@ import urllib.request
|
||||
import zipfile
|
||||
|
||||
import frame_decky
|
||||
import framey_compatibility
|
||||
|
||||
|
||||
STORE_URL = 'https://plugins.deckbrew.xyz/plugins'
|
||||
@@ -65,7 +66,10 @@ def catalog():
|
||||
counts = {}
|
||||
for item in result:
|
||||
counts[item['id']] = counts.get(item['id'], 0) + 1
|
||||
return [item for item in result if counts[item['id']] == 1]
|
||||
unique = [item for item in result if counts[item['id']] == 1]
|
||||
for item in unique:
|
||||
item['compatibility'] = framey_compatibility.lookup(item['id'], item['sha256'], item['version'])
|
||||
return unique
|
||||
|
||||
|
||||
def archive_info(data, expected_hash, expected_name):
|
||||
@@ -119,7 +123,7 @@ def archive_info(data, expected_hash, expected_name):
|
||||
|
||||
APPLY = r'''
|
||||
import fcntl,hashlib,json,os,pathlib,shutil,subprocess,sys,zipfile
|
||||
action,token,plugin_id,digest,folder,version = sys.argv[1:]
|
||||
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'
|
||||
target=base/folder
|
||||
@@ -147,6 +151,10 @@ try:
|
||||
if action not in ('install','update','remove'): raise RuntimeError('Invalid plugin action')
|
||||
if action=='install' and (target.exists() or target.is_symlink()): raise RuntimeError('Plugin folder already exists')
|
||||
if action in ('update','remove') and not owned(): raise RuntimeError('Only a Frame Control store plugin can be changed')
|
||||
if action in ('update','remove') and expected_installed!='-':
|
||||
current=json.loads((target/marker).read_text()).get('sha256')
|
||||
if current!=expected_installed:
|
||||
raise RuntimeError('Installed plugin changed; refresh the installed list and try again')
|
||||
running=subprocess.run(['systemctl','--user','is-active','--quiet','frame-control-decky.service']).returncode==0
|
||||
if action!='remove':
|
||||
data=archive.read_bytes()
|
||||
@@ -171,6 +179,19 @@ try:
|
||||
z.extractall(stage)
|
||||
unpacked=stage/folder
|
||||
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
|
||||
# Recheck under the transaction lock. Another store operation may
|
||||
# have installed a colliding name while this archive downloaded.
|
||||
proposed_name=json.loads((unpacked/'plugin.json').read_text()).get('name')
|
||||
for peer in base.iterdir():
|
||||
if peer==target or peer.is_symlink() or not peer.is_dir(): continue
|
||||
manifest=peer/'plugin.json'
|
||||
if manifest.is_symlink() or not manifest.is_file(): continue
|
||||
try:
|
||||
if manifest.stat().st_size>16384: continue
|
||||
peer_data=json.loads(manifest.read_text())
|
||||
if isinstance(peer_data,dict) and peer_data.get('name')==proposed_name:
|
||||
raise RuntimeError('A different plugin with this name is already installed')
|
||||
except (OSError,ValueError,UnicodeError): continue
|
||||
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
|
||||
# Stop even a failed unit before removal to cancel any pending auto-restart.
|
||||
if running or action=='remove': service('stop','frame-control-decky.service')
|
||||
@@ -222,9 +243,14 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
|
||||
raise ValueError('The official store version changed; refresh the store and review it again')
|
||||
if plugin['root']:
|
||||
raise ValueError('Root plugins are not supported by the Frame user service')
|
||||
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']]
|
||||
if action == 'install' and existing_name:
|
||||
raise ValueError('A plugin with this name is already installed')
|
||||
if plugin.get('compatibility', {}).get('blockInstall'):
|
||||
raise ValueError(plugin['compatibility']['reason'])
|
||||
# Decky routes backend calls by plugin name. A store update may rename
|
||||
# its plugin, so check names for updates as well as fresh installs.
|
||||
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']
|
||||
and (action == 'install' or p.get('storeId') != plugin_id)]
|
||||
if existing_name:
|
||||
raise ValueError('A different plugin with this name is already installed')
|
||||
if action == 'update' and len(installed) != 1:
|
||||
raise ValueError('Only an installed Frame Control store plugin can be updated')
|
||||
data = None
|
||||
@@ -239,6 +265,8 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
|
||||
raise ValueError('The store changed its plugin folder; remove the old version before installing')
|
||||
if not SAFE_FOLDER.fullmatch(folder):
|
||||
raise ValueError('Unsafe installed plugin folder')
|
||||
previous = installed[0].get('sha256') if action != 'install' else None
|
||||
expected_installed = previous if isinstance(previous, str) and SHA.fullmatch(previous) else '-'
|
||||
token = secrets.token_hex(12)
|
||||
remote = '/tmp/frame-control-decky-plugin-' + token
|
||||
try:
|
||||
@@ -246,7 +274,7 @@ def change(ssh, action, plugin_id, report=lambda stage, percent=None: None, revi
|
||||
report('Sending archive to Frame', 65)
|
||||
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"]}',
|
||||
return json.loads(ssh(f'python3 - {action} {token} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]} {expected_installed}',
|
||||
stdin=APPLY, timeout=90))
|
||||
finally:
|
||||
if data is not None:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
/* Executed in Steam CEF, using Decky 3.3.0's loader/plugin_event envelope. */
|
||||
(function(payload) {
|
||||
const backend = window.DeckyBackend;
|
||||
if (!backend?.addEventListener || !backend?.removeEventListener)
|
||||
throw Error('Decky backend events are unavailable');
|
||||
let bridge = window.__frameyEventBridgeV1;
|
||||
if (bridge && bridge.backend !== backend) {
|
||||
for (const key of [...bridge.sessions.keys()]) bridge.remove(key);
|
||||
bridge = null;
|
||||
}
|
||||
if (!bridge) {
|
||||
const sessions = new Map();
|
||||
const remove = key => {
|
||||
const item = sessions.get(key);
|
||||
if (item) backend.removeEventListener('loader/plugin_event', item.listener);
|
||||
sessions.delete(key);
|
||||
if (!sessions.size) {clearInterval(bridge.timer); bridge.timer = null;}
|
||||
};
|
||||
bridge = window.__frameyEventBridgeV1 = {backend, sessions, remove, timer:null};
|
||||
}
|
||||
const key = payload.session;
|
||||
let item = bridge.sessions.get(key);
|
||||
if (item && item.name !== payload.name) throw Error('Framey event session belongs to another plugin');
|
||||
if (!payload.events.length) {
|
||||
bridge.remove(key);
|
||||
return {events:[], cursor:0};
|
||||
}
|
||||
if (!item) {
|
||||
if (bridge.sessions.size >= 4) throw Error('Too many Framey event sessions');
|
||||
item = {name:payload.name, names:new Set(), queue:[], sequence:0, touched:Date.now()};
|
||||
item.listener = data => {
|
||||
if (data?.plugin !== item.name || !item.names.has(data.event) || !Array.isArray(data.args)) return;
|
||||
try {
|
||||
const encoded = JSON.stringify(data.args);
|
||||
if (encoded.length > 8192) return;
|
||||
item.queue.push({sequence:++item.sequence, event:data.event, args:JSON.parse(encoded)});
|
||||
// Bound both event count and the CDP response, even for a noisy plugin.
|
||||
while (item.queue.length > 64 || JSON.stringify(item.queue).length > 48*1024) item.queue.shift();
|
||||
} catch (_) {} // Non-JSON event data cannot cross the iframe bridge.
|
||||
};
|
||||
bridge.sessions.set(key, item);
|
||||
backend.addEventListener('loader/plugin_event', item.listener);
|
||||
if (!bridge.timer) bridge.timer = setInterval(() => {
|
||||
for (const [session, state] of bridge.sessions)
|
||||
if (Date.now() - state.touched > 15000) bridge.remove(session);
|
||||
}, 5000);
|
||||
}
|
||||
item.names = new Set(payload.events);
|
||||
item.touched = Date.now();
|
||||
return {events:item.queue.filter(event => event.sequence > payload.cursor && item.names.has(event.event)),
|
||||
cursor:item.sequence};
|
||||
})
|
||||
+136
-12
@@ -11,6 +11,11 @@
|
||||
: R.createElement(type, elementProps);
|
||||
};
|
||||
window.SP_JSX = {jsx, jsxs:jsx, Fragment:R.Fragment};
|
||||
// Let plugins use optional chaining for Steam-only globals without inventing
|
||||
// user identity or suspend state in this isolated document.
|
||||
for (const name of ['App','appStore','appInfoStore','collectionStore']) window[name] = undefined;
|
||||
window.SuspendResumeStore = Object.freeze({});
|
||||
window.SteamUIStore = Object.freeze({RunningApps:Object.freeze([]), MainRunningApp:undefined});
|
||||
// Steam normally fills these token maps. Official plugins can safely use
|
||||
// their own fallback strings when the isolated panel has no Steam tokens.
|
||||
window.LocalizationManager = {m_rgLocalesToUse:[navigator.language || 'en'],
|
||||
@@ -29,6 +34,9 @@
|
||||
let currentParams = {};
|
||||
let nextId = 0;
|
||||
let plugin;
|
||||
const eventListeners = new Map();
|
||||
let eventCursor = 0, eventRevision = 0, subscribedRevision = -1;
|
||||
let eventRequest = null, eventTimer = null, eventClosed = false;
|
||||
// A sandboxed iframe has an opaque origin, so its native localStorage getter
|
||||
// throws SecurityError. Keep the familiar synchronous API inside the iframe;
|
||||
// the parent stores only this plugin's bounded snapshot under its own origin.
|
||||
@@ -63,15 +71,66 @@
|
||||
parent.postMessage({type:'framey:cache:init', session:cacheSession}, '*');
|
||||
setTimeout(() => {acceptSeed?.(); acceptSeed = null;}, 1000); // Open if storage is unavailable.
|
||||
|
||||
function ask(method, args, legacy=false) {
|
||||
function request(method, args, legacy=false, type='framey:call') {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(method)) return Promise.reject(Error('Invalid backend method'));
|
||||
const id = ++nextId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { pending.delete(id); reject(Error('Decky backend timed out')); }, 30000);
|
||||
pending.set(id, {resolve, reject, timer});
|
||||
parent.postMessage({type:'framey:call', id, method, args, legacy}, '*');
|
||||
parent.postMessage({type, id, method, args, legacy, session:cacheSession}, '*');
|
||||
});
|
||||
}
|
||||
function syncEvents() {
|
||||
if (eventClosed) return Promise.resolve();
|
||||
if (eventRequest) return eventRequest.then(() =>
|
||||
subscribedRevision !== eventRevision ? syncEvents() : undefined);
|
||||
const revision = eventRevision;
|
||||
eventRequest = request('events', [[...eventListeners.keys()], eventCursor], false, 'framey:events')
|
||||
.then(data => {
|
||||
subscribedRevision = revision;
|
||||
if (!data || !Array.isArray(data.events) || !Number.isSafeInteger(data.cursor))
|
||||
throw Error('Invalid Decky event response');
|
||||
eventCursor = data.cursor;
|
||||
for (const event of data.events) {
|
||||
if (!Array.isArray(event.args)) continue;
|
||||
for (const listener of eventListeners.get(event.event) || [])
|
||||
Promise.resolve().then(() => listener(...event.args)).catch(failure =>
|
||||
console.warn('Decky event listener failed', failure));
|
||||
}
|
||||
}).finally(() => {eventRequest = null;});
|
||||
return eventRequest.then(() => subscribedRevision !== eventRevision ? syncEvents() : undefined);
|
||||
}
|
||||
function refreshEvents() {
|
||||
clearTimeout(eventTimer);
|
||||
syncEvents().catch(failure => console.warn('Decky event bridge unavailable', failure.message))
|
||||
.finally(() => {
|
||||
clearTimeout(eventTimer);
|
||||
if (!eventClosed && eventListeners.size)
|
||||
eventTimer = setTimeout(refreshEvents, 1000);
|
||||
});
|
||||
}
|
||||
function addPluginEvent(event, listener) {
|
||||
if (typeof event !== 'string' || !/^[A-Za-z0-9_:/.-]{1,128}$/.test(event) || typeof listener !== 'function')
|
||||
throw Error('Invalid Decky event listener');
|
||||
if (!eventListeners.has(event)) {
|
||||
if (eventListeners.size >= 32) throw Error('Too many Decky event names');
|
||||
eventListeners.set(event, new Set());
|
||||
}
|
||||
eventListeners.get(event).add(listener);
|
||||
eventRevision++; refreshEvents();
|
||||
return listener; // Decky returns the registered callback, not a disposer.
|
||||
}
|
||||
function removePluginEvent(event, listener) {
|
||||
const listeners = eventListeners.get(event);
|
||||
if (!listeners?.delete(listener)) return;
|
||||
if (!listeners.size) eventListeners.delete(event);
|
||||
eventRevision++; refreshEvents();
|
||||
}
|
||||
async function ask(method, args, legacy=false) {
|
||||
// Subscribe before a backend call can emit its immediate response events.
|
||||
if (eventListeners.size && (eventRequest || subscribedRevision !== eventRevision)) await syncEvents();
|
||||
return request(method, args, legacy);
|
||||
}
|
||||
addEventListener('message', event => {
|
||||
if (event.source === parent && event.data?.type === 'framey:cache:seed' &&
|
||||
event.data.session === cacheSession && acceptSeed) {
|
||||
@@ -90,7 +149,12 @@
|
||||
pending.delete(event.data.id); clearTimeout(item.timer);
|
||||
if (event.data.error) item.reject(Error(event.data.error)); else item.resolve(event.data.result);
|
||||
});
|
||||
addEventListener('unload', () => { try { plugin?.onDismount?.(); } catch (_) {} });
|
||||
addEventListener('unload', () => {
|
||||
eventClosed = true; clearTimeout(eventTimer);
|
||||
try { plugin?.onDismount?.(); } catch (_) {}
|
||||
// Parent tears down the remote session on close; CEF also expires it after 15s.
|
||||
parent.postMessage({type:'framey:events:close', session:cacheSession}, '*');
|
||||
});
|
||||
addEventListener('pointerup', event => {
|
||||
if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*');
|
||||
}, {passive:true});
|
||||
@@ -128,12 +192,19 @@
|
||||
mustBeNumeric, mustBeURL, mustBeEmail, onChange}) => R.createElement(Field, {label, description},
|
||||
R.createElement('input', {type:bIsPassword?'password':mustBeNumeric?'number':mustBeURL?'url':mustBeEmail?'email':'text',
|
||||
value, placeholder, disabled:!!disabled, onChange:e=>{if(!disabled)onChange?.(e);}}));
|
||||
const SliderField = ({label, description, value, min=0, max=100, step=1, disabled, showValue,
|
||||
const SliderField = ({label, description, value, min=0, max=100, step=1, disabled, showValue, editableValue,
|
||||
valueSuffix='', onChange}) => R.createElement(Field, {label, description},
|
||||
R.createElement('input', {type:'range', value, min, max, step, disabled:!!disabled,
|
||||
'aria-label':typeof label==='string'?label:undefined,
|
||||
onChange:e=>{if(!disabled)onChange?.(Number(e.target.value));}}),
|
||||
showValue && R.createElement('span', null, String(value) + valueSuffix));
|
||||
editableValue ? R.createElement('span', null,
|
||||
R.createElement('input', {type:'number', value, min, max, step, disabled:!!disabled,
|
||||
'aria-label':typeof label==='string'?`${label} value`:'Slider value',
|
||||
style:{width:'7ch'}, onChange:e=>{
|
||||
const raw=e.target.value, next=Number(raw);
|
||||
if(!disabled && raw.trim()!=='' && e.target.validity?.valid!==false &&
|
||||
Number.isFinite(next) && next>=Number(min) && next<=Number(max)) onChange?.(next);
|
||||
}}), valueSuffix) : showValue && R.createElement('span', null, String(value) + valueSuffix));
|
||||
const PanelSection = ({title, children}) => R.createElement('section', null,
|
||||
title && R.createElement('h2', null, title), children);
|
||||
const Spinner = ({children}) => R.createElement('p', {role:'status'}, children || 'Loading…');
|
||||
@@ -225,6 +296,7 @@
|
||||
DialogButton: passthrough('button'), DialogButtonPrimary: passthrough('button'), ConfirmModal,
|
||||
DialogButtonSecondary: passthrough('button'), Button: ButtonItem, GamepadButton: passthrough('button'),
|
||||
Toggle: ToggleField, 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'),
|
||||
DialogLabel:passthrough('label'), DialogControlsSection:passthrough('section'),
|
||||
@@ -267,17 +339,33 @@
|
||||
Settings:{GetCurrentLanguage:async()=>steamLanguage(),
|
||||
...register(['RegisterForSettingsChanges'])},
|
||||
Apps:register(['RegisterForAppDetails','RegisterForGameActionTaskChange',
|
||||
'RegisterForGameActionStart','RegisterForGameActionEnd']),
|
||||
'RegisterForGameActionStart','RegisterForGameActionEnd','RegisterForGameActionUserRequest']),
|
||||
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
|
||||
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
|
||||
UI:register(['RegisterForFocusChangeEvents'])},
|
||||
GetSystemInfo:()=>request('GetSystemInfo',[],false,'framey:steam'),
|
||||
UI:register(['RegisterForFocusChangeEvents','RegisterForSystemKeyEvents'])},
|
||||
Input:register(['RegisterForControllerInputMessages','RegisterForControllerStateChanges']),
|
||||
User:register(['RegisterForLoginStateChange','RegisterForPrepareForSystemSuspendProgress',
|
||||
User:{...register(['RegisterForLoginStateChange','RegisterForPrepareForSystemSuspendProgress',
|
||||
'RegisterForResumeSuspendedGamesProgress','RegisterForCurrentUserChanges']),
|
||||
GetIPCountry:()=>request('GetIPCountry',[],false,'framey:steam')},
|
||||
InstallFolder:{GetInstallFolders:()=>request('GetInstallFolders',[],false,'framey:steam')},
|
||||
Overlay:register(['RegisterForOverlayActivated']),
|
||||
UI:register(['RegisterForUIModeChanged']),
|
||||
// This is private plugin cache, not Steam's account/global JSON store.
|
||||
Storage:{
|
||||
GetJSON:async key=>{
|
||||
const value=storage.getItem('steam-client:'+String(key));
|
||||
if(value===null)throw Error('Not found');
|
||||
return value;
|
||||
},
|
||||
SetObject:async(key,value)=>{
|
||||
const encoded=JSON.stringify(value);
|
||||
if(encoded===undefined)throw Error('SteamClient.Storage requires a JSON value');
|
||||
storage.setItem('steam-client:'+String(key),encoded);
|
||||
},
|
||||
},
|
||||
};
|
||||
DFL.Router = {...DFL.Navigation, MainRunningApp:undefined};
|
||||
DFL.Router = {...DFL.Navigation, ...window.SteamUIStore};
|
||||
DFL.DialogButton.render = () => ({type: DFL.DialogButton});
|
||||
const toaster = {toast: ({title, body, subtext, icon, logo, duration, showToast=true, onClick}={}) => {
|
||||
const data = {title, body, subtext, icon, logo};
|
||||
@@ -347,7 +435,7 @@
|
||||
return R.createElement(R.Fragment, null,
|
||||
plugin.titleView && R.createElement('header', null, plugin.titleView),
|
||||
route && R.createElement('button', {type:'button', onClick:()=>navigate(null)}, 'Back to plugin'),
|
||||
plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'},
|
||||
plugin.content && plugin.settings && R.createElement('nav', {'aria-label':'Plugin views'},
|
||||
R.createElement('button', {type:'button', 'aria-current':view === 'panel' ? 'page' : undefined,
|
||||
onClick:()=>setView('panel')}, 'Panel'),
|
||||
R.createElement('button', {type:'button', 'aria-current':view === 'settings' ? 'page' : undefined,
|
||||
@@ -366,7 +454,7 @@
|
||||
_version: Math.min(version, 2),
|
||||
call: (method, ...args) => ask(method, args),
|
||||
callable: method => (...args) => ask(method, args),
|
||||
addEventListener: () => {}, removeEventListener: () => {},
|
||||
addEventListener:addPluginEvent, removeEventListener:removePluginEvent,
|
||||
routerHook, toaster,
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
executeInTab: () => Promise.reject(Error('Steam tab injection is unavailable in Framey')),
|
||||
@@ -386,13 +474,49 @@
|
||||
},
|
||||
fetchNoCors: () => Promise.reject(Error('Decky proxy fetch is unavailable in Framey')),
|
||||
};
|
||||
function setLibrarySnapshot(snapshot) {
|
||||
// These are detached plugin-local values. Patching an overview or its Map
|
||||
// must never mutate Steam's library. Live MobX observations are unsupported.
|
||||
if (!snapshot || !Array.isArray(snapshot.apps) || snapshot.apps.length > 10000 ||
|
||||
!Array.isArray(snapshot.gameIds) || snapshot.gameIds.length > 10000 ||
|
||||
(snapshot.steamId != null && (typeof snapshot.steamId !== 'string' || !/^\d{17}$/.test(snapshot.steamId))))
|
||||
throw Error('Invalid Steam library snapshot');
|
||||
const apps = snapshot.apps.map(row => {
|
||||
if (!Array.isArray(row) || row.length !== 8 || !Number.isSafeInteger(row[0]) || row[0] <= 0 ||
|
||||
typeof row[1] !== 'string' || row[1].length > 2048 ||
|
||||
(row[2] != null && typeof row[2] !== 'string') || !Number.isSafeInteger(row[3]) ||
|
||||
typeof row[4] !== 'string' || !/^\d{1,20}$/.test(row[4]) ||
|
||||
row.slice(5).some(value=>value != null && (!Number.isFinite(value) || value < 0)))
|
||||
throw Error('Invalid Steam app overview');
|
||||
const [appid,display_name,sort_as,app_type,gameid,
|
||||
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played] = row;
|
||||
return {appid,display_name,sort_as,app_type,gameid,
|
||||
minutes_playtime_forever,minutes_playtime_last_two_weeks,rt_last_time_played};
|
||||
});
|
||||
const map = new Map(apps.map(app=>[app.appid,app]));
|
||||
if (map.size !== apps.length || snapshot.gameIds.some(id=>!Number.isSafeInteger(id) || !map.has(id)))
|
||||
throw Error('Invalid Steam game collection');
|
||||
window.appStore = {allApps:apps, m_mapApps:map,
|
||||
GetAppOverviewByAppID:id=>map.get(Number(id)),
|
||||
GetAppOverviewByGameID:id=>apps.find(app=>app.gameid === String(id)),
|
||||
};
|
||||
window.collectionStore = {allGamesCollection:{allApps:snapshot.gameIds.map(id=>map.get(id))}};
|
||||
window.App = snapshot.steamId ? {m_CurrentUser:{strSteamID:snapshot.steamId}} : undefined;
|
||||
}
|
||||
async function start() {
|
||||
try {
|
||||
await seeded;
|
||||
const bytes = Uint8Array.from(atob(document.getElementById('framey-bundle').textContent), c=>c.charCodeAt(0));
|
||||
const bundleText = new TextDecoder().decode(bytes);
|
||||
// Only panels referencing library globals need the extra read. A failed
|
||||
// read leaves them unavailable rather than fabricating an empty library.
|
||||
if (/\b(?:appStore|collectionStore|m_CurrentUser)\b/.test(bundleText)) {
|
||||
try {setLibrarySnapshot(await request('GetLibrarySnapshot',[],false,'framey:steam'));}
|
||||
catch (failure) {console.warn('Framey Steam library unavailable', failure.message);}
|
||||
}
|
||||
// Old Decky builds are expressions returning the initializer, rather than
|
||||
// ES modules. A module wrapper preserves the sandbox and avoids eval.
|
||||
const legacy = /^\s*\(function\s*\(/.test(new TextDecoder().decode(bytes));
|
||||
const legacy = /^\s*\(function\s*\(/.test(bundleText);
|
||||
const url = URL.createObjectURL(new Blob(legacy ?
|
||||
['const __frameyLegacy =\n', bytes, '\nexport default __frameyLegacy;'] : [bytes], {type:'text/javascript'}));
|
||||
let module;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Digest-scoped observations, never a trust or execution guarantee."""
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
DATA = Path(__file__).with_name('framey-compatibility.json')
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _records(path):
|
||||
return json.loads(path.read_text())['plugins']
|
||||
|
||||
|
||||
def lookup(plugin_id, digest, version):
|
||||
unknown = {'status': 'unverified', 'reason': 'This exact store version has not been audited on Frame.',
|
||||
'verification': [], 'blockInstall': False}
|
||||
if type(plugin_id) is not int or not isinstance(digest, str) or not isinstance(version, str):
|
||||
return unknown
|
||||
try:
|
||||
records = _records(DATA)
|
||||
matches = [p for p in records if p['id'] == plugin_id and p['sha256'] == digest and p['version'] == version]
|
||||
if len(matches) != 1:
|
||||
return unknown
|
||||
record = matches[0]
|
||||
return {key: record[key] for key in ('status', 'reason', 'verification', 'blockInstall')}
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return unknown
|
||||
+90
-4
@@ -9,6 +9,8 @@ import frame_decky
|
||||
|
||||
|
||||
METHOD = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,63}\Z')
|
||||
EVENT = re.compile(r'[A-Za-z0-9_:/.-]{1,128}\Z')
|
||||
SESSION = re.compile(r'[A-Za-z0-9_.-]{1,100}\Z')
|
||||
PLUGINS = Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
|
||||
|
||||
@@ -79,7 +81,7 @@ METHOD_SCRIPT = r'''
|
||||
import json, pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
|
||||
from frame_steam import Page
|
||||
payload = json.loads(sys.argv[1])
|
||||
payload = json.loads(__FRAMEY_METHOD_PAYLOAD__)
|
||||
route = 'loader/call_legacy_plugin_method' if payload.get('legacy') else 'loader/call_plugin_method'
|
||||
arguments = [payload['name'], payload['method'], *payload['args']]
|
||||
expression = "(async()=>{if(!window.DeckyBackend)throw Error('Decky frontend is unavailable in Steam CEF');return await DeckyBackend.call(" + ','.join(json.dumps(x) for x in [route, *arguments]) + ')})()'
|
||||
@@ -94,7 +96,7 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(method, str) or not METHOD.fullmatch(method):
|
||||
raise PanelError('Invalid plugin method')
|
||||
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args)) > 8192:
|
||||
if not isinstance(args, list) or len(args) > 8 or len(json.dumps(args).encode('utf-8')) > 256*1024:
|
||||
raise PanelError('Plugin method arguments are too large')
|
||||
if type(legacy) is not bool or (legacy and (len(args) != 1 or not isinstance(args[0], dict))):
|
||||
raise PanelError('Legacy plugin methods need one keyword argument object')
|
||||
@@ -102,5 +104,89 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)),
|
||||
stdin=METHOD_SCRIPT, timeout=25))
|
||||
# Library-sized arguments can exceed the OS limit for a single SSH command
|
||||
# argument. Send them as a quoted Python literal on stdin instead.
|
||||
script = METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__', repr(json.dumps(payload)))
|
||||
return json.loads(ssh('python3 -', stdin=script, timeout=25))
|
||||
|
||||
|
||||
# Only parameterless read APIs. Never resolve a plugin-supplied property path
|
||||
# against SteamClient or evaluate a plugin-supplied expression in trusted CEF.
|
||||
STEAM_READS = {
|
||||
'GetIPCountry': 'SteamClient.User.GetIPCountry()',
|
||||
'GetInstallFolders': 'SteamClient.InstallFolder.GetInstallFolders()',
|
||||
'GetSystemInfo': 'SteamClient.System.GetSystemInfo()',
|
||||
# Copy selected scalar fields, never Steam objects, methods, credentials or
|
||||
# paths. Only the current public Steam ID is needed for per-user plugin data.
|
||||
# Compact rows keep even a large library inside the response budget.
|
||||
'GetLibrarySnapshot': r'''(()=>{
|
||||
if (!window.appStore?.allApps || !window.collectionStore?.allGamesCollection?.allApps)
|
||||
throw Error('Steam library is unavailable');
|
||||
const apps = Array.from(appStore.allApps);
|
||||
if (apps.length > 10000) throw Error('Steam library exceeds the Framey snapshot limit');
|
||||
return {apps:apps.map(a=>[a.appid,a.display_name,a.sort_as,a.app_type,String(a.gameid),
|
||||
a.minutes_playtime_forever,a.minutes_playtime_last_two_weeks,a.rt_last_time_played]),
|
||||
gameIds:Array.from(collectionStore.allGamesCollection.allApps,a=>a.appid),
|
||||
steamId:window.App?.m_CurrentUser?.strSteamID || null};
|
||||
})()''',
|
||||
}
|
||||
STEAM_READ_SCRIPT = r'''
|
||||
import json, pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
|
||||
from frame_steam import Page
|
||||
page = Page()
|
||||
try:
|
||||
encoded = json.dumps({'result':page.eval(__FRAMEY_STEAM_READ__)})
|
||||
if len(encoded.encode('utf-8')) > __FRAMEY_STEAM_LIMIT__:
|
||||
raise ValueError('Steam read response is too large')
|
||||
print(encoded)
|
||||
finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def read_steam(ssh, plugin_id, method):
|
||||
"""Read one explicitly supported Steam API for an installed store panel."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(method, str) or method not in STEAM_READS:
|
||||
raise PanelError('This Steam API is unavailable in Framey')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
expression = '(async()=>await ' + STEAM_READS[method] + ')()'
|
||||
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
|
||||
'__FRAMEY_STEAM_LIMIT__', str(2*1024*1024 if method == 'GetLibrarySnapshot' else 256*1024))
|
||||
return json.loads(ssh('python3 -', stdin=script, timeout=25))
|
||||
|
||||
|
||||
EVENT_SCRIPT = r'''
|
||||
import json, pathlib, sys
|
||||
sys.path.insert(0, str(pathlib.Path.home()/'.local/share/frame-control/headset/bundle/ui'))
|
||||
from frame_steam import Page
|
||||
payload = json.loads(sys.argv[1])
|
||||
source = __FRAMEY_EVENTS_SOURCE__
|
||||
page = Page()
|
||||
try: print(json.dumps(page.eval('(' + source + ')(' + json.dumps(payload) + ')')))
|
||||
finally: page.sock.close()
|
||||
'''
|
||||
|
||||
|
||||
def poll_events(ssh, plugin_id, session, events, cursor=0):
|
||||
"""Read only this store-owned plugin's subscribed events; never trust its claimed name."""
|
||||
if type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise PanelError('Choose an installed Decky store plugin')
|
||||
if not isinstance(session, str) or not SESSION.fullmatch(session):
|
||||
raise PanelError('Invalid Framey event session')
|
||||
if (not isinstance(events, list) or len(events) > 32 or
|
||||
any(not isinstance(event, str) or not EVENT.fullmatch(event) for event in events)):
|
||||
raise PanelError('Invalid Decky event names')
|
||||
if type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
|
||||
raise PanelError('Invalid Decky event cursor')
|
||||
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
|
||||
if len(found) != 1:
|
||||
raise PanelError('The plugin is not managed by the Frame Control Decky store')
|
||||
payload = {'name':found[0]['name'], 'session':session, 'events':events, 'cursor':cursor}
|
||||
# Embed only our bundled source as a Python string; plugin data stays in JSON.
|
||||
source = Path(__file__).with_name('framey-events.js').read_text(encoding='utf-8')
|
||||
script = EVENT_SCRIPT.replace('__FRAMEY_EVENTS_SOURCE__', repr(source))
|
||||
return json.loads(ssh('python3 - ' + shlex.quote(json.dumps(payload)), stdin=script, timeout=25))
|
||||
+42
-11
@@ -50,10 +50,11 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
|
||||
<p id="deckyJobStatus" role="status" aria-live="polite"></p><progress id="deckyJobProgress" max="100"></progress>
|
||||
<div class="row"><button id="deckyJobCheck" class="secondary" hidden>Check result</button></div></div>
|
||||
<div id="deckyStorePanel" hidden><div class="row" style="margin:16px 0"><label>Find a plugin <input id="deckySearch" type="search" placeholder="Name, author or description"></label>
|
||||
<label>Show <select id="deckyStoreFilter"><option value="all">All plugins</option><option value="compatible">Without root requirement</option><option value="installed">Installed plugins</option></select></label></div>
|
||||
<label>Show <select id="deckyStoreFilter"><option value="all">All plugins</option><option value="compatible">No known install blocker</option><option value="installed">Installed plugins</option></select></label></div>
|
||||
<p id="deckyStoreSummary" role="status" aria-live="polite"></p><div id="deckyStoreList" class="stack"></div>
|
||||
<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></div>
|
||||
<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>
|
||||
<h2>Extensions</h2><p>Local additions run only when opened in the isolated panel below. They can read status but cannot change headset settings through the extension API.</p>
|
||||
@@ -103,6 +104,7 @@ async function refreshGames(){const list=byId('games');try{const data=await api(
|
||||
catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}};
|
||||
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';}
|
||||
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
|
||||
activeFrameyId=null, frameySession=null, deckyBusy=false;
|
||||
async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
@@ -112,6 +114,7 @@ async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
const list=byId('deckyInstalled');list.replaceChildren();
|
||||
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
|
||||
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
|
||||
const note=document.createElement('p');note.textContent=compatibilityText(plugin);row.append(note);
|
||||
if(plugin.storeId){const open=document.createElement('button');open.className='secondary';open.textContent='Open panel';
|
||||
open.onclick=()=>openFrameyPanel(plugin.storeId,plugin.name);row.append(open);
|
||||
const button=document.createElement('button');button.className='danger';button.textContent='Remove';
|
||||
@@ -130,7 +133,7 @@ function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren
|
||||
const q=byId('deckySearch').value.trim().toLowerCase();
|
||||
const filter=byId('deckyStoreFilter').value;
|
||||
const found=deckyStore.filter(p=>(!q||`${p.name} ${p.author||''} ${p.description}`.toLowerCase().includes(q))&&
|
||||
(filter==='all'||(filter==='compatible'&&!p.root)||(filter==='installed'&&(deckyState?.plugins||[]).some(x=>x.storeId===p.id))));
|
||||
(filter==='all'||(filter==='compatible'&&!p.root&&!p.compatibility?.blockInstall)||(filter==='installed'&&(deckyState?.plugins||[]).some(x=>x.storeId===p.id))));
|
||||
const size=20, pages=Math.max(1,Math.ceil(found.length/size));
|
||||
deckyStoreIndex=Math.min(deckyStoreIndex,pages-1);
|
||||
byId('deckyStoreSummary').textContent=`${found.length} plugin${found.length===1?'':'s'} found`;
|
||||
@@ -140,12 +143,13 @@ function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren
|
||||
if(!found.length)list.textContent='No plugins match this search.';
|
||||
for(const plugin of found.slice(deckyStoreIndex*size,(deckyStoreIndex+1)*size)){
|
||||
const row=document.createElement('div');row.className='block game';
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version}${plugin.author?' · '+plugin.author:''} · ${plugin.description}`;
|
||||
const label=document.createElement('div');label.textContent=`${plugin.name} ${plugin.version}${plugin.author?' · '+plugin.author:''} · ${plugin.description}`;
|
||||
const button=document.createElement('button');button.textContent='Install';
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===plugin.id);
|
||||
if(installed)button.textContent=installed.version===plugin.version?'Reinstall':'Update';
|
||||
button.disabled=deckyBusy||!deckyState?.running||plugin.root;
|
||||
if(plugin.root)label.textContent+=' · Needs root; not supported on Frame';
|
||||
button.disabled=deckyBusy||!deckyState?.running||plugin.root||!!plugin.compatibility?.blockInstall;
|
||||
const note=document.createElement('p');note.textContent=compatibilityText(plugin);label.append(note);
|
||||
if(plugin.root && !plugin.compatibility?.blockInstall)label.append(' · Needs root; not supported on Frame');
|
||||
button.onclick=()=>deckyChange(installed?'update':'install',plugin);row.append(label,button);list.append(row);}}
|
||||
function deckyJobView(title,message,percent,failed=false){
|
||||
byId('deckyJobPanel').hidden=false;byId('deckyJobTitle').textContent=title;
|
||||
@@ -191,7 +195,9 @@ async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.get
|
||||
finally{deckyBusy=false;await refreshDecky();}}
|
||||
byId('deckyJobCheck').onclick=checkDeckyJob;
|
||||
function openFrameyPanel(id,name){
|
||||
if(!(deckyState?.plugins||[]).some(p=>p.storeId===id))return;
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
|
||||
byId('frameyCompatibility').textContent=compatibilityText(installed);
|
||||
closeFrameyEvents();
|
||||
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
|
||||
byId('frameyPanelStatus').textContent='Loading plugin panel…';
|
||||
byId('frameyFrame').src=`/framey/panel/${id}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
|
||||
@@ -199,7 +205,13 @@ function openFrameyPanel(id,name){
|
||||
setTimeout(()=>{if(activeFrameyId===id&&byId('frameyPanelStatus').textContent==='Loading plugin panel…')
|
||||
byId('frameyPanelStatus').textContent='No compatible panel loaded. This plugin may need Steam UI features Framey cannot provide.';},12000);
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
let frameyEventsActive=false;
|
||||
function closeFrameyEvents(){
|
||||
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
|
||||
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
|
||||
frameyEventsActive=false;
|
||||
}
|
||||
byId('frameyClose').onclick=()=>{closeFrameyEvents();activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
|
||||
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
|
||||
function frameyCacheRead(id){
|
||||
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
|
||||
@@ -232,11 +244,30 @@ window.addEventListener('message',async event=>{
|
||||
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:call'||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const pluginId=activeFrameyId,reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api('/api/framey/method',{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
if(req.type==='framey:events:close'){
|
||||
if(req.session===frameySession)closeFrameyEvents();return;
|
||||
}
|
||||
if(req.type==='framey:events'){
|
||||
if(req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||!Array.isArray(req.args)||
|
||||
req.args.length!==2||!Array.isArray(req.args[0])||req.args[0].length>32||
|
||||
req.args[0].some(name=>typeof name!=='string'||!/^[A-Za-z0-9_:/.-]{1,128}$/.test(name))||
|
||||
!Number.isSafeInteger(req.args[1])||req.args[1]<0)return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
frameyEventsActive=req.args[0].length>0;
|
||||
try{reply.result=await api('/api/framey/events',{id:pluginId,session,events:req.args[0],cursor:req.args[1]});}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
|
||||
event.source.postMessage(reply,'*');
|
||||
return;
|
||||
}
|
||||
if(!['framey:call','framey:steam'].includes(req.type)||req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))return;
|
||||
const steamRead=req.type==='framey:steam';
|
||||
if(steamRead&&(req.args.length!==0||!['GetIPCountry','GetInstallFolders','GetSystemInfo','GetLibrarySnapshot'].includes(req.method)))return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api(steamRead?'/api/framey/steam':'/api/framey/method',
|
||||
{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
catch(error){reply.error=error.message;}
|
||||
if(pluginId===activeFrameyId&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
});
|
||||
window.addEventListener('pointerup',event=>{if(event.isTrusted)
|
||||
api('/api/framey/check',{kind:'input'}).catch(()=>{});},{passive:true});
|
||||
|
||||
+4
-2
@@ -2189,6 +2189,7 @@ $('extensionClose').onclick = () => {
|
||||
$('extensionFrame').removeAttribute('src');
|
||||
$('extensionPanel').hidden = true;
|
||||
};
|
||||
function deckyCompatibilityText(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';}
|
||||
let deckyInfo = null;
|
||||
let deckyCatalog = [];
|
||||
async function loadDecky() {
|
||||
@@ -2200,7 +2201,7 @@ async function loadDecky() {
|
||||
const managedList=$('deckyManagedList');managedList.replaceChildren();
|
||||
for(const plugin of d.plugins||[]){if(!plugin.storeId)continue;
|
||||
const row=document.createElement('div');row.className='hint';
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''}`;
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''} · ${deckyCompatibilityText(plugin)}`;
|
||||
const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove plugin';
|
||||
remove.disabled=!d.managed||!$('deckyAck').checked;
|
||||
remove.onclick=()=>changeDeckyPlugin('remove',{id:plugin.storeId,name:plugin.name});
|
||||
@@ -2226,8 +2227,9 @@ function showDeckyStore() {
|
||||
const description = document.createElement('span'); description.textContent = ` ${plugin.description}`;
|
||||
const entry = installed.find(p => p.storeId === plugin.id);
|
||||
const unmanaged = installed.some(p => p.name === plugin.name && p.storeId !== plugin.id);
|
||||
const reason = plugin.root ? 'Needs root; not supported by the Frame user service.' : unmanaged ? 'Existing plugin is not managed by Frame Control.' : '';
|
||||
const reason = plugin.root ? 'Needs root; not supported by the Frame user service.' : plugin.compatibility?.blockInstall ? plugin.compatibility.reason : unmanaged ? 'Existing plugin is not managed by Frame Control.' : '';
|
||||
row.append(title, description);
|
||||
const compatibility=document.createElement('p');compatibility.textContent=deckyCompatibilityText(plugin);row.append(compatibility);
|
||||
if (reason) { const note=document.createElement('small'); note.textContent=` ${reason}`; row.append(note); }
|
||||
else {
|
||||
const button=document.createElement('button'); button.className='small';
|
||||
|
||||
@@ -586,6 +586,25 @@ def framey_method_action(body):
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
def framey_steam_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.read_steam(ssh, body.get('id'), body.get('method'))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
def framey_events_action(body):
|
||||
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
|
||||
raise Failure('Decky plugin panels run in the headset service', 400)
|
||||
try:
|
||||
return framey_panel.poll_events(ssh, body.get('id'), body.get('session'),
|
||||
body.get('events'), body.get('cursor', 0))
|
||||
except framey_panel.PanelError as exc:
|
||||
raise Failure(str(exc), 400) from exc
|
||||
|
||||
|
||||
def framey_check_action(body):
|
||||
"""Record only a live, explicitly started worn-headset check."""
|
||||
if not HEADSET_RUNTIME or body.get('kind') not in ('input', 'panel'):
|
||||
@@ -3452,6 +3471,8 @@ POST = {
|
||||
"/api/decky": decky,
|
||||
"/api/decky/plugin": decky_plugin,
|
||||
"/api/framey/method": framey_method_action,
|
||||
"/api/framey/steam": framey_steam_action,
|
||||
"/api/framey/events": framey_events_action,
|
||||
"/api/framey/check": framey_check_action,
|
||||
"/api/headset": headset_action,
|
||||
"/api/headset/quick": headset_quick,
|
||||
|
||||
Reference in new issue
Block a user