mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 05:00:26 +01:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3b0dd6f9 | ||
|
|
67b9151513 | ||
|
|
ce6e8f807f | ||
|
|
e7fe34d865 | ||
|
|
fd0b0a946f | ||
|
|
2895d5711e | ||
|
|
1ad6a97a36 | ||
|
|
b795c1dc56 | ||
|
|
db079eec9f | ||
|
|
bc72c41be5 | ||
|
|
697e68f098 | ||
|
|
3921e59035 | ||
|
|
b863527c33 | ||
|
|
c08f5df677 | ||
|
|
c62bffc8ef | ||
|
|
aeb6bb5294 | ||
|
|
65964f0e46 | ||
|
|
792e9c016e | ||
|
|
aeac07cf54 | ||
|
|
d90d111218 | ||
|
|
8fc1117209 | ||
|
|
442b5ecfd8 | ||
|
|
5afffbef89 | ||
|
|
76f619280a | ||
|
|
2b92599d94 | ||
|
|
6aa74619d5 | ||
|
|
0a463dea23 | ||
|
|
e44ea65080 | ||
|
|
1bb5ed6216 | ||
|
|
e3bb608dde | ||
|
|
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
|
||||
|
||||
@@ -128,6 +128,7 @@ Choose the operating system and processor of the computer you will use.
|
||||
| | Download | Needs |
|
||||
|---|---|---|
|
||||
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
|
||||
| **macOS** (Intel, v0.4.39+) | [Frame-Control-mac-x64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-x64.dmg) | Nothing extra |
|
||||
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) |
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
@@ -150,8 +151,8 @@ The [source](https://github.com/saphid/frame-control) and [release build workflo
|
||||
|
||||
日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
|
||||
|
||||
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
|
||||
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
|
||||
**Which build?** On a Mac, check Apple menu → About This Mac: use `mac-arm64`
|
||||
for Apple Silicon (M-series), or `mac-x64` for Intel (available from v0.4.39). On Windows,
|
||||
check Settings → System → About → System type: the release is x64, not a native
|
||||
Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64`
|
||||
(arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions.
|
||||
@@ -173,16 +174,44 @@ 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.38 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. The official **v0.4.39 Intel DMG** was then verified,
|
||||
installed and launched on that Air. Both CLI and T3 browser downloads lacked
|
||||
quarantine metadata; no Gatekeeper dialog appeared, while `spctl` rejected the
|
||||
ad hoc signed app. This does not verify Safari/Chrome warnings or Open Anyway.
|
||||
See the [released Intel test](docs/evidence/mac-v0439-2026-10-26.md) and
|
||||
[earlier architecture failure](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 either Mac architecture.
|
||||
|
||||
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.38",
|
||||
"version": "0.4.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.38",
|
||||
"version": "0.4.39",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+5
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.38",
|
||||
"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",
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.38**, 26 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.
|
||||
Current for Frame Control **v0.4.39**, 26 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.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| 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.37 tag `5239dae`, Frame OS 0.5.6 build `20261022.6153909`: [release smoke evidence](evidence/v0437-release-smoke-2026-10-26.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): official v0.4.39 x64 DMG verified, installed and launched. Native UI, preference persistence across restart (original file restored), bundled Python logs, update check, offline OBS Start/Stop and 116-entry Decky store checked. A 40-byte relay upload/readback/delete passed under coordinator/headset locks | No | No quarantined Safari/Chrome download or Gatekeeper override test; native Open at Login/menu automation unavailable. Direct frame.local had no route and the app refused a stale SSH identity. Relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Quit exposed a server cleanup race; fix #284 has physical-Mac regression coverage, not a released fix | 26 Oct, official v0.4.39 tag `fd0b0a9`: [Intel release evidence](evidence/mac-v0439-2026-10-26.md); [earlier ARM64 failure](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) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30.0.2 on a Linux host: 187.367 s, 720p30 H.264/AAC stereo, 5,621 frames, no encoder skips; room passthrough appeared in the recording | No | Synthetic tone was measured, not real game audio or audible listening. Default audio led by about 0.43 s in this setup; a measured +480 ms OBS offset removed residual skew at test resolution. Source-image latency was 1.431 s in one trial; other hosts and long sessions are unverified | 25 Oct, v0.4.30 package plus PR #219 runtime: [validation](evidence/obs-validation-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | Windows 11 VM helper fixture passed, but a native bridge to the real Frame sent no packets | In-app OBS controls remain hidden; native Windows OBS and firewall path are unverified | 25 Oct, PR #219: [validation](evidence/obs-validation-2026-10-25.md), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | Physical Intel Air: official v0.4.39 native Start/Stop returned to idle offline | No | Real headset stream, OBS receiver and audio/video remain unverified; the app rejected its stale SSH identity | 26 Oct, v0.4.39: [Mac evidence](evidence/mac-v0439-2026-10-26.md), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
|
||||
@@ -22,8 +23,8 @@ Current for Frame Control **v0.4.38**, 26 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 | 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 | 25 Oct, v0.4.29–0.4.37: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.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. The exact-version official catalog covers 116 plugins: 1 works (Notebook default flow only), 44 partial, 60 broken and 11 not applicable. 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.38: [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), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Framey VR plugin panels and store | An unattended Frame OS 0.5.6 run showed the Framey sample panel in stereo compositor and mirror captures. 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; 98 local Chromium initializers were ready and 18 failed. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38: [unattended sample evidence](evidence/framey-vr-2026-10-25/README.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), [worn checklist](framey-vr-check.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. The exact-version official catalog covers 116 plugins: 1 works (Notebook default flow only), 45 partial, 59 broken and 11 not applicable. 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), [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; 98 local Chromium initializers were ready and 18 failed. 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), [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.
|
||||
@@ -91,6 +91,11 @@ hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
|
||||
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
|
||||
|
||||
@@ -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,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,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,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,116 @@
|
||||
# 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.
|
||||
|
||||
**Follow-up:** the official v0.4.39 Intel installer later launched on this Air.
|
||||
See the [released-build test](mac-v0439-2026-10-26.md); the results below remain
|
||||
the earlier ARM64 failure and separate candidate 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,102 @@
|
||||
# Official Intel Mac release test, 26 October 2026
|
||||
|
||||
Physical **MacBookAir9,1, Intel Core i3 1.1 GHz, macOS 15.7.7 (24G720)**.
|
||||
The earlier ARM64 failure led to [#262](https://github.com/saphid/frame-control/pull/262),
|
||||
now merged. This test uses the published **v0.4.39**, tag `fd0b0a9`, rather
|
||||
than the locally built candidate. [Earlier results](mac-desktop-2026-10-26.md)
|
||||
remain a separate historical record.
|
||||
|
||||
## Download, installation and Gatekeeper observations
|
||||
|
||||
Downloaded `Frame-Control-mac-x64.dmg` and its same-release `SHA256SUMS`
|
||||
with `gh release download`. `shasum -a 256 -c SHA256SUMS --ignore-missing`
|
||||
passed for the downloaded asset:
|
||||
|
||||
```text
|
||||
f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8 Frame-Control-mac-x64.dmg
|
||||
```
|
||||
|
||||
Also clicked that asset in T3's Chromium browser. Its 169,230,398-byte download
|
||||
had the same hash. **Neither download had `com.apple.quarantine`.** T3's
|
||||
browser is not evidence of Safari/Chrome download metadata.
|
||||
|
||||
Mounted the verified CLI DMG read-only, preserved the task's earlier ARM64
|
||||
installation, and copied `Frame Control.app` into `/Applications` with `ditto`
|
||||
(the command-line equivalent of dragging it there). The executable is x86_64;
|
||||
`codesign -dv` reports ad hoc signing. `open "/Applications/Frame Control.app"`
|
||||
returned success and the app started its bundled Python server.
|
||||
|
||||
**No Gatekeeper warning dialog was observed.** `spctl --assess --type execute
|
||||
-vv "/Applications/Frame Control.app"` returned exit 3 and exactly:
|
||||
|
||||
```text
|
||||
/Applications/Frame Control.app: rejected
|
||||
```
|
||||
|
||||
Opening this unquarantined copy needed no override. This does not establish
|
||||
the warning or opening steps for a quarantined browser download. The README
|
||||
keeps Apple's Open Anyway guidance, explicitly unverified here. No quarantine
|
||||
attribute was removed and no global security setting was changed. System
|
||||
Events inspection timed out; an AppleEvent quit request returned “User
|
||||
cancelled” (-128). Native console/menu automation remains unavailable.
|
||||
|
||||
## Official native app results
|
||||
|
||||
After the normal launch, a second run disabled telemetry, used a task-local
|
||||
server data directory and enabled a temporary loopback renderer debug port.
|
||||
The app bundle was not patched. The following checks used its real renderer
|
||||
and `window.frameApp` IPC bridge; they are not physical mouse/menu tests.
|
||||
|
||||
| Check | Result and limit |
|
||||
|---|---|
|
||||
| UI | Apps, Games, Tools, Settings, Privacy & updates, Capture and Decky views rendered with the expected headings. The activity log showed the stale SSH identity warning. [Native Apps screenshot](mac-v0439-native-2026-10-26.png) |
|
||||
| Settings | Initial native preferences were `{}`. `mvSeparate="0"` saved and read back, including after a real app restart. The original preferences file was restored byte-for-byte in `finally` (or removed if originally absent) |
|
||||
| Open at Login | Still opt-in in source; no login item was enabled. Actual menu checkbox and OS login behavior remain unverified because native automation was unavailable |
|
||||
| Logs | `~/Library/Logs/Frame Control/server.log` recorded startup with the installed app's `Contents/Resources/python/bin/python3` and `ui/server.py`. Show Server Log menu interaction remains unverified |
|
||||
| Update check | Native check returned current/latest `0.4.39`, status `none`, error `null`. No update installed |
|
||||
| OBS | Native Start on port 15123 returned `starting`; Stop returned `stopping`, then `idle`. This was offline: no OBS receiver or real headset audio/video |
|
||||
| Decky store | Native Load store returned 116 entries; 40 initial rows rendered. Search found ProtonDB Badges 1.3.4 and its compatibility text. Install remained disabled; no plugin/loader installed |
|
||||
| Framey preview | No standalone local Framey preview control found. No VR runtime result claimed |
|
||||
|
||||
Closing the app through `window.close()` stopped its processes and closed
|
||||
the debug port. **The final shutdown log exposed a bug:** stdin EOF and the
|
||||
app's SIGTERM can overlap, raising `KeyboardInterrupt` while cleanup is
|
||||
installing its ignore handler. Cleanup can then stop before releasing staging
|
||||
or SSH resources. [#284](https://github.com/saphid/frame-control/pull/284)
|
||||
changes SIGTERM to request HTTP shutdown without raising into cleanup.
|
||||
|
||||
The regression test injects SIGTERM at that exact cleanup boundary. On this
|
||||
physical Mac with the official app's bundled Python 3.12, it failed before
|
||||
the fix with exit -2 and the observed traceback. After the fix, all **45
|
||||
server integration tests** passed, including owned staging-file cleanup.
|
||||
This validates source from the fix PR, not a fixed published app.
|
||||
|
||||
## Headset relay and small transfer
|
||||
|
||||
`frame.local` resolved but TCP 22 returned **No route to host**. The native
|
||||
app refused a stale SSH identity; no pin was replaced. The trusted lxso2
|
||||
coordinator relay could reach the Frame. Both shared locks were absent.
|
||||
|
||||
The relay test atomically created `/tmp/frame-test.lock` on the coordinator
|
||||
and the headset, each with this job's owner marker. It then:
|
||||
|
||||
- Read battery **99%, Not charging** before and after; storage total
|
||||
**996,984,774,656 bytes**, free **769,077,035,008 bytes**.
|
||||
- Listed **7** Steam screenshot JPEGs matching
|
||||
`userdata/*/760/remote/*/screenshots/*.jpg`; no image was copied. This narrower
|
||||
layout-specific count differs from the earlier broad JPEG search.
|
||||
- Sent a **40-byte** test payload from this Mac through lxso2, created one
|
||||
reserved file in the Frame user's home using exclusive creation, and read
|
||||
it back. SHA-256 matched
|
||||
`457485db13f80d819d5301d337502c3df050c6b3eb6202be7aac85d2af8e0b92`.
|
||||
- Deleted that exact test file and released only the matching owned locks.
|
||||
A separate SSH read verified the file and both locks were absent.
|
||||
|
||||
This proves a bounded relay transfer, not native Mac app file transfer or
|
||||
direct `frame.local` connectivity. No existing headset file, app, service,
|
||||
power setting or SteamVR state was changed. The headset was not wiped or
|
||||
rebooted. Real OBS and wearer/controller interaction remain unverified.
|
||||
|
||||
The DMG was ejected; the native app, debug port and task browser tab were
|
||||
closed; the desk hold was released. The official app remains installed for
|
||||
reproduction. No social post was made; PR #113 was left alone.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 529 KiB |
@@ -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.
|
||||
@@ -4,10 +4,10 @@ Snapshot: 2026-10-25T23:19:48.854504+00:00; 116 current visible official plugins
|
||||
|
||||
This table and both store views use [`ui/framey-compatibility.json`](../ui/framey-compatibility.json). Results apply only to the exact store ID, version and SHA-256. A changed digest/version is **unverified**. Regenerate this document with `python3 scripts/framey-compatibility-doc.py`; use `--check` in review.
|
||||
|
||||
**Works: 1 · Partial: 44 · Broken: 60 · N/A: 11.**
|
||||
**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 a limited frontend/settings surface initialized or rendered. Backend-dependent functionality remains unverified; it is not an end-to-end success claim.
|
||||
- **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.
|
||||
|
||||
@@ -27,7 +27,7 @@ Open the browser audit URL in local Chromium, run `runAudit()` and inspect/downl
|
||||
|
||||
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. All 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. 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).
|
||||
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
|
||||
|
||||
@@ -56,19 +56,19 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 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 | RunningApps and appStore are absent; Discord activity integration fails after initialization. | static archive, local browser, fake-Frame |
|
||||
| 38: Volume Mixer 1.2.1-1 | legacy / legacy | partial | Initializer succeeds; user PulseAudio/PipeWire mixer backend and volume changes unverified. | 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 | broken | Frontend audit failed: Cannot read properties of undefined (reading 'm_CurrentUser'). Steam account/app context is unavailable. | 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 | Frontend audit failed: Cannot read properties of undefined (reading 'allApps'). | 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 | Initializer succeeds; PulseAudio/PipeWire volume backend is 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 |
|
||||
@@ -76,7 +76,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 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 | broken | Private SteamClient JSON cache now works; initialization still fails on missing Steam appStore.m_mapApps. Live app data and MobX game observations are unavailable; backend actions were denied. | 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 |
|
||||
@@ -89,7 +89,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 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 | broken | Country lookup calls missing SteamClient.User.GetIPCountry; deal loading fails after initialization. | 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 |
|
||||
@@ -99,7 +99,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 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 | broken | Initializer succeeds; game selection fails because SteamClient.InstallFolder.GetInstallFolders is unavailable. | 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 |
|
||||
@@ -149,7 +149,7 @@ The API column shows manifest / actual bundled frontend API. Many manifests say
|
||||
| 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 | Initializer succeeds; actual audio mixer backend and volume 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
|
||||
@@ -180,7 +180,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
|
||||
| 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 | sudo |
|
||||
| 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. |
|
||||
@@ -191,7 +191,7 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
|
||||
| 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 | pactl |
|
||||
| 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. |
|
||||
@@ -272,13 +272,15 @@ DFL includes exports imported through decky-frontend-lib / @decky/ui. Steam stor
|
||||
| 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 | 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. |
|
||||
@@ -302,7 +304,7 @@ All published Linux native payloads in this snapshot are x86_64; no published aa
|
||||
| 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 | LocalSend-Go commit 66edc509a28b9f877e7374504892dcbc653a4da4 cross-compiled successfully with CGO_ENABLED=0 GOOS=linux GOARCH=arm64. Static aarch64 ELF SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Not executed; source tip is not proven to match the shipped helper/protocol. Official ZIP is still x86_64. |
|
||||
| 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. |
|
||||
|
||||
|
||||
+12
-1
@@ -124,6 +124,17 @@ 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
|
||||
@@ -180,7 +191,7 @@ 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"
|
||||
@@ -36,7 +36,7 @@ Ready means initializer completion, not useful plugin behavior. No plugin backen
|
||||
<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;
|
||||
const plugins=CATALOG_DATA, librarySnapshot=LIBRARY_DATA;
|
||||
window.results=[];let active=null,running=false;
|
||||
addEventListener('message',event=>{
|
||||
if(event.source!==panel.contentWindow||!active)return;
|
||||
@@ -45,6 +45,9 @@ addEventListener('message',event=>{
|
||||
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'},'*');}
|
||||
@@ -53,7 +56,8 @@ addEventListener('message',event=>{
|
||||
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:[]};
|
||||
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));
|
||||
@@ -71,9 +75,17 @@ def main():
|
||||
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, *_):
|
||||
@@ -85,7 +97,8 @@ def main():
|
||||
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).encode()
|
||||
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])]
|
||||
|
||||
@@ -23,7 +23,7 @@ def render(data):
|
||||
'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 a limited frontend/settings surface initialized or rendered. Backend-dependent functionality remains unverified; it is not an end-to-end success claim.',
|
||||
'- **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', '',
|
||||
@@ -51,11 +51,23 @@ def render(data):
|
||||
'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. '
|
||||
'All other official backends remain unexecuted. '
|
||||
'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 '
|
||||
@@ -82,6 +94,9 @@ def render(data):
|
||||
'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']:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}),
|
||||
@@ -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];
|
||||
@@ -99,6 +117,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');
|
||||
@@ -181,6 +210,31 @@ 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();
|
||||
|
||||
@@ -47,3 +47,22 @@ assert.equal(replies.length,1);assert.equal(replies[0].result.events[0].args[0],
|
||||
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);
|
||||
@@ -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);
|
||||
|
||||
+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:
|
||||
@@ -202,6 +202,29 @@ class StoreTransaction(unittest.TestCase):
|
||||
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):
|
||||
@@ -218,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:
|
||||
@@ -226,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):
|
||||
@@ -257,7 +282,8 @@ class StoreHost(unittest.TestCase):
|
||||
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'}]}
|
||||
{'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 = []
|
||||
@@ -269,6 +295,7 @@ class StoreHost(unittest.TestCase):
|
||||
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':
|
||||
|
||||
@@ -20,6 +20,86 @@ 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):
|
||||
@@ -110,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)
|
||||
@@ -146,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'}]}):
|
||||
@@ -155,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()
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -123,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
|
||||
@@ -151,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()
|
||||
@@ -261,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:
|
||||
@@ -268,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:
|
||||
|
||||
@@ -2871,12 +2871,13 @@
|
||||
"https://github.com/andrewburgess/steamdeck-discord-status.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "RunningApps and appStore are absent; Discord activity integration fails after initialization.",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 37,
|
||||
@@ -2911,6 +2912,11 @@
|
||||
"sha256": "00a45dd919abb6e050685e1e408ce8023b9c4a09d6e91ba528d50aed2a63bf45",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"libraryFollowUp": {
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2985,12 +2991,13 @@
|
||||
"https://github.com/mk-fg/python-pulse-control"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Initializer succeeds; user PulseAudio/PipeWire mixer backend and volume changes unverified.",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Linux backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 38,
|
||||
@@ -3013,13 +3020,19 @@
|
||||
"folder": "VolumeMixer-decky"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "sudo",
|
||||
"hardware": "PulseAudio/PipeWire, libpulse/libdbus and D-Bus; optional Bluetooth playback",
|
||||
"uiSurfaces": [],
|
||||
"fakeFrame": {
|
||||
"id": 38,
|
||||
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"localBackend": {
|
||||
"runtime": "Decky 3.3.0 amd64",
|
||||
"evidence": "docs/evidence/framey-audio-backends-2026-10-26.json",
|
||||
"audio": "private silent virtual targets",
|
||||
"realFrame": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3351,13 +3364,14 @@
|
||||
"https://github.com/geeksville/steamback",
|
||||
"https://github.com/geeksville/steamback.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Frontend audit failed: Cannot read properties of undefined (reading 'm_CurrentUser'). Steam account/app context is unavailable.",
|
||||
"status": "partial",
|
||||
"reason": "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.",
|
||||
"blockInstall": true,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 46,
|
||||
@@ -3384,6 +3398,11 @@
|
||||
"archiveValidation": "Unsafe plugin archive member",
|
||||
"result": "blocked",
|
||||
"reason": "ValueError: Unsafe plugin archive member. Frontend audit failed: App is not defined."
|
||||
},
|
||||
"libraryFollowUp": {
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3835,12 +3854,13 @@
|
||||
"https://github.com/safijari/Shotty"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Frontend audit failed: Cannot read properties of undefined (reading 'allApps').",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 53,
|
||||
@@ -3866,6 +3886,11 @@
|
||||
"sha256": "ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"libraryFollowUp": {
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4005,12 +4030,13 @@
|
||||
"https://github.com/saumya-banthia/volume-boost.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Initializer succeeds; PulseAudio/PipeWire volume backend is unverified.",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Linux backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 58,
|
||||
@@ -4033,13 +4059,19 @@
|
||||
"folder": "volume-boost"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "pactl",
|
||||
"hardware": "PulseAudio/PipeWire and pactl; hard-coded /run/user/1000",
|
||||
"uiSurfaces": [],
|
||||
"fakeFrame": {
|
||||
"id": 58,
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"localBackend": {
|
||||
"runtime": "Decky 3.3.0 amd64",
|
||||
"evidence": "docs/evidence/framey-audio-backends-2026-10-26.json",
|
||||
"audio": "private silent virtual targets",
|
||||
"realFrame": false
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4922,13 +4954,14 @@
|
||||
"https://github.com/pnpm/pnpm",
|
||||
"https://github.com/your-username/PlayTime"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Private SteamClient JSON cache now works; initialization still fails on missing Steam appStore.m_mapApps. Live app data and MobX game observations are unavailable; backend actions were denied.",
|
||||
"status": "partial",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 66,
|
||||
@@ -4969,6 +5002,11 @@
|
||||
"sha256": "10fc3beea48991c77cb02867e919b618d4cd6d5e9c6a6d0ccd5266c13214039f",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"libraryFollowUp": {
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -6154,27 +6192,24 @@
|
||||
"https://github.com/JtdeGraaf/IsThereAnyDeal-DeckyPlugin",
|
||||
"https://github.com/JtdeGraaf/IsThereAnyDeal-DeckyPlugin.git"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Country lookup calls missing SteamClient.User.GetIPCountry; deal loading fails after initialization.",
|
||||
"status": "partial",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 81,
|
||||
"name": "IsThereAnyDeal for Deck",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "rejection",
|
||||
"message": "TypeError: SteamClient.User.GetIPCountry is not a function"
|
||||
}
|
||||
],
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"settings_load"
|
||||
"settings_load",
|
||||
"settings_save"
|
||||
]
|
||||
},
|
||||
"archiveValidation": {
|
||||
@@ -7201,13 +7236,14 @@
|
||||
"https://github.com/DeckSettings/game-reports-steamos",
|
||||
"https://github.com/SteamDeckHomebrew/decky-frontend-lib"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Initializer succeeds; game selection fails because SteamClient.InstallFolder.GetInstallFolders is unavailable.",
|
||||
"status": "partial",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"real Frame SSH"
|
||||
],
|
||||
"browser": {
|
||||
"id": 93,
|
||||
@@ -7217,11 +7253,11 @@
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: TypeError: Cannot read properties of undefined (reading 'GetInstallFolders')"
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: TypeError: Cannot read properties of undefined (reading 'allGamesCollection')"
|
||||
}
|
||||
],
|
||||
"methods": []
|
||||
@@ -7243,6 +7279,11 @@
|
||||
"sha256": "23c2a933a6883e2622f0184c33a76ce4a4e88c0a7dcd730f9bab71b8c3702132",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"libraryFollowUp": {
|
||||
"evidence": "docs/evidence/framey-library-context-2026-10-26.json",
|
||||
"mode": "real Frame read-only CEF projection; local Chromium snapshot replay; scoped actual amd64 backends",
|
||||
"frontendReady": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -10900,7 +10941,7 @@
|
||||
},
|
||||
"backend": "Python + native payload",
|
||||
"hardware": "No specific hardware requirement detected statically; runtime requirements unverified.",
|
||||
"nativePort": "LocalSend-Go commit 66edc509a28b9f877e7374504892dcbc653a4da4 cross-compiled successfully with CGO_ENABLED=0 GOOS=linux GOARCH=arm64. Static aarch64 ELF SHA-256 e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9. Not executed; source tip is not proven to match the shipped helper/protocol. Official ZIP is still x86_64.",
|
||||
"nativePort": "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.",
|
||||
"uiSurfaces": [
|
||||
"local/Steam routes",
|
||||
"toasts",
|
||||
@@ -13223,12 +13264,13 @@
|
||||
"https://github.com/allmazz/decky-volume-mixer.git"
|
||||
],
|
||||
"status": "partial",
|
||||
"reason": "Initializer succeeds; actual audio mixer backend and volume changes unverified.",
|
||||
"reason": "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.",
|
||||
"blockInstall": false,
|
||||
"verification": [
|
||||
"static archive",
|
||||
"local browser",
|
||||
"fake-Frame"
|
||||
"fake-Frame",
|
||||
"isolated Linux backend"
|
||||
],
|
||||
"browser": {
|
||||
"id": 144,
|
||||
@@ -13256,7 +13298,7 @@
|
||||
"folder": "decky-volume-mixer"
|
||||
},
|
||||
"backend": "Python",
|
||||
"hardware": "flatpak",
|
||||
"hardware": "PipeWire, pw-dump and wpctl",
|
||||
"uiSurfaces": [
|
||||
"toasts"
|
||||
],
|
||||
@@ -13265,6 +13307,12 @@
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
|
||||
"archiveValidation": "accepted",
|
||||
"result": "installed-removed"
|
||||
},
|
||||
"localBackend": {
|
||||
"runtime": "Decky 3.3.0 amd64",
|
||||
"evidence": "docs/evidence/framey-audio-backends-2026-10-26.json",
|
||||
"audio": "private silent virtual targets",
|
||||
"realFrame": false
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+50
-4
@@ -192,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…');
|
||||
@@ -335,10 +342,13 @@
|
||||
'RegisterForGameActionStart','RegisterForGameActionEnd','RegisterForGameActionUserRequest']),
|
||||
GameSessions:register(['RegisterForAppLifetimeNotifications','RegisterForScreenshotNotification']),
|
||||
System:{...register(['RegisterForOnResumeFromSuspend','RegisterForOnSuspendRequest']),
|
||||
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.
|
||||
@@ -464,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;
|
||||
|
||||
+55
-4
@@ -81,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]) + ')})()'
|
||||
@@ -96,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')
|
||||
@@ -104,8 +104,59 @@ 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'''
|
||||
|
||||
+5
-2
@@ -260,9 +260,12 @@ window.addEventListener('message',async event=>{
|
||||
event.source.postMessage(reply,'*');
|
||||
return;
|
||||
}
|
||||
if(req.type!=='framey:call'||req.session!==frameySession||!Number.isSafeInteger(req.id)||req.id<1||typeof req.method!=='string'||!Array.isArray(req.args))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('/api/framey/method',{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
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&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
|
||||
});
|
||||
|
||||
@@ -586,6 +586,15 @@ 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)
|
||||
@@ -3462,6 +3471,7 @@ 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,
|
||||
|
||||
Reference in new issue
Block a user