mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-26 06:00:26 +01:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3b0dd6f9 | ||
|
|
67b9151513 | ||
|
|
ce6e8f807f | ||
|
|
e7fe34d865 | ||
|
|
fd0b0a946f | ||
|
|
2895d5711e | ||
|
|
1ad6a97a36 | ||
|
|
b795c1dc56 | ||
|
|
db079eec9f | ||
|
|
bc72c41be5 | ||
|
|
697e68f098 | ||
|
|
3921e59035 | ||
|
|
b863527c33 | ||
|
|
c08f5df677 | ||
|
|
c62bffc8ef | ||
|
|
aeb6bb5294 | ||
|
|
792e9c016e | ||
|
|
d90d111218 |
No files matched your search
@@ -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.
|
||||
@@ -178,20 +179,25 @@ notarization. The DMG is unsigned. First verify the download against the
|
||||
`SHA256SUMS` from the same release, then open the DMG and drag **Frame Control**
|
||||
to **Applications**.
|
||||
|
||||
Check your processor before opening: v0.4.36 and earlier publish only
|
||||
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. See the [real-Mac test record](docs/evidence/mac-desktop-2026-10-26.md).
|
||||
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 Apple Silicon.
|
||||
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
|
||||
|
||||
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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,17 +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 |
|
||||
|---|---|---|---|---|
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): v0.4.35/v0.4.36 DMGs verified and installed; both fail to launch because they are ARM64-only. Separate Intel candidate launched: UI, native preference read/write (restored), bundled Python logs, latest-version check and offline OBS Start/Stop checked; released UI browsed 116 Decky store entries | No | Candidate results are not released-build acceptance. Native Open at Login state, Gatekeeper override, real OBS/Frame stream, native settings across restart, headset transfer and wearer interaction remain unverified | 26 Oct, v0.4.35/v0.4.36 and Intel candidate `6ea9eb2`: [real-Mac evidence](evidence/mac-desktop-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 installed and removed reserved native/Proton titles on the real Frame; all 13 steps passed. ARM64 and Windows titles launched | No | The x86_64 title could not launch because Steam Linux Runtime 4.0 was not installed. The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, v0.4.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) |
|
||||
@@ -23,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,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,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.
|
||||
@@ -5,6 +5,10 @@ Host: **MacBook Air (MacBookAir9,1), Intel Core i3 1.1 GHz, macOS 15.7.7
|
||||
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
|
||||
|
||||
@@ -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,7 +4,7 @@ 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: 45 · Broken: 59 · 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 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.
|
||||
@@ -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. 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. 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).
|
||||
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,17 +56,17 @@ 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 |
|
||||
| 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 | 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 |
|
||||
@@ -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 |
|
||||
@@ -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 | Real Steam install folders are now available; game selection still fails on missing collectionStore.allGamesCollection. Steam library context, proxy fetch and backend actions remain unsupported/unverified. | 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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +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,error:'Read-only compatibility audit: live Steam API blocked'},'*');
|
||||
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'},'*');}
|
||||
@@ -54,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));
|
||||
@@ -72,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, *_):
|
||||
@@ -86,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])]
|
||||
|
||||
@@ -55,6 +55,11 @@ def render(data):
|
||||
'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. '
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}),
|
||||
@@ -210,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();
|
||||
|
||||
+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':
|
||||
|
||||
@@ -27,7 +27,7 @@ class Panel(unittest.TestCase):
|
||||
def fake_ssh(command, stdin, **_):
|
||||
self.assertEqual(command, 'python3 -')
|
||||
compile(stdin, '<Steam read>', 'exec')
|
||||
self.assertIn(expression, stdin)
|
||||
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')
|
||||
@@ -44,7 +44,8 @@ class Panel(unittest.TestCase):
|
||||
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()'))
|
||||
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,{})
|
||||
@@ -53,6 +54,45 @@ class Panel(unittest.TestCase):
|
||||
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'})
|
||||
@@ -150,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)
|
||||
@@ -186,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'}]}):
|
||||
@@ -195,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'))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3358,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,
|
||||
@@ -3391,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
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -3842,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,
|
||||
@@ -3873,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
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4936,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,
|
||||
@@ -4983,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
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7212,13 +7236,14 @@
|
||||
"https://github.com/DeckSettings/game-reports-steamos",
|
||||
"https://github.com/SteamDeckHomebrew/decky-frontend-lib"
|
||||
],
|
||||
"status": "broken",
|
||||
"reason": "Real Steam install folders are now available; game selection still fails on missing collectionStore.allGamesCollection. Steam library context, proxy fetch and backend actions remain unsupported/unverified.",
|
||||
"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,
|
||||
@@ -7254,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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+37
-1
@@ -474,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;
|
||||
|
||||
+22
-6
@@ -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,10 @@ 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
|
||||
@@ -114,6 +116,19 @@ 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
|
||||
@@ -122,7 +137,7 @@ from frame_steam import Page
|
||||
page = Page()
|
||||
try:
|
||||
encoded = json.dumps({'result':page.eval(__FRAMEY_STEAM_READ__)})
|
||||
if len(encoded.encode('utf-8')) > 256*1024:
|
||||
if len(encoded.encode('utf-8')) > __FRAMEY_STEAM_LIMIT__:
|
||||
raise ValueError('Steam read response is too large')
|
||||
print(encoded)
|
||||
finally: page.sock.close()
|
||||
@@ -139,7 +154,8 @@ def read_steam(ssh, plugin_id, method):
|
||||
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))
|
||||
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))
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -262,7 +262,7 @@ window.addEventListener('message',async event=>{
|
||||
}
|
||||
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'].includes(req.method)))return;
|
||||
if(steamRead&&(req.args.length!==0||!['GetIPCountry','GetInstallFolders','GetSystemInfo','GetLibrarySnapshot'].includes(req.method)))return;
|
||||
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
|
||||
try{const data=await api(steamRead?'/api/framey/steam':'/api/framey/method',
|
||||
{id:pluginId,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
|
||||
|
||||
Reference in new issue
Block a user