Compare commits

...
22 Commits
Author SHA1 Message Date
Alex Southwell 98909cb543 Merge pull request #302 from saphid/release/v0.4.42
Prepare Frame Control v0.4.42
2026-10-26 05:52:18 +00:00
SAPHID 1903153d7b docs: record exact v0.4.42 Frame candidate gates 2026-10-26 05:43:12 +00:00
SAPHID 27af46dd20 Record receipt guard in v0.4.42 candidate 2026-10-26 05:38:34 +00:00
SAPHID 33d85dff1c Merge remote-tracking branch 'origin/main' into release/v0.4.42 2026-10-26 05:37:36 +00:00
Alex Southwell ad57662d40 Merge pull request #301 from saphid/fix/framey-receipt-bound-api
Reject stale Framey panel requests after plugin changes
2026-10-26 05:37:04 +00:00
SAPHID 5381667947 Update v0.4.42 candidate with Intel Mac evidence 2026-10-26 05:33:31 +00:00
SAPHID 7cdff4285a Merge remote-tracking branch 'origin/main' into release/v0.4.42 2026-10-26 05:32:40 +00:00
Alex Southwell fe1da43171 Merge pull request #303 from saphid/fleet/1vbt29ptkw
Record official Intel Mac v0.4.39 acceptance and fix host tests
2026-10-26 05:31:37 +00:00
SAPHID 1c1d941a1c Document locked unworn Framey receipt exercise 2026-10-26 05:27:46 +00:00
SAPHID c2f94c242d Give unworn Framey sample a content receipt 2026-10-26 05:25:48 +00:00
SAPHID e6e42a93f0 Fix stale receipt e2e response assertion 2026-10-26 05:23:47 +00:00
Alex Southwell f2f7a3f4c1 Merge current main and retain Intel acceptance evidence 2026-10-26 16:21:49 +11:00
Alex Southwell 5c4c447d2d Test official Intel Mac v0.4.39 and fix host validation fixtures 2026-10-26 16:21:03 +11:00
SAPHID 63f1f7fb39 Prepare Frame Control v0.4.42 candidate 2026-10-26 05:20:11 +00:00
Alex Southwell c88736c6ce Merge pull request #300 from saphid/fix/smoke-runtime-skip
Report unavailable x86 runtime as skipped in Frame smoke
2026-10-26 05:16:15 +00:00
SAPHID fa354ce365 Bind Framey panel requests to installed plugin receipt 2026-10-26 05:16:02 +00:00
Alex Southwell fcdd1f22f5 Merge pull request #299 from saphid/fix/framey-close-stale-panel
Close stale Framey panels during plugin changes
2026-10-26 05:15:21 +00:00
Alex Southwell d43e760aef Merge pull request #298 from saphid/docs/v0441-release-evidence
Document v0.4.41 tagged release verification
2026-10-26 05:12:14 +00:00
SAPHID a3e0ec01d4 Report unavailable x86 runtime as skipped in Frame smoke 2026-10-26 05:09:01 +00:00
SAPHID 240288df65 Close stale Framey panels during plugin changes 2026-10-26 05:06:43 +00:00
SAPHID a68c5584a9 Document v0.4.41 tagged release verification 2026-10-26 05:03:11 +00:00
Alex Southwell b5d50a8d89 Merge pull request #297 from saphid/release/v0.4.41
Prepare Frame Control v0.4.41
2026-10-26 04:50:27 +00:00
27 changed files with 833 additions and 95 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.41",
"version": "0.4.42",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.41",
"version": "0.4.42",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.41",
"version": "0.4.42",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+5 -5
View File
@@ -1,11 +1,11 @@
# What we have tested
Release baseline: Frame Control **v0.4.40**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
Release baseline: Frame Control **v0.4.41**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
| 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 shipped in v0.4.40 with physical-Mac source regression coverage, but the tagged Intel installer was not launched on physical Intel hardware | 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 ran the exact v0.4.40 tagged source against the real Frame; all 13 reserved native/Proton title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.40 tag `a4d8073`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0440-release-2026-10-26.md); [earlier candidate smoke](evidence/v0440-candidate-frame-2026-10-26.md) |
| 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 and ZIP hashes/architectures verified; clean DMG app installed and launched. Native UI, preferences across restart, update discovery of 0.4.40, offline OBS Start/Stop and 116-entry Decky store checked. Actual Open at Login menu toggled on, persisted across Quit/relaunch, then restored off. A 54-byte relay upload/readback/delete passed under coordinator/headset locks | No | Ordinary browser quarantine, right-click Open and Open Anyway remain unverified; the synthetic quarantine probe was inconclusive. Actual login startup untested. Direct frame.local had no route and app SSH trust failed; relay transfer is not native Mac transfer acceptance. Real OBS/Framey VR unverified. Earlier shutdown race fix #284 shipped in v0.4.40, whose Intel installer was not tested here | 26 Oct, official v0.4.39 tag `fd0b0a9`: [repeat Intel acceptance evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); [earlier release test](evidence/mac-v0439-2026-10-26.md) |
| Core title smoke test | Linux lxso2 ran the exact v0.4.41 tagged source against the real Frame; 13/13 reserved title steps passed and cleanup completed | No | The headset was unworn; visible rendering and controller input were not checked. Optional Steam Linux Runtime 4 was absent, so the x86_64 test executable did not actually launch. Other feature paths retain their separate limits below | 26 Oct, exact v0.4.41 tag `b5d50a8`, Frame OS 0.5.6 build `20261022.6153909`: [tagged release evidence](evidence/v0441-release-2026-10-26.md); [earlier candidate smoke](evidence/v0441-candidate-frame-2026-10-26.md) |
| 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) |
@@ -21,10 +21,10 @@ Release baseline: Frame Control **v0.4.40**, 26 October 2026. Rows also identify
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
| 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) |
| Open at login | No Frame needed; official v0.4.39 on the physical Intel Air toggled the actual native menu on, retained its checkmark after Quit/relaunch and restored it off. Linux autostart file previously toggled on a physical Linux host with an isolated config directory | No | A full OS login session and physical Windows host login remain untested | 26 Oct, v0.4.39: [native Intel toggle evidence](evidence/intel-mac-v0.4.39-2026-10-26.md); Linux 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 | 26 Oct, v0.4.29–0.4.40: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [post-#292 unworn sample exercise](evidence/v0440-release-2026-10-26.md#post-tag-292-panel-regression), [earlier 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. v0.4.40 classifies the 116 exact-version plugins as 1 works (Notebook default flow only), 49 partial, 55 broken and 11 not applicable. Post-tag PRs #289 and #293 give 3 works / 48 partial / 54 broken / 11 N/A on current main: TabMaster is partial, and Volume Mixer and Mixer work for tested per-app silent-stream volume control only. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR 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.40: [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), [ARM64 audio evidence](evidence/framey-audio-arm64-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.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. v0.4.41 classifies the 116 exact-version plugins as 3 works, 48 partial, 54 broken and 11 not applicable. Notebook works for its default flow; Volume Mixer and Mixer work for tested per-app silent-stream volume control only. TabMaster is partial. Deck Shelves moved to partial after its exact chunked frontend rendered settings in local Chromium; its backend and Steam Home shelves remain unverified. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Volume Mixer and Mixer controlled a named silent stream through real ARM64 Decky with independent Pulse readbacks; Volume Boost default-sink reads matched, but real writes were denied. Audible output, Bluetooth playback, internal-node controls and worn VR 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.41: [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), [ARM64 audio evidence](evidence/framey-audio-arm64-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; v0.4.40 recorded 101 local Chromium initializers ready and 15 failed, up from 98/18 in v0.4.39. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.40: [post-#294 unworn sample and scroll evidence](evidence/framey-scroll-probe-2026-10-26.md#post-294-exact-main-sample-check), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [post-release sample exercise](evidence/v0439-release-2026-10-26.md#post-release-framey-library-change-282), [worn checklist](framey-vr-check.md) |
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
@@ -0,0 +1,9 @@
# Headset smoke: unavailable x86 runtime is a skip
The v0.4.41 tagged smoke result recorded 13/13 passed even though its x86_64 launch outcome was `runtime missing`: Steam had acknowledged the title but had not started it. This follow-up separates an unavailable optional runtime from a verified launch. It keeps the smoke usable on a Frame without Steam Linux Runtime 4 while making the missing coverage explicit in its console summary and saved JSON.
Focused local `test_smoke_cleanup.py` ran **22 tests successfully**. The new regression starts an x86_64 title cycle with a fresh missing-runtime Steam log, verifies `skipped=true` and `outcome=runtime missing`, and verifies that the title is still removed. A result test checks separate passed/skipped/failed counts and a zero exit code when an optional runtime is absent but nothing fails.
The revised `scripts/frame-smoke.sh` then ran on the real Frame from this branch. It acquired its unique owner in `/tmp/frame-test.lock` before any headset read. Locked preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, the result was **12 passed / 1 skipped / 0 failed**. ARM64 and Proton test executables ran; x86_64 was explicitly skipped because Steam Linux Runtime 4 was not installed. Both title cleanup steps passed. The command exited 0, including release of its owned shared lock. The detailed result remains private at `tests/smoke/results/2026-10-26T050711Z-20261022.6153909.json` in the test checkout.
The headset was in standby and nobody wore it for this test. Title smoke does not prove wearer-visible pixels, physical controller input, official Decky panels, audio output or comfort. It also does not prove x86_64 runtime execution on this Frame; that path needs the optional runtime present. The hourly worn check remains enabled.
@@ -0,0 +1,11 @@
# Framey receipt guard, unworn Frame exercise (26 October 2026)
The bounded `scripts/framey-vr-check.py --exercise` runner acquired `/tmp/frame-test.lock` with its own random owner before reading the real Frame. Its 05:26 UTC run used source commit `c2f94c242dac670e18259193311a7375f5a87942`. Preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%. No wearer was confirmed.
The temporary Frame Control dashboard overlay became visible to OpenVR. The stereo compositor image changed, and the `/dev/video99` mirror produced a frame. The sample settings panel displayed its stored off value, accepted a trusted synthetic OpenVR pointer event, saved on through the real Decky Python backend, read the setting from disk, and showed on after a fresh iframe reopen. The quick action read on. A native slider reached 50. Text entry used CDP after OpenVR focus. The runner exited 0 with `input.passed=true` and `compositorChanged=true`.
The private capture files remain outside the repository at `/tmp/frame-pr301-unworn-vr-fixed/`. The before/after stereo SHA-256 values were `1683bf1dab70c1a0a1e8f313c8317dc04f992070b5b0de232de9008700130711` and `684a27eecf7bcbbc5308092ddbb1517c4596fb283e061e73dd6bb7f025954e8b`; the mirror frame SHA-256 was `5939149ea9c85fd2d86d3f8879c348f006c421a386bd66a93fe`. Room pixels are not committed.
An earlier locked run at 05:24 UTC exposed a missing SHA-256 in the runner's temporary sample receipt: the new guard correctly asked to reopen the panel. Commit `c2f94c2` gives that sample a content digest; the second run passed. Both runs reported no cleanup errors, restored `power.pauseCompositorOnStandby=false` and `power.turnOffScreensTimeout=5`, removed their power recovery timers, and released only their own locks. Battery remained 99%.
This is an **unworn sample-plugin result**. It does not prove wearer-visible pixels, physical controller input, native scrolling (the attempted scroll offset stayed 0), VR keyboard behavior, comfort, or any official Decky plugin's VR panel or backend. The hourly worn check remains enabled.
@@ -0,0 +1,288 @@
{
"release": "v0.4.39",
"tagCommit": "fd0b0a946f11d92fd1cc480cd9d1a83be795bf71",
"job": "1vbt29ptkw",
"date": "2026-10-26",
"host": {
"model": "MacBookAir9,1",
"cpu": "Intel Core i3-1000NG4",
"arch": "x86_64",
"macOS": "15.7.7",
"build": "24G720"
},
"artifacts": {
"Frame-Control-mac-x64.dmg": {
"bytes": 169230398,
"sha256": "f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8",
"matchesSHA256SUMS": true
},
"Frame-Control-mac-x64.zip": {
"bytes": 169141014,
"sha256": "bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7",
"matchesSHA256SUMS": true
}
},
"native": {
"title": "Apps \u00b7 Frame Control",
"bridge": true,
"preferencesBefore": {},
"views": [
{
"view": "apps",
"title": "Apps \u00b7 Frame Control",
"heading": [
"Apps"
]
},
{
"view": "games",
"title": "Games \u00b7 Frame Control",
"heading": [
"Games"
]
},
{
"view": "tools",
"title": "Tools \u00b7 Frame Control",
"heading": [
"Tools"
]
},
{
"view": "settings",
"title": "Settings \u00b7 Frame Control",
"heading": [
"Settings"
]
},
{
"view": "privacy",
"title": "Privacy & updates \u00b7 Frame Control",
"heading": [
"Privacy & updates"
]
},
{
"view": "capture",
"title": "Capture \u00b7 Frame Control",
"heading": [
"Capture & Live"
]
},
{
"view": "decky",
"title": "Decky Loader \u00b7 Frame Control",
"heading": [
"Decky Loader"
]
}
],
"decky": {
"state": "116 plugins in the official store. Select a plugin to install it.",
"rows": 40,
"search": "ProtonDB Badges \u00b7 1.3.4 Display tappable ProtonDB badges on your game pages\n\npartial: Settings render locally and read-only get_setting RPC returned the default through real Decky 3.3.0 on ARM64 Frame. Steam game-page badges have no patch target; live writes and VR controls unverified. Verified: static archive, local browser, fake-Frame, real Frame SSH.\n\nInstall",
"installDisabled": [
{
"text": "Install",
"disabled": true
}
]
},
"preferenceWritten": "0",
"preferencesAfter": {
"mvSeparate": "0"
},
"update": {
"status": "available",
"current": "0.4.39",
"error": null,
"canInstall": true,
"latest": "0.4.40"
},
"obs": {
"before": {
"state": "idle",
"alias": "",
"port": 15099,
"error": "",
"supported": true
},
"start": {
"state": "starting",
"alias": "frame",
"port": 15139,
"error": "",
"supported": true
},
"stop": {
"state": "stopping",
"alias": "frame",
"port": 15139,
"error": "",
"supported": true
},
"after": {
"state": "idle",
"alias": "frame",
"port": 15139,
"error": "",
"supported": true
}
},
"preferencesAfterRestart": {
"mvSeparate": "0"
}
},
"loginItem": {
"enabledBeforeQuit": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
"nativeQuit": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\nPRESS 0\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
"restartPid": 50393,
"enabledAfterRestart": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
"beforeRestore": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
"restoreOff": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": \"\\u2713\", \"role\": \"AXMenuItem\"}\nPRESS 0\n",
"finalMenu": "trusted True\n{\"title\": \"Check for Updates\\u2026\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Quit Frame Control\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Open at Login\", \"mark\": null, \"role\": \"AXMenuItem\"}\n{\"title\": \"Show Server Log\", \"mark\": null, \"role\": \"AXMenuItem\"}\n",
"restoredOriginalOff": true
},
"macHelper": {
"permissions": {
"accessibility": false,
"screen": false
},
"displays": {
"displays": [
{
"h": 900,
"id": 69734464,
"main": true,
"name": "Built-in Retina Display",
"src": "display:69734464",
"w": 1440
}
]
},
"windows": {
"screen": false,
"count": 3,
"frameControlCount": 2
}
},
"headset": {
"timestamp": "2026-10-26T04:56:52.808203+00:00",
"route": "Mac SSH -> pinned lxso2 -> trusted frame SSH",
"owner": "intel-v0439-1vbt29ptkw-20261026",
"payloadBytes": 54,
"coordinatorLock": "locked\n",
"headset": {
"os": {
"VERSION_ID": "0.5.6",
"BUILD_ID": "20261022.6153909",
"VARIANT_ID": "vr"
},
"battery": [
{
"percent": 99,
"status": "Not charging"
}
],
"homeStorage": {
"total": 996984774656,
"free": 769012412416
},
"screenshotJpegs": 7,
"transfer": {
"path": "/tmp/intel-v0439-1vbt29ptkw-20261026-vtynzyky/intel-mac-check.txt",
"bytes": 54,
"sha256": "82029b7e8b519bac65e43a6e90386a06da9988b23330aa7c48fea0abdfac0a8b",
"deleted": true
}
},
"headsetLockReleased": true,
"coordinatorLockRelease": "released\n"
},
"gatekeeper": {
"downloadMethod": "gh release download",
"downloadQuarantinePresent": false,
"controlledSyntheticQuarantine": "inconclusive",
"cleanQuarantinedOpen": "NSPOSIXErrorDomain Code=16 Resource busy",
"cleanSpctl": "timeout after 15 seconds",
"rightClickOpenVerified": false,
"openAnywayVerified": false,
"appSpecificXattrFallbackUsed": true,
"globalGatekeeperChanged": false
},
"headsetCleanupReadback": {
"headset": {
"testFoldersAbsent": true,
"lockAbsent": false,
"subsequentLockOwner": "frame-smoke-92bb2294f1d9cd4b7084",
"subsequentLockLeftUntouched": true
},
"coordinatorLockAbsent": true
},
"cleanup": {
"debugPortClosed": true,
"originalPreferencesRestored": true,
"ownedAppRemaining": [],
"desk": {
"foreground_apps": [
"Finder",
"Messages",
"cmux"
],
"display_on": false,
"notifications_found": 0,
"attention": []
},
"dmgejected": true,
"installedSignatureValidAfterRuntime": true,
"finalInstalledSignature": {
"removedOnlyGeneratedBytecodeFiles": 36,
"finalSignatureExit": 0,
"finalSignatureStderr": ""
}
},
"validation": {
"sourceBase": "741735aa98e615fb51bb1a5adb665c9d12e3fd2a",
"initialMacFullSuite": {
"python": "system 3.9",
"tests": 1621,
"failures": 6,
"skipped": 20,
"seconds": 818.353
},
"affectedMacModulesAfterFix": {
"python": "official bundled 3.12",
"tests": 87,
"skipped": 2,
"seconds": 37.605,
"result": "OK"
},
"focusedAppTests": {
"tests": 14,
"passed": 14
},
"websiteTests": {
"tests": 18,
"passed": 18
},
"testFixes": [
"Skip local Frame recording shell fixtures on Bash 3.2, whose read timeout status differs from Frame Bash 5",
"Require actual XCTest.framework for standalone XCTest contracts",
"Separate OBS fixture interpreter startup from stream-stall deadlines and synchronize signal-handler readiness",
"Leave margin between panel fixture delays and individual X tool timeouts"
],
"linuxFullSuiteAfterFix": {
"tests": 1621,
"skipped": 16,
"seconds": 170.313,
"result": "OK",
"isolation": "temporary archived checkout on lxso2"
},
"testedFileSHA256": {
"tests/test_clips.py": "00ba70866cda2b6cacaa0dd9bd19e9f2a9532a035cef66c9d8e2bd0021b1f489",
"tests/test_ios_contracts.py": "f1cc22e54873617e381025233c6b422245e6943ba7432331fc2d01e7a9ae4fc1",
"tests/test_obs_headset_source.py": "62462cbadbfcf67d4c200edf54ee45697b6f8d6547cd4c538f0344d71cf70241",
"tests/test_touch.py": "c34c646d134e57130c4fb43cd3d53e6c859369f774f9b7fd1dd8c3e718b72b6f"
}
}
}
@@ -0,0 +1,181 @@
# Intel Mac acceptance test: official v0.4.39, 26 October 2026
Tested on the physical **MacBookAir9,1, Intel Core i3-1000NG4 1.10 GHz,
8 GB RAM, macOS 15.7.7 (24G720), x86_64**. Job `1vbt29ptkw` repeated the
[earlier release test](mac-v0439-2026-10-26.md) and added ZIP verification,
native menu actions and Open at Login persistence. The app under test was
the official [v0.4.39 release](https://github.com/saphid/frame-control/releases/tag/v0.4.39),
tag commit `fd0b0a946f11d92fd1cc480cd9d1a83be795bf71`.
It was not rebuilt or patched. Current source validation is reported separately.
## Download and clean DMG installation
Downloaded both Intel assets and `SHA256SUMS` from that exact tag with
`gh release download`. Independently calculated SHA-256 values matched the
same-release manifest:
| Asset | Bytes | SHA-256 |
|---|---:|---|
| `Frame-Control-mac-x64.dmg` | 169,230,398 | `f47b5a188b5090f97a72467394d4fa65ccc73fe3169a6276e2a32cab5c8019d8` |
| `Frame-Control-mac-x64.zip` | 169,141,014 | `bafc1aec0c11c1fae097dbe1e4b585ebc13a0f8eaf1b249717c14d78811e08d7` |
Mounted the DMG read-only with `hdiutil attach -readonly -nobrowse` and
unpacked the ZIP separately. For each app, `file` reported
`Mach-O 64-bit executable x86_64` and `lipo -info` reported:
```text
Non-fat file: …/Contents/MacOS/Frame Control is architecture: x86_64
```
The DMG's bundled Python and `frame-mac-view` are also x86_64; `adb` is
universal with x86_64 and arm64 slices. Both fresh bundles passed
`codesign --verify --deep --strict --verbose=2`. Signing is ad hoc, with no
TeamIdentifier. This verifies the resource seal, not Apple notarization.
Preserved the existing `/Applications/Frame Control.app` in the task's
temporary directory. An initial `ditto` overlay retained that installation's
extra Python bytecode caches and failed signature verification. Moved that
copy aside and copied the DMG app into an empty destination. The clean copy
passed `codesign --verify --strict`; the release bundles themselves passed.
The final installed app is the clean DMG copy.
## Gatekeeper: tested fallback, incomplete first-open coverage
The CLI downloads had **no `com.apple.quarantine` attribute**. This Air had
already launched the same app in the earlier job, so it is not a pristine
first-install trust environment. A controlled probe added only this app's
quarantine attribute (`0083;<timestamp>;FrameControlIntelReleaseTest;`).
That is synthetic metadata, not a Safari or Chrome download.
The overlaid copy's first `open` returned 0 and later produced a translocated
app process. After making the clean copy, a quarantined `open` returned:
```text
The application cannot be opened, error=Error Domain=NSPOSIXErrorDomain Code=16 "Resource busy"
```
Assessment of the clean copy with `spctl --assess --type execute -vv` did
not finish within 15 seconds. No readable Gatekeeper warning was captured;
the controlled probe is **inconclusive**. Do not interpret `open` returning
0 as proof of passing Gatekeeper. Native screen capture was unavailable.
The exact app-specific fallback used for the verified clean copy was:
```sh
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
```
The installed app then launched successfully. No global Gatekeeper setting
was changed. For a normal user, verify the Intel download, mount the DMG,
drag the app to Applications and open it. If macOS blocks it, Apple's
documented route is **System Settings → Privacy & Security → Open Anyway →
Open**, after the initial attempt. **Right-click Open and Open Anyway were
not exercised in this run**; neither is certified here. The Terminal command
above is the fallback actually exercised, limited to this trusted app.
## Official native app checks
Controlled runs disabled telemetry and used a task-local server data
directory. A temporary loopback CDP port inspected the actual Electron
renderer and its real `window.frameApp` bridge. This was not a web-only
substitute. Native menu actions used Apple's Accessibility API through an
existing authorized SSH session; no Accessibility grant was added. Direct
System Events/AppleScript calls timed out. The API route is why this run can
verify native menu actions that the earlier evidence left untested.
| Check | Result |
|---|---|
| Launch | **Pass.** Native window, real IPC bridge and bundled Python server loaded. Version remained 0.4.39 |
| Offline UI | **Pass.** Apps, Games, Tools, Settings, Privacy & updates, Capture & Live and Decky rendered their expected headings |
| Decky store | **Pass.** Official store returned 116 plugins, with 40 initial rows. Search displayed ProtonDB Badges 1.3.4 and its compatibility text. Install stayed disabled; no loader or plugin was installed |
| Settings save | **Pass.** Native preferences initially `{}`; `mvSeparate="0"` saved, read back and survived a real app restart. Restored the original absence of the preferences file afterward |
| Update check | **Pass.** Native IPC returned `available`, current 0.4.39, latest 0.4.40, `error=null`, `canInstall=true`. The actual Check for Updates menu action also succeeded. No update installed |
| OBS Start/Stop | **Partial.** Native status supported/idle → Start on port 15139 returned starting → Stop returned stopping → idle. This checks local helper lifecycle; no OBS receiver or headset stream was exercised |
| Open at Login | **Pass for toggle and persistence.** Native menu began unchecked. AXPress returned success, then the checkmark appeared. Native Quit/relaunch preserved it. Toggled off and independently read the unchecked menu back. Actual logout/login execution remains untested |
| Show Server Log | **Partial.** Native menu AXPress succeeded and launched Console. The log contains startup with the installed bundled Python/server paths. The viewer's displayed document was not independently read back |
| Intel Mac helper | **Pass for read-only commands.** `permissions`, `displays` and `windows` returned JSON. One 1440×900 built-in display was listed; screen/accessibility permissions were false. No capture or permission request was initiated by these commands |
[Native searched Decky store screenshot](intel-mac-v0.4.39-2026-10-26.png).
[Structured observations](intel-mac-v0.4.39-2026-10-26.json).
## Headset: read-only checks and one temporary transfer
`frame.local` resolved, but its LAN port 22 returned **No route to host**.
The Tailscale endpoint answered TCP 22, but direct SSH and the app refused
the Air's stale/missing host trust. No pin was replaced to bypass that
failure. Used the already-pinned `saphid@lxso2` coordinator relay and its
trusted `frame` SSH connection instead.
Atomically acquired `/tmp/frame-test.lock` on the coordinator and headset
with owner `intel-v0439-1vbt29ptkw-20261026`. Under those locks:
- Read SteamOS **0.5.6**, variant `vr`, build **20261022.6153909**.
- Read battery **99%, Not charging**.
- Read home storage: **996,984,774,656 bytes total**, **769,012,412,416 free**.
- Listed **7** JPEGs matching Steam's
`userdata/*/760/remote/*/screenshots/*.jpg`; no screenshots were copied.
- Sent a **54-byte** payload originating on this Mac through the relay into
an exclusively created `/tmp/intel-v0439-1vbt29ptkw-20261026-…/` folder.
Its readback SHA-256 matched
`82029b7e8b519bac65e43a6e90386a06da9988b23330aa7c48fea0abdfac0a8b`.
Deleted the exact file and its temporary folder.
- Released only matching owned locks. Separate readback confirmed the
headset lock was absent immediately after release. A later independent check
found the transfer folder absent and coordinator lock absent, while the
headset lock had been acquired by another job (`frame-smoke-92bb2294f1d9cd4b7084`);
that different owner was left untouched.
This verifies a relay transfer, **not the native app's direct file-transfer
path**. No existing headset content, installs, services, settings, power
state or SteamVR state was changed. No wipe or reboot was attempted.
## Bugs, validation and remaining limits
No new Intel-specific product bug was isolated in these runtime checks.
The earlier v0.4.39 shutdown race already has a merged fix in
[PR #284](https://github.com/saphid/frame-control/pull/284), shipped in
v0.4.40. This run did not install or validate the v0.4.40 Intel app.
The initial full source suite on system Python 3.9 ran **1,621 tests in
818.353 seconds**, with **six failed assertions and 20 skips**. These were
source-test issues, separate from the released native checks:
- The recording fixture's time limit ended with `q`, because macOS Bash 3.2
reports read timeout as status 1, the same as EOF. Frame Bash 5 reports a
value above 128. Local recording-shell tests now explicitly require Bash 4+.
- The Foundation XCTest test found `swiftc` but failed `import XCTest`:
the active Command Line Tools installation has no XCTest framework.
It now checks for the actual framework and skips when unavailable.
- Two OBS tests (three assertions, including subtests) started 0.15/0.3-second
watchdog clocks before the fixture's Python interpreter and signal handler
were ready. Fixture startup is now synchronized or separately budgeted;
the short stream-stall checks remain. Production watchdog limits did not change.
- The panel timeout fixture's 4.6-second sleeps were too close to each tool's
five-second timeout once process startup was included, causing the wrong
failure path. Three-second sleeps still exhaust the 12-second overall
deadline before a panel is complete, with margin for process startup.
All four affected modules then passed on this physical Air with the official
app's bundled Python 3.12: **87 tests, two explicit prerequisite skips,
37.605 seconds**. Focused native app tests passed **14/14** and website tests
**18/18**. An isolated Linux checkout of base `741735a` plus the exact four
test-file changes passed the **full 1,621-test suite, 16 skips, 170.313 seconds**,
including recording-shell fixtures under modern Bash. No full Mac suite pass
after these corrections is claimed.
Final native cleanup confirmed the app processes and loopback debug port were
closed, original preferences restored, Open at Login off, the DMG ejected and
its installed signature still valid. External test subprocesses had generated
36 Python bytecode files inside the bundle; removed only those absent from
the verified clean release before the final signature check. The native app
uses `-B` itself. The installed desk cleanup tool reported
only **Finder, Messages and cmux** in front, **zero notifications**, no items
needing attention and the **display asleep**. The official app remains installed;
previous copies and raw test logs are retained in `/tmp/frame-control-v0439-intel`.
The desk hold is released at the end of the task.
Actual login startup, ordinary browser quarantine/first-open override,
direct app/headset transfer, real OBS audio/video, Mac screen capture,
headset VR interaction, wearer comfort and controller input remain untested.
No social post was made. PR #113 was left untouched; merging and releasing
remain with the lxso2 coordinator.
Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

+23
View File
@@ -0,0 +1,23 @@
# v0.4.41 tagged release, 26 October 2026
The annotated `v0.4.41` tag dereferences to main merge commit `b5d50a8d896f790a94baf4e937db914eab475ed9` from PR #297. The [tagged release workflow](https://github.com/saphid/frame-control/actions/runs/40271174791) passed its draft, Linux, Windows, Apple Silicon Mac, Intel Mac and checksum jobs. The platform jobs launched the built Linux AppImage, checked the Windows installation and bundled PC Python libraries, and checked Mac bundle architectures. These CI results do not establish a physical Windows or Mac launch.
## Real Frame, exact tag
From an exact tag checkout, `scripts/frame-smoke.sh` acquired its unique owner in `/tmp/frame-test.lock` before reading the headset. Preflight found SteamVR activity 3 (standby) and battery 99%. On Frame OS 0.5.6 build `20261022.6153909`, all **13/13 reserved title steps passed**; the command exited 0, including test title and process cleanup and release of its owned lock. ARM64 and Proton test executables launched. The x86_64 launch step recorded that optional Steam Linux Runtime 4 was **not installed**, so that executable did not actually launch. The private detailed result is `tests/smoke/results/2026-10-26T045140Z-20261022.6153909.json` in the exact-tag checkout.
No person wore the headset. This title smoke does not verify active passthrough pixels, wearer-visible panels, physical controller input, official Decky plugin panels, audio output, comfort or VR keyboard use. The separate [unworn Framey sample exercise](framey-scroll-probe-2026-10-26.md) established live sample setting persistence, stereo compositor changes and trusted synthetic OpenVR input; it did not establish a worn result. The hourly worn check remains enabled.
## Downloaded installer and checksum checks
The draft held ten installers plus `SHA256SUMS`, each with a GitHub SHA-256 digest. `scripts/publish-release.sh --dry-run v0.4.41` checked the exact tag and all ten installer digests, regenerated `SHA256SUMS` from those digests and matched its own GitHub digest. A fresh downloaded checksum list independently matched `e7555956aeb9ec3a464081aaafe1ed6762f65a17c04edaa6ea45286f10927109`.
A fresh Linux x86_64 AppImage download matched `5d1f50dc8527faf299f2058c7b5ed19057ec7c81c41449f6ccd44d68e6b4d212`. `scripts/check-appimage-launch.py` launched that exact file under isolated Xvfb: the bundled server answered HTTP 200 with `FrameControl/1`, then stopped. The first local attempt failed at Chromium sandbox startup inside the command sandbox; rerunning the same downloaded file with normal host permissions passed. The release workflow's independent Linux launch also passed.
Fresh Mac ZIP downloads matched GitHub digests: arm64 `84615ec8a0c62481a9f33bae1815d55ec902db9aa9d4bd767fcf9380c3d44d7a`, x64 `2800b749cda98d15207950eca4a6bd205d35f30551f16efd7720b41347edc7ca`. Both contain bundle version 0.4.41; the app executable Mach-O CPU types are arm64 and x86_64 respectively. The physical Mac was unreachable over SSH, so neither tagged ZIP was launched there. The physical Intel Air was also not tested with this tag.
## Publication and updater readback
The final dry run passed with the release notes and hardware limits in the draft. `scripts/publish-release.sh v0.4.41` published [v0.4.41](https://github.com/saphid/frame-control/releases/tag/v0.4.41) at 2026-10-26 05:01:47 UTC. GitHub's latest-release API read back `draft=false`, tag `v0.4.41`, ten installers, `SHA256SUMS` and `update.json`. A fresh public latest-download of `update.json` parsed as version 0.4.41 with the stable release page, ten SHA-256 digests and the unworn-test limits in its notes. The release contains the tagged `b5d50a8` commit; no post-tag changes are implied.
The exact-version official Decky catalog in this release is **3 works / 48 partial / 54 broken / 11 N/A**. Volume Mixer and Mixer are works only for the tested per-app silent-stream volume scope on real ARM64 Decky; [audio evidence](framey-audio-arm64-2026-10-26.md) describes the independent Pulse readbacks. Volume Boost remains partial. Audible playback, Bluetooth, other mixer controls and official-plugin worn VR remain unverified.
@@ -0,0 +1,9 @@
# v0.4.42 candidate gate, 26 October 2026
This branch bumps the app and lockfile to 0.4.42. It includes the [v0.4.41 release evidence](v0441-release-2026-10-26.md), the Framey panel lifecycle change in PR #299, the truthful optional-runtime smoke result in PR #300, the physical Intel Mac acceptance and host-test fixes in PR #303 (`fe1da43171883dc31161ff607c6b5a6ddf75f6ef`), and the receipt-bound API guard in PR #301 (`ad57662d40d3bbf6c850dd80ddc275280d389c86`). All 11 hosted checks passed on PR #301's exact head before its normal merge. The combined release candidate's own headset gates passed; exact-head CI and installer previews remain pending.
The exact v0.4.42 candidate at `27af46dd20d7af30dde4c3f954d7b57b94843594` ran `scripts/frame-smoke.sh` under its unique shared lock at 05:40 UTC. Locked preflight found standby activity 3, proximity 6.25 and battery 99%. It recorded **12 passed / 1 skipped / 0 failed** on Frame OS 0.5.6 build `20261022.6153909`; ARM64 and Proton test executables launched. The x86_64 launch was skipped because Steam Linux Runtime 4 was not installed. Cleanup passed and the command exited 0. The private detailed result is `tests/smoke/results/2026-10-26T054014Z-20261022.6153909.json` in the candidate checkout. The prior PR #300 result is documented [separately](frame-smoke-runtime-skip-2026-10-26.md).
The exact candidate then ran `scripts/framey-vr-check.py --exercise` under a fresh owned lock at 05:41 UTC. Preflight again found activity 3 and battery 99%. The real Decky sample setting saved `enabled=true` and survived a fresh iframe; the quick action read the same value. A trusted synthetic OpenVR pointer reached the panel; the sample slider reached 50 and CDP text entry after OpenVR focus reached `framey-ci`. Stereo compositor hashes changed from `38265343e13f5915869c9622ab1ceda7c31ab2e9b050f44564cfb9b6dc9f6e6d` to `64cc4c6f865bb8b2f756632c922d17dee12af40d480171586f2e9a51797b83e6`; the mirror capture hash was `165289dca9ad2bd895eb544f5be5d5fa2db7d2f635a927ac68a0c74267f69ee2`. The exercise exited 0 with `passed=true`, battery 99% afterward, original power values restored (`false` / `5.000000`), recovery timer removed, no cleanup errors and its own lock released. Its private full result is `/tmp/framey-v0442-exercise/result.json` on the test host. Synthetic scroll events left `scrollTop=0` and produced no wheel events. No person wore the Frame; physical controller interaction, official plugin VR panels, optical readability, VR keyboard and comfort remain unverified. The earlier [receipt-guard exercise](framey-receipt-live-2026-10-26.md) is separate evidence.
The release tag and published installers do not exist at this candidate stage. Publication remains gated on this combined head's exact-head CI, then tagged installer checksums, package checks and public updater readback. The 116 exact-version Decky catalog remains **3 works / 48 partial / 54 broken / 11 N/A**; the two additional works claims are restricted to tested per-app silent-stream volume control. The official v0.4.39 Intel installer ran on a physical MacBook Air, but no tagged v0.4.42 Mac launch or real OBS audio/video check is claimed.
+9
View File
@@ -177,6 +177,15 @@ the store and review the new version before retrying. If Frame Control restarts,
job result. The panel clears that job ID and asks you to refresh the installed
list before making another change.
An open plugin panel closes before its own Update or Remove starts, so its old
frontend cannot call the changing backend. Open panel is unavailable while a
store job is pending. A later installed-status refresh also closes an open panel
if Decky stopped, its store receipt changed, or the status read failed. Reopen
the plugin after a successful refresh to use its current frontend.
The headset also includes the panel's store SHA-256 receipt with each backend
call, Steam read, and event poll. It rejects a request if the installed receipt
changed before the next status refresh; reopen the panel to use the new version.
## For plugin authors
Start with the [Framey settings sample](../examples/framey-settings/README.md)
+16 -12
View File
@@ -176,19 +176,21 @@ switch while SSH is down:
## Headset smoke test
**Documented shared-device procedure:** before a test installs, launches or
stops an application, acquire `ssh frame 'mkdir /tmp/frame-test.lock'`. If it
fails, leave that lock alone and continue offline work. Only the thread that
acquired it releases it with `ssh frame 'rmdir /tmp/frame-test.lock'`, after
cleanup. Keep each device session to a few minutes.
**Documented shared-device procedure:** before any headset read or a test that
installs, launches or stops an application, atomically create
`/tmp/frame-test.lock` on the Frame and write a unique token to its `owner`
file. If creation fails, leave that lock alone and continue offline work. Only
the owner that verifies its token may remove its `owner` file and lock directory
after cleanup. Keep each device session to a few minutes.
Check battery capacity and charging state under `/sys/class/power_supply`
before and after; keep capacity above 20%. Stop only processes started by the
test, remove temporary installs and profiles, and restore the prior dashboard
state. Leave Steam and SteamVR running. Do not reboot or change global settings.
Record the build, actual interaction results, cleanup and any unworn-headset
limits alongside screenshots or logs. These are caller responsibilities; the
smoke script below does not acquire this shared lock itself.
limits alongside screenshots or logs. The smoke script below acquires and
releases its own tokenized shared lock; other device tests follow this
procedure themselves.
[Packaged Desktop Capture evidence, 2026-10-24](evidence/desktop-capture-packaged-2026-10-24.md)
records v0.4.17 GUI button captures of a real mapped nested desktop on SteamOS
@@ -220,17 +222,19 @@ 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
an x86-64 `.exe` that exits at once). A launch passes only with fresh evidence:
the ARM64 program running, the `.exe` started (its process or Steam's log),
and the x86-64 program running or Steam logging that its runtime isn't
installed, which is what the Frame does today. Steam's log lines about each
title are kept.
and the x86-64 program running. If Steam logs that the optional x86-64 runtime
is not installed, that launch is **skipped**, with the reason and log saved in
the result. It does not prove that the x86-64 title ran. Steam's log lines about
each title are kept.
Everything it installs is removed again, also after a failure: the titles and
their Steam shortcuts, a paired key, and `~/devkit-utils` if it wasn't there
before (if it was, it stays, synced to this checkout as Frame Control always
does). A cleanup that fails counts as a failed step. Results go to
`tests/smoke/results/<time>-<BUILD_ID>.json` (not committed) with a summary on
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
isn't reachable.
screen. The summary counts passed, skipped and failed steps separately. It
exits 0 when no step failed (including a skipped optional runtime), 1 if a step
failed, or 2 if the headset isn't reachable.
Smoke process cleanup runs before and after title removal and during the
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
+5 -2
View File
@@ -210,7 +210,7 @@ def main(argv=None):
files = {name: base64.b64encode((ROOT/'examples/framey-settings'/name).read_bytes()).decode()
for name in ('plugin.json', 'main.py', 'dist/index.js')}
source = '''
import base64,json,pathlib
import base64,hashlib,json,pathlib
base=pathlib.Path.home()/'.local/share/frame-control/decky'
folder=%r
p=base/'plugins'/folder
@@ -219,7 +219,10 @@ p.mkdir(parents=True); (p/'dist').mkdir()
for name,data in %r.items(): (p/name).write_bytes(base64.b64decode(data))
manifest=json.loads((p/'plugin.json').read_text());manifest['name']='Framey VR Check '+folder
(p/'plugin.json').write_text(json.dumps(manifest))
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check'}))
digest=hashlib.sha256()
for name in ('plugin.json','main.py','dist/index.js'):
digest.update(name.encode()+b'\\0'+(p/name).read_bytes())
(p/'.frame-control-store.json').write_text(json.dumps({'storeId':2000000001,'version':'local-vr-check','sha256':digest.hexdigest()}))
print(json.dumps({'created':True}))
''' % (folder, files)
remote.python(source)
+16 -8
View File
@@ -112,25 +112,28 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.0.0')
self.assertIn('Sample 1.0.0', ssh('cat ~/.local/share/frame-control/decky/plugins/sample-panel/dist/index.js'))
receipt = HASHES[0]
def framey_call(body):
return ok('POST', '/api/framey/method', dict(body, receipt=receipt))
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'get_setting','args':[]},
headers={'X-Frame-UI':''})
self.assertEqual(status, 403)
status, _, _ = api('POST', '/api/framey/method', {'id':999,'method':'x;alert(1)','args':[]})
self.assertEqual(status, 400)
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'dark')
self.assertTrue(ok('POST', '/api/framey/method',
self.assertTrue(framey_call(
{'id':999,'method':'set_setting','args':['theme','light']})['result'])
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':[{'key':'theme','default':'dark'}],
'legacy':True})['result'], {'success':True,'result':'light'})
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'set_setting','args':[{'key':'legacy','value':'saved'}],
'legacy':True})['result'], {'success':True,'result':True})
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['legacy','default']})['result'], 'saved')
StoreFixture.release = 1
@@ -138,7 +141,12 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
{'action':'update','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('GET', '/api/decky')['plugins'][0]['version'], '1.1.0')
self.assertEqual(ok('POST', '/api/framey/method',
status, stale_reply, _ = api('POST', '/api/framey/method',
{'id':999,'receipt':receipt,'method':'get_setting','args':['theme','dark']})
self.assertEqual(status, 400)
self.assertIn('changed', str(stale_reply))
receipt = HASHES[1]
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
job = finished(ok('POST', '/api/decky/plugin',
{'action':'remove','id':999,'acknowledged':True}))
@@ -151,7 +159,7 @@ u=h/'.config/systemd/user/frame-control-decky.service'; u.parent.mkdir(parents=T
job = finished(ok('POST', '/api/decky/plugin',
{'action':'install','id':999,'acknowledged':True}))
self.assertFalse(job.get('error'), job)
self.assertEqual(ok('POST', '/api/framey/method',
self.assertEqual(framey_call(
{'id':999,'method':'get_setting','args':['theme','dark']})['result'], 'light')
stale = finished(ok('POST', '/api/decky/plugin',
{'action':'update','id':999,'sha256':HASHES[0],
+1 -1
View File
@@ -19,7 +19,7 @@ const parent=vm.createContext({document:{getElementById:el,createElement:()=>({}
window:{addEventListener:(type,fn)=>parentListeners.push({type,fn})},
setTimeout(){},setInterval(){},fetch(){throw Error('No network expected');},console});
vm.runInContext(parentSource,parent);
vm.runInContext('deckyState={plugins:[{storeId:14}]};openFrameyPanel(14,"Steam Status")',parent);
vm.runInContext('deckyState={running:true,plugins:[{storeId:14,sha256:"a",version:"1"}]};openFrameyPanel(14,"Steam Status")',parent);
let session=vm.runInContext('frameySession',parent);
const firstURL=new URL(el('frameyFrame').src,'http://127.0.0.1');
const onParent=parentListeners.find(x=>x.type==='message').fn;
+4 -1
View File
@@ -15,7 +15,7 @@ const context=vm.createContext({document:{getElementById:el,createElement:()=>({
setTimeout(){},setInterval(){},console,
fetch:(path,options)=>new Promise(resolve=>requests.push({path,body:JSON.parse(options.body),resolve}))});
vm.runInContext(source,context);
vm.runInContext('deckyState={plugins:[{storeId:96}]};openFrameyPanel(96,"Timer")',context);
vm.runInContext('deckyState={running:true,plugins:[{storeId:96,sha256:"'+'a'.repeat(64)+'",version:"1"}]};openFrameyPanel(96,"Timer")',context);
let session=vm.runInContext('frameySession',context);
const onMessage=listeners.find(x=>x.type==='message').fn;
const send=(data,origin=frame)=>onMessage({source:origin,data});
@@ -28,6 +28,7 @@ const first=send(subscription);
assert.equal(requests.length,1);
assert.equal(requests[0].path,'/api/framey/events');
assert.equal(requests[0].body.id,96);
assert.equal(requests[0].body.receipt,'a'.repeat(64));
assert.equal(requests[0].body.session,session);
// WindowProxy survives navigation: stale same-plugin replies need a session check.
vm.runInContext('openFrameyPanel(96,"Timer")',context);
@@ -35,6 +36,7 @@ const secondSession=vm.runInContext('frameySession',context);
assert.notEqual(secondSession,session);
assert.equal(requests[1].body.events.length,0); // Closes only the original subscription.
assert.equal(requests[1].body.session,session);
assert.equal(requests[1].body.receipt,'a'.repeat(64));
requests[0].resolve({ok:true,json:async()=>({events:[{event:'tick',args:[42]}],cursor:1})});
await first;assert.equal(replies.length,0);
await send({type:'framey:call',id:2,session,method:'cancel_timer',args:[]});
@@ -59,6 +61,7 @@ await send(steamRead,{});
assert.equal(requests.length,initialCount);
const read=send(steamRead);
assert.equal(requests.at(-1).path,'/api/framey/steam');assert.equal(requests.at(-1).body.id,96);
assert.equal(requests.at(-1).body.receipt,'a'.repeat(64));
requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})});
await read;assert.equal(replies.at(-1).result,'AU');
const staleRead=send({...steamRead,id:11});
+48
View File
@@ -68,6 +68,7 @@ ctx.mockApi=async path => path==='/api/decky' ?
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,true);
el('deckySearch').value='';el('deckyStoreFilter').value='all';
vm.runInContext('showDeckyStore()',ctx);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
@@ -97,6 +98,7 @@ await vm.runInContext('deckyChange("install",{id:2,name:"Plugin 2",sha256:"a".re
assert.equal(changes,1);
assert.equal(session.has('frameyPluginJob'),true);
assert.equal(el('deckyJobCheck').hidden,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,true);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,true);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,true);
await vm.runInContext('deckyChange("remove",{id:2,name:"Plugin 2"})',ctx);
@@ -111,6 +113,7 @@ ctx.mockApi=async path => {
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('checkDeckyJob()',ctx);
assert.equal(session.has('frameyPluginJob'),false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Open panel').disabled,false);
assert.equal(el('deckyInstalled').children[0].children.find(child=>child.textContent==='Remove').disabled,false);
assert.equal(el('deckyStoreList').children[1].children[1].disabled,false);
@@ -196,3 +199,48 @@ await staleFailure;
assert.match(el('deckyInstalled').children[0].children[0].textContent,/Current/);
assert.doesNotMatch(el('deckyState').textContent,/offline/);
assert.equal(el('deckyStoreList').children[0].children[1].disabled,false);
// A panel must not keep an old frontend after the installed receipt changes.
let installedHash='a'.repeat(64);
ctx.mockApi=async path => {
assert.equal(path,'/api/decky');
return {managed:true,running:true,plugins:[{storeId:2,name:'Plugin 2',version:'1.0',sha256:installedHash}]};
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
assert.equal(el('frameyPanel').hidden,false);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('frameyPanel').hidden,false,'unchanged receipt keeps the current panel');
installedHash='b'.repeat(64);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('frameyPanel').hidden,true);
assert.equal(el('frameyFrame').src,undefined);
assert.match(el('deckyState').textContent,/old plugin panel was closed/);
// The panel closes before updating its own backend, and stale Open handlers
// cannot recreate it while a job is active.
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
assert.equal(el('frameyPanel').hidden,false);
ctx.mockApi=async (path,body) => {
if(path==='/api/decky/plugin'){
assert.equal(el('frameyPanel').hidden,true);
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
assert.equal(el('frameyPanel').hidden,true);
return {job:'job-update'};
}
if(path.startsWith('/api/job'))return {done:true,message:'Updated Plugin 2'};
if(path==='/api/decky')return {managed:true,running:true,
plugins:[{storeId:2,name:'Plugin 2',version:'2.0',sha256:'c'.repeat(64)}]};
throw Error(`unexpected ${path}`);
};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('deckyChange("update",{id:2,name:"Plugin 2",sha256:"c".repeat(64)})',ctx);
assert.equal(el('frameyPanel').hidden,true);
assert.match(el('deckyJobStatus').textContent,/Old plugin panel closed; reopen it/);
vm.runInContext('openFrameyPanel(2,"Plugin 2")',ctx);
ctx.mockApi=async()=>{throw Error('offline');};
vm.runInContext('api=mockApi',ctx);
await vm.runInContext('refreshDecky()',ctx);
assert.equal(el('frameyPanel').hidden,true);
assert.match(el('deckyState').textContent,/panel was closed until status can be refreshed/);
+26 -10
View File
@@ -27,7 +27,8 @@ rechecked around deletion; shared Proton services are never stopped by name.
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
Exit status: 0 all passed, 1 a step failed, 2 the headset isn't reachable.
Exit status: 0 no step failed (optional runtime may skip a launch), 1 a step
failed, 2 the headset isn't reachable.
"""
import argparse
import json
@@ -125,21 +126,32 @@ class Smoke:
self.installed = set()
def step(self, name, fn, *args):
"""Run one step; record ok/failed, its detail and how long it took."""
"""Run one step; record passed/skipped/failed and how long it took."""
t0 = time.monotonic()
rec = {'step': name}
try:
detail = fn(*args)
rec.update(ok=True, detail=detail)
except Skipped as e:
rec.update(ok=False, skipped=True, reason=str(e), detail=e.detail)
except Exception as e: # a failed step is a result, not the end of the run
rec.update(ok=False, error=f'{type(e).__name__}: {e}', trace=traceback.format_exc(limit=3))
rec['seconds'] = round(time.monotonic() - t0, 1)
self.steps.append(rec)
print(f" {'ok ' if rec['ok'] else 'FAIL'} {name} ({rec['seconds']} s)"
+ ('' if rec['ok'] else f": {rec['error']}"), flush=True)
label = 'ok ' if rec['ok'] else 'SKIP' if rec.get('skipped') else 'FAIL'
message = '' if rec['ok'] else f": {rec.get('reason') or rec['error']}"
print(f" {label} {name} ({rec['seconds']} s){message}", flush=True)
return rec
class Skipped(Exception):
"""A prerequisite was unavailable; preserve its evidence without claiming a launch."""
def __init__(self, reason, detail):
super().__init__(reason)
self.detail = detail
def ssh(cmd, timeout=60):
return frame_android.ssh(cmd, timeout=timeout)
@@ -194,8 +206,8 @@ def steam_log_lines(*patterns):
# What a launch must show, per kind. The x86-64 runtime isn't installed on the
# Frame, so Steam acknowledges that launch and logs "... is not installed"
# instead (docs/sideloading.md, 2026-09-26): recorded, not a failure.
EXPECTED = {'arm64': ('running',), 'x86_64': ('running', 'runtime missing'), 'exe': ('running', 'started')}
# instead (docs/sideloading.md, 2026-09-26): record a skip, not a launch pass.
EXPECTED = {'arm64': ('running',), 'x86_64': ('running',), 'exe': ('running', 'started')}
def title_cycle(smoke, kind, folder):
@@ -229,10 +241,12 @@ def title_cycle(smoke, kind, folder):
outcome, new = 'runtime missing', new + missing
elif any('started devkit game' in line or 'chdir' in line for line in new):
outcome = 'started'
if outcome in EXPECTED[kind]: # else keep looking: the log can come before the process
if outcome in EXPECTED[kind] or (kind == 'x86_64' and outcome == 'runtime missing'):
break
time.sleep(0.5)
detail = {'outcome': outcome, 'processes': procs, 'steam_log': new[-12:]}
if kind == 'x86_64' and outcome == 'runtime missing':
raise Skipped('Steam Linux Runtime 4 is not installed; x86_64 title did not launch', detail)
if outcome not in EXPECTED[kind]:
raise AssertionError(f"Steam acknowledged the launch, but {outcome} (expected "
f"{' or '.join(EXPECTED[kind])}): {json.dumps(detail)[:400]}")
@@ -396,14 +410,16 @@ def run_smoke(args, before):
'rm -rf ~/devkit-utils ~/.devkit-utils.frame-control && echo removed')
subprocess.run(['rm', '-rf', folder])
passed = sum(s['ok'] for s in smoke.steps)
skipped = sum(s.get('skipped', False) for s in smoke.steps)
failed = len(smoke.steps) - passed - skipped
RESULTS.mkdir(exist_ok=True)
out = RESULTS / f"{started.replace(':', '')}-{build}.json"
out.write_text(json.dumps({'started': started, 'alias': ALIAS, 'preflight': before,
'os_release': release, 'paired': args.pair,
'passed': passed, 'failed': len(smoke.steps) - passed,
'passed': passed, 'skipped': skipped, 'failed': failed,
'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
print(f'==> {passed} passed, {skipped} skipped, {failed} failed on build {build}; results in {out}')
return 0 if failed == 0 else 1
def main():
+9
View File
@@ -440,6 +440,15 @@ sys.exit(255 if stop else 0)
class Recording(unittest.TestCase):
"""The Recorder with the real record/finish/cleanup shell, run locally under bash."""
@classmethod
def setUpClass(cls):
# The Frame's Bash 5 returns >128 for a timed-out read, distinct from EOF.
# macOS Bash 3.2 returns 1 for both, so it would falsely simulate Stop.
result = subprocess.run(["bash", "-c", "(( BASH_VERSINFO[0] >= 4 ))"],
capture_output=True, timeout=5)
if result.returncode:
raise unittest.SkipTest("Recording shell fixtures need Bash 4+ timeout semantics; macOS ships Bash 3.2")
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
+32 -20
View File
@@ -18,11 +18,13 @@ import framey_panel
import frame_headset
import server
RECEIPT = 'a' * 64
class Panel(unittest.TestCase):
def test_steam_reads_are_allowlisted_and_installed_only(self):
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck'}]}):
'plugins':[{'storeId':81,'name':'IsThereAnyDeal for Deck','sha256':RECEIPT}]}):
for method, expression in framey_panel.STEAM_READS.items():
def fake_ssh(command, stdin, **_):
self.assertEqual(command, 'python3 -')
@@ -30,13 +32,16 @@ class Panel(unittest.TestCase):
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')
self.assertEqual(framey_panel.read_steam(fake_ssh,81,method,RECEIPT)['result'], 'actual read')
for plugin_id, method in [(True,'GetIPCountry'), (0,'GetIPCountry'),
(82,'GetIPCountry'), (81,'ShutdownPC'), (81,'User.GetIPCountry'),
(81,[]), (81,'GetIPCountry();alert(1)')]:
with self.subTest(plugin_id=plugin_id, method=method):
with self.assertRaises(framey_panel.PanelError):
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method)
framey_panel.read_steam(lambda *a,**k:self.fail('Unexpected SSH'),plugin_id,method,RECEIPT)
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
framey_panel.read_steam(lambda *a,**k:self.fail('Stale read reached SSH'),
81,'GetIPCountry','b'*64)
def test_steam_read_response_limit_and_socket_cleanup(self):
from types import SimpleNamespace
@@ -86,19 +91,19 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
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'}]}):
'plugins':[{'storeId':53,'name':'Shotty','sha256':RECEIPT}]}):
self.assertEqual(framey_panel.call_method(fake_ssh,
53,'set_id_map_fronend',args,True),{'result':None})
53,'set_id_map_fronend',args,True,RECEIPT),{'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)
53,'set_id_map_fronend',[{'allapps':'x'*(256*1024)}],True,RECEIPT)
def test_steam_reads_have_the_headset_service_boundary(self):
with patch.object(server,'HEADSET_RUNTIME',False), patch.dict(server.os.environ,{'FRAME_E2E_CONTAINER':'0'}):
with self.assertRaises(server.Failure):server.framey_steam_action({'id':81,'method':'GetIPCountry'})
with patch.object(server,'HEADSET_RUNTIME',True), patch.object(framey_panel,'read_steam',return_value={'result':'AU'}) as read:
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry'}),{'result':'AU'})
read.assert_called_once_with(server.ssh,81,'GetIPCountry')
self.assertEqual(server.framey_steam_action({'id':81,'method':'GetIPCountry','receipt':RECEIPT}),{'result':'AU'})
read.assert_called_once_with(server.ssh,81,'GetIPCountry',RECEIPT)
def test_decky_event_queue_scope_bounds_expiry_and_reconnect(self):
for name in ('framey_events.mjs', 'framey_event_parent.mjs'):
@@ -117,8 +122,11 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
self.assertIn('loader/plugin_event', stdin)
return json.dumps({'events':[{'event':'timer:tick', 'args':[42]}], 'cursor':6})
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':96,'name':'Simple Timer'}]}):
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5)['cursor'], 6)
'plugins':[{'storeId':96,'name':'Simple Timer','sha256':RECEIPT}]}):
self.assertEqual(framey_panel.poll_events(fake_ssh, 96, 'test-123.45', ['timer:tick'], 5, RECEIPT)['cursor'], 6)
with self.assertRaisesRegex(framey_panel.PanelError, 'changed'):
framey_panel.poll_events(lambda *a,**k:self.fail('Stale poll reached SSH'),
96,'test-123.45',['timer:tick'],5,'b'*64)
for plugin_id, session, events, cursor in [
(True,'test',['tick'],0), (96,'bad session',['tick'],0),
(96,'test',['a']*33,0), (96,'test',['bad event'],0),
@@ -129,7 +137,7 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
with self.subTest(plugin_id=plugin_id, session=session, events=events, cursor=cursor):
with self.assertRaises(framey_panel.PanelError):
framey_panel.poll_events(lambda *a, **k:self.fail('Unexpected SSH'),
plugin_id, session, events, cursor)
plugin_id, session, events, cursor, RECEIPT)
def test_event_action_has_same_headset_boundary_as_methods(self):
with patch.object(server, 'HEADSET_RUNTIME', False), patch.dict(server.os.environ, {'FRAME_E2E_CONTAINER':'0'}):
@@ -137,8 +145,8 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
server.framey_events_action({'id':96,'session':'test','events':['tick']})
with patch.object(server, 'HEADSET_RUNTIME', True), \
patch.object(framey_panel, 'poll_events', return_value={'events':[], 'cursor':0}) as poll:
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick']})['events'], [])
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0)
self.assertEqual(server.framey_events_action({'id':96,'session':'test','events':['tick'],'receipt':RECEIPT})['events'], [])
poll.assert_called_once_with(server.ssh, 96, 'test', ['tick'], 0, RECEIPT)
def test_opaque_origin_plugin_cache_is_scoped_and_bounded(self):
result = subprocess.run(['node', str(Path(__file__).parent/'page/framey_cache.mjs')],
@@ -220,12 +228,12 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
self.assertIn('loader/call_legacy_plugin_method', stdin)
return json.dumps({'result':{'success':True, 'result':'dark'}})
with patch.object(framey_panel.frame_decky, 'status', return_value={
'plugins':[{'storeId':14,'name':'Legacy'}]}):
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True),
'plugins':[{'storeId':14,'name':'Legacy','sha256':RECEIPT}]}):
self.assertEqual(framey_panel.call_method(fake_ssh, 14, 'read_setting', [{'key':'mode'}], True, RECEIPT),
{'result':{'success':True, 'result':'dark'}})
for args, legacy in [([],True), ([[]],True), ([{},{}],True), ([{}],'true')]:
with self.assertRaises(framey_panel.PanelError):
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy)
framey_panel.call_method(fake_ssh, 14, 'read_setting', args, legacy, RECEIPT)
def test_unsupported_root_symlink_and_unmanaged_are_refused(self):
manifest = self.folder/'plugin.json'
@@ -255,11 +263,15 @@ assert.throws(()=>eval(EXPRESSION),/library is unavailable/);
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'}]}):
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark']),
'plugins':[{'storeId':14,'name':'ProtonDB Badges','sha256':RECEIPT}]}):
self.assertEqual(framey_panel.call_method(fake_ssh,14,'get_setting',['theme','dark'],receipt=RECEIPT),
{'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)',[])
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,15,'get_setting',[],receipt=RECEIPT)
with self.assertRaises(framey_panel.PanelError): framey_panel.call_method(fake_ssh,14,'x;alert(1)',[],receipt=RECEIPT)
for stale in (None, 'b' * 64, 'invalid'):
with self.subTest(stale=stale), self.assertRaisesRegex(framey_panel.PanelError, 'receipt|changed'):
framey_panel.call_method(lambda *a, **k:self.fail('Stale receipt reached SSH'),
14,'get_setting',[],receipt=stale)
compile(framey_panel.METHOD_SCRIPT.replace('__FRAMEY_METHOD_PAYLOAD__',repr('{}')), '<remote method>', 'exec')
def test_headset_bundle_contains_runtime_and_licenses(self):
+2
View File
@@ -76,6 +76,8 @@ class NativeContracts(unittest.TestCase):
frameworks = str(Path(developer) / "Platforms/MacOSX.platform/Developer/Library/Frameworks")
libraries = str(Path(developer) / "Platforms/MacOSX.platform/Developer/usr/lib")
shared = str(Path(developer).parent / "SharedFrameworks")
if not (Path(frameworks) / "XCTest.framework").is_dir():
self.skipTest("Foundation XCTest contracts need full Xcode; Command Line Tools lack XCTest")
result = subprocess.run(["swiftc", "-I", libraries, "-L", libraries, "-F", frameworks, "-Xlinker", "-rpath", "-Xlinker", frameworks,
"-Xlinker", "-rpath", "-Xlinker", libraries,
"-Xlinker", "-rpath", "-Xlinker", shared,
+21 -3
View File
@@ -131,14 +131,14 @@ class ObsSourceTests(unittest.TestCase):
wrapper = ("import importlib.util; "
f"s=importlib.util.spec_from_file_location('obs', {str(SCRIPT)!r}); "
"o=importlib.util.module_from_spec(s);s.loader.exec_module(o); "
"o.STARTUP_TIMEOUT=o.STREAM_STALL=o.CHILD_STOP_TIMEOUT=0.15; "
"o.STARTUP_TIMEOUT=5; o.STREAM_STALL=o.CHILD_STOP_TIMEOUT=0.15; "
"o.main(['--frame','fake-frame','--control-stdin'])")
env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', '')}
process = subprocess.Popen([sys.executable, '-c', wrapper], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env)
try:
# Keep stdin OPEN: real Electron does this until the helper exits.
self.assertEqual(process.wait(timeout=4), 1)
self.assertEqual(process.wait(timeout=10), 1)
error = process.stderr.read().decode(errors='replace')
self.assertIn('video stopped', error)
self.assertNotIn('Fatal Python error', error)
@@ -209,6 +209,7 @@ class ObsSourceTests(unittest.TestCase):
for mode in ('startup', 'stream'):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
ready = directory / 'ready'
fake = directory / 'ssh'
fake.write_text("#!/usr/bin/env python3\n"
"import os, signal, sys, time\n"
@@ -219,12 +220,29 @@ class ObsSourceTests(unittest.TestCase):
"if os.environ['FAKE_SSH_MODE'] == 'stream':\n"
" sys.stdout.buffer.write(b'G' * 1316)\n"
" sys.stdout.buffer.flush()\n"
"open(os.environ['FAKE_SSH_READY'], 'w').close()\n"
"while True: time.sleep(1)\n")
fake.chmod(0o755)
stopped = directory / 'stopped'
env = {'PATH': str(directory) + os.pathsep + os.environ.get('PATH', ''),
'FAKE_SSH_STOPPED': str(stopped), 'FAKE_SSH_MODE': mode}
'FAKE_SSH_STOPPED': str(stopped), 'FAKE_SSH_MODE': mode,
'FAKE_SSH_READY': str(ready)}
real_popen = subprocess.Popen
def start_ready(*args, **kwargs):
proc = real_popen(*args, **kwargs)
# Start the short watchdog clock after the stand-in installed
# its handler, independent of native interpreter launch time.
end = time.monotonic() + 5
while not ready.exists() and proc.poll() is None and time.monotonic() < end:
time.sleep(0.01)
if not ready.exists():
obs.stop_child(proc)
self.fail('Stand-in SSH did not finish startup')
return proc
with mock.patch.dict(os.environ, env), \
mock.patch.object(obs.subprocess, 'Popen', side_effect=start_ready), \
mock.patch.object(obs, 'STARTUP_TIMEOUT', 0.3), \
mock.patch.object(obs, 'STREAM_STALL', 0.3):
with self.assertRaisesRegex(RuntimeError, 'no OBS video' if mode == 'startup' else 'video stopped'):
+54
View File
@@ -95,6 +95,60 @@ class RealProcesses(unittest.TestCase):
class Safety(unittest.TestCase):
def test_missing_x86_runtime_skips_launch_but_still_removes_title(self):
smoke = frame_smoke.Smoke()
gid = 'fc_smoke_x86_64'
with mock.patch.object(frame_smoke.tiny_programs, 'write', return_value=Path('/tmp/tiny')), \
mock.patch.object(frame_smoke.frame_titles, 'install', return_value={
'runtime': 'SteamLinuxRuntime_4'}), \
mock.patch.object(frame_smoke.frame_titles, 'list_titles', side_effect=[
[{'id': gid}], []]), \
mock.patch.object(frame_smoke.frame_titles, 'launch') as launch, \
mock.patch.object(frame_smoke.frame_titles, 'remove') as remove, \
mock.patch.object(frame_smoke, 'steam_log_lines', side_effect=[
[], [], [], ['Tool 4183110 Steam Linux Runtime 4.0 is found, but is not installed']]), \
mock.patch.object(frame_smoke, 'ssh', return_value=''), \
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': []}):
frame_smoke.title_cycle(smoke, 'x86_64', '/tmp')
self.assertEqual([step['step'] for step in smoke.steps],
['x86_64: install', 'x86_64: launch', 'x86_64: remove'])
self.assertTrue(smoke.steps[0]['ok'])
self.assertFalse(smoke.steps[1]['ok'])
self.assertTrue(smoke.steps[1]['skipped'])
self.assertEqual(smoke.steps[1]['detail']['outcome'], 'runtime missing')
self.assertTrue(smoke.steps[2]['ok'])
self.assertNotIn(gid, smoke.installed)
launch.assert_called_once_with(gid)
remove.assert_called_once_with(gid)
def test_optional_runtime_skip_is_counted_separately_in_saved_result(self):
with tempfile.TemporaryDirectory() as root:
output = Path(root) / 'results'
folder = Path(root) / 'titles'
folder.mkdir()
def cycle(smoke, kind, _folder):
if kind == 'x86_64':
def missing():
raise frame_smoke.Skipped('optional runtime absent', {'outcome': 'runtime missing'})
smoke.step(f'{kind}: launch', missing)
else:
smoke.step(f'{kind}: launch', lambda: {'outcome': 'running'})
with mock.patch.object(frame_smoke, 'RESULTS', output), \
mock.patch.object(frame_smoke, 'os_release', return_value={
'BUILD_ID': 'fake', 'VERSION_ID': 'fake', 'VARIANT_ID': 'fake'}), \
mock.patch.object(frame_smoke, 'ssh', return_value='yes'), \
mock.patch.object(frame_smoke, 'cleanup', return_value={}), \
mock.patch.object(frame_smoke, 'properties', return_value={}), \
mock.patch.object(frame_smoke, 'status', return_value={}), \
mock.patch.object(frame_smoke, 'title_cycle', side_effect=cycle), \
mock.patch.object(frame_smoke.tempfile, 'mkdtemp', return_value=str(folder)):
self.assertEqual(frame_smoke.run_smoke(mock.Mock(pair=False),
{'activity': 3, 'proximity': [], 'battery': [100]}), 0)
result = json.loads(next(output.glob('*-fake.json')).read_text())
self.assertEqual((result['passed'], result['skipped'], result['failed']), (6, 1, 0))
self.assertEqual(next(s for s in result['steps'] if s['step'] == 'x86_64: launch')
['detail']['outcome'], 'runtime missing')
def test_non_test_ids_rejected_before_scanning(self):
for ids in ([], ['portal2'], ['fc_smoke_exe/../portal2'], ['fc_smoke_custom']):
with self.subTest(ids=ids), self.assertRaises(ValueError):
+4 -3
View File
@@ -529,9 +529,10 @@ class PanelsAsAFreshProcess(unittest.TestCase):
self.assertNotIn("why", out)
def test_slow_x_tools_stay_inside_the_servers_timeout(self):
# Review of #83: X tools answering in 4.6 s each would take well over the server's
# 20 s; one deadline for the run (PANELS_DEADLINE, 12 s) returns in time, saying why.
out, took = self.run_panels(4.6)
# Several 3 s calls exceed the whole-run deadline before a panel is complete.
# Keep clear of each tool's 5 s timeout, including process startup on Intel Macs.
# One deadline (12 s) must return inside the server's 20 s timeout, saying why.
out, took = self.run_panels(3)
self.assertLess(took, 15, out)
self.assertEqual(out["panels"], [])
self.assertIn("took too long", out["why"])
+20 -14
View File
@@ -11,6 +11,7 @@ import frame_decky
METHOD = re.compile(r'[A-Za-z][A-Za-z0-9_]{0,63}\Z')
EVENT = re.compile(r'[A-Za-z0-9_:/.-]{1,128}\Z')
SESSION = re.compile(r'[A-Za-z0-9_.-]{1,100}\Z')
RECEIPT = re.compile(r'[0-9a-f]{64}\Z')
MODULE_PATH = re.compile(r'[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*/[A-Za-z0-9_.-]+\.js\Z|index\.js\Z')
PLUGINS = Path.home()/'.local/share/frame-control/decky/plugins'
@@ -139,7 +140,18 @@ finally: page.sock.close()
'''
def call_method(ssh, plugin_id, method, args, legacy=False):
def _current_plugin(ssh, plugin_id, receipt):
if not isinstance(receipt, str) or not RECEIPT.fullmatch(receipt):
raise PanelError('Reopen the plugin panel to refresh its store receipt')
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
if len(found) != 1:
raise PanelError('The plugin is not managed by the Frame Control Decky store')
if found[0].get('sha256') != receipt:
raise PanelError('The plugin changed; close and reopen its panel')
return found[0]
def call_method(ssh, plugin_id, method, args, legacy=False, receipt=None):
if type(plugin_id) is not int or plugin_id <= 0:
raise PanelError('Choose an installed Decky store plugin')
if not isinstance(method, str) or not METHOD.fullmatch(method):
@@ -148,10 +160,8 @@ def call_method(ssh, plugin_id, method, args, legacy=False):
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')
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
if len(found) != 1:
raise PanelError('The plugin is not managed by the Frame Control Decky store')
payload = {'name':found[0]['name'], 'method':method, 'args':args, 'legacy':legacy}
plugin = _current_plugin(ssh, plugin_id, receipt)
payload = {'name':plugin['name'], 'method':method, 'args':args, 'legacy':legacy}
# 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)))
@@ -192,15 +202,13 @@ finally: page.sock.close()
'''
def read_steam(ssh, plugin_id, method):
def read_steam(ssh, plugin_id, method, receipt=None):
"""Read one explicitly supported Steam API for an installed store panel."""
if type(plugin_id) is not int or plugin_id <= 0:
raise PanelError('Choose an installed Decky store plugin')
if not isinstance(method, str) or method not in STEAM_READS:
raise PanelError('This Steam API is unavailable in Framey')
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
if len(found) != 1:
raise PanelError('The plugin is not managed by the Frame Control Decky store')
_current_plugin(ssh, plugin_id, receipt)
expression = '(async()=>await ' + STEAM_READS[method] + ')()'
script = STEAM_READ_SCRIPT.replace('__FRAMEY_STEAM_READ__', repr(expression)).replace(
'__FRAMEY_STEAM_LIMIT__', str(2*1024*1024 if method == 'GetLibrarySnapshot' else 256*1024))
@@ -219,7 +227,7 @@ finally: page.sock.close()
'''
def poll_events(ssh, plugin_id, session, events, cursor=0):
def poll_events(ssh, plugin_id, session, events, cursor=0, receipt=None):
"""Read only this store-owned plugin's subscribed events; never trust its claimed name."""
if type(plugin_id) is not int or plugin_id <= 0:
raise PanelError('Choose an installed Decky store plugin')
@@ -230,10 +238,8 @@ def poll_events(ssh, plugin_id, session, events, cursor=0):
raise PanelError('Invalid Decky event names')
if type(cursor) is not int or not 0 <= cursor <= 9007199254740991:
raise PanelError('Invalid Decky event cursor')
found = [p for p in frame_decky.status(ssh)['plugins'] if p.get('storeId') == plugin_id]
if len(found) != 1:
raise PanelError('The plugin is not managed by the Frame Control Decky store')
payload = {'name':found[0]['name'], 'session':session, 'events':events, 'cursor':cursor}
plugin = _current_plugin(ssh, plugin_id, receipt)
payload = {'name':plugin['name'], 'session':session, 'events':events, 'cursor':cursor}
# Embed only our bundled source as a Python string; plugin data stays in JSON.
source = Path(__file__).with_name('framey-events.js').read_text(encoding='utf-8')
script = EVENT_SCRIPT.replace('__FRAMEY_EVENTS_SOURCE__', repr(source))
+33 -10
View File
@@ -108,25 +108,34 @@ function compatibilityText(plugin){const c=plugin.compatibility;return c ? `${c.
function deckyJobPending(){return !!sessionStorage.getItem('frameyPluginJob');}
let deckyState=null, deckyStore=[], deckyStoreIndex=0, deckyStoreRequest=0,
deckyStatusRequest=0,
activeFrameyId=null, frameySession=null, deckyBusy=false;
activeFrameyId=null, activeFrameyReceipt=null, frameySession=null, deckyBusy=false;
async function refreshDecky(){const request=++deckyStatusRequest;try{const state=await api('/api/decky');
if(request!==deckyStatusRequest)return;
deckyState=state;
const active=(state.plugins||[]).find(p=>p.storeId===activeFrameyId);
const panelStale=activeFrameyId!==null&&(!state.running||!active||
active.sha256!==activeFrameyReceipt?.sha256||active.version!==activeFrameyReceipt?.version);
if(panelStale)closeFrameyPanel();
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':
deckyState.managed?'Decky is stopped. You can remove a managed plugin to recover, then start Decky from Frame Control on your computer.':
'Decky is not running. Install it from Frame Control on your computer.';
if(panelStale)byId('deckyState').textContent+=' The old plugin panel was closed; reopen it after refreshing.';
const list=byId('deckyInstalled');list.replaceChildren();
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
const note=document.createElement('p');note.textContent=compatibilityText(plugin);row.append(note);
if(plugin.storeId){const open=document.createElement('button');open.className='secondary';open.textContent='Open panel';
open.disabled=deckyBusy||deckyJobPending()||!deckyState.running;
open.onclick=()=>openFrameyPanel(plugin.storeId,plugin.name);row.append(open);
const button=document.createElement('button');button.className='danger';button.textContent='Remove';
button.disabled=deckyBusy||deckyJobPending();button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.';
showDeckyStore();}catch(error){if(request!==deckyStatusRequest)return;
const panelClosed=activeFrameyId!==null;
if(panelClosed)closeFrameyPanel();
deckyState=null;
byId('deckyState').textContent=`Installed plugins unavailable: ${error.message}`;
if(panelClosed)byId('deckyState').textContent+=' The plugin panel was closed until status can be refreshed.';
byId('deckyInstalled').replaceChildren();
byId('deckyInstalled').textContent='Refresh to check installed plugins.';
showDeckyStore();}}
@@ -191,14 +200,20 @@ async function deckyChange(action,plugin){if(deckyBusy)return;
}
if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
deckyBusy=true;showDeckyStore();
deckyJobView(`${action} ${plugin.name}`,'Starting…',0);
const panelClosed=(action==='update'||action==='remove')&&activeFrameyId===plugin.id;
const panelNote=panelClosed?(action==='remove'?' Plugin panel closed after removal.':
' Old plugin panel closed; reopen it after refreshing installed plugins.') : '';
if(panelClosed)closeFrameyPanel();
deckyJobView(`${action} ${plugin.name}`,
panelClosed?'Starting… Old plugin panel closed until the change finishes.':'Starting…',0);
try{const started=await api('/api/decky/plugin',{action,id:plugin.id,
sha256:action==='remove'?undefined:plugin.sha256,acknowledged:true});
if(!started.job)throw Error('The server did not return a job ID');
sessionStorage.setItem('frameyPluginJob',JSON.stringify({job:started.job,action,name:plugin.name}));
await followDeckyJob(started.job,action,plugin.name);
sessionStorage.removeItem('frameyPluginJob');
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}`,null,true);}
if(panelNote)byId('deckyJobStatus').textContent+=panelNote;
}catch(error){deckyJobView(`${action} ${plugin.name}`,`Error: ${error.message}${panelNote}`,null,true);}
finally{deckyBusy=false;await refreshDecky();}
}
async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.getItem('frameyPluginJob')||'null');}catch(_){saved=null;}
@@ -210,10 +225,12 @@ async function checkDeckyJob(){let saved;try{saved=JSON.parse(sessionStorage.get
finally{deckyBusy=false;await refreshDecky();}}
byId('deckyJobCheck').onclick=checkDeckyJob;
function openFrameyPanel(id,name){
if(deckyBusy||deckyJobPending()||!deckyState?.running)return;
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===id);if(!installed)return;
byId('frameyCompatibility').textContent=compatibilityText(installed);
closeFrameyEvents();
activeFrameyId=id;frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
activeFrameyId=id;activeFrameyReceipt={sha256:installed.sha256,version:installed.version};
frameySession=`${Date.now()}-${Math.random()}`;byId('frameyTitle').textContent=name;
byId('frameyPanelStatus').textContent='Loading plugin panel…';
byId('frameyFrame').src=`/framey/panel/${id}?session=${encodeURIComponent(frameySession)}#${frameySession}`;
byId('frameyPanel').hidden=false;byId('frameyPanel').scrollIntoView();
@@ -223,10 +240,13 @@ function openFrameyPanel(id,name){
let frameyEventsActive=false;
function closeFrameyEvents(){
if(frameyEventsActive&&activeFrameyId!==null&&frameySession)
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
api('/api/framey/events',{id:activeFrameyId,receipt:activeFrameyReceipt?.sha256,
session:frameySession,events:[],cursor:0}).catch(()=>{});
frameyEventsActive=false;
}
byId('frameyClose').onclick=()=>{closeFrameyEvents();activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
function closeFrameyPanel(){closeFrameyEvents();activeFrameyId=null;activeFrameyReceipt=null;frameySession=null;
byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;}
byId('frameyClose').onclick=closeFrameyPanel;
function frameyCacheKey(id){return `framey-plugin-cache-v1:${id}`;}
function frameyCacheRead(id){
try{const data=JSON.parse(localStorage.getItem(frameyCacheKey(id))||'{}');
@@ -267,9 +287,11 @@ window.addEventListener('message',async event=>{
req.args.length!==2||!Array.isArray(req.args[0])||req.args[0].length>32||
req.args[0].some(name=>typeof name!=='string'||!/^[A-Za-z0-9_:/.-]{1,128}$/.test(name))||
!Number.isSafeInteger(req.args[1])||req.args[1]<0)return;
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
reply={type:'framey:reply',id:req.id};
frameyEventsActive=req.args[0].length>0;
try{reply.result=await api('/api/framey/events',{id:pluginId,session,events:req.args[0],cursor:req.args[1]});}
try{reply.result=await api('/api/framey/events',{id:pluginId,receipt,session,
events:req.args[0],cursor:req.args[1]});}
catch(error){reply.error=error.message;}
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)
event.source.postMessage(reply,'*');
@@ -278,9 +300,10 @@ 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','GetLibrarySnapshot'].includes(req.method)))return;
const pluginId=activeFrameyId,session=frameySession,reply={type:'framey:reply',id:req.id};
const pluginId=activeFrameyId,session=frameySession,receipt=activeFrameyReceipt?.sha256,
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;}
{id:pluginId,receipt,method:req.method,args:req.args,legacy:req.legacy===true});reply.result=data.result;}
catch(error){reply.error=error.message;}
if(pluginId===activeFrameyId&&session===frameySession&&event.source===byId('frameyFrame').contentWindow)event.source.postMessage(reply,'*');
});
+4 -3
View File
@@ -581,7 +581,8 @@ def framey_method_action(body):
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
raise Failure('Decky plugin panels run in the headset service', 400)
try:
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'), body.get('legacy', False))
return framey_panel.call_method(ssh, body.get('id'), body.get('method'), body.get('args'),
body.get('legacy', False), body.get('receipt'))
except framey_panel.PanelError as exc:
raise Failure(str(exc), 400) from exc
@@ -590,7 +591,7 @@ def framey_steam_action(body):
if not HEADSET_RUNTIME and os.environ.get('FRAME_E2E_CONTAINER') != '1':
raise Failure('Decky plugin panels run in the headset service', 400)
try:
return framey_panel.read_steam(ssh, body.get('id'), body.get('method'))
return framey_panel.read_steam(ssh, body.get('id'), body.get('method'), body.get('receipt'))
except framey_panel.PanelError as exc:
raise Failure(str(exc), 400) from exc
@@ -600,7 +601,7 @@ def framey_events_action(body):
raise Failure('Decky plugin panels run in the headset service', 400)
try:
return framey_panel.poll_events(ssh, body.get('id'), body.get('session'),
body.get('events'), body.get('cursor', 0))
body.get('events'), body.get('cursor', 0), body.get('receipt'))
except framey_panel.PanelError as exc:
raise Failure(str(exc), 400) from exc