Compare commits

...
Author SHA1 Message Date
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
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
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
18 changed files with 718 additions and 40 deletions

No files matched your search

+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,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.
+6
View File
@@ -177,6 +177,12 @@ 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.
## 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`,
+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;
+1 -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",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});
+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)
+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"])
+24 -5
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();
@@ -226,7 +243,9 @@ function closeFrameyEvents(){
api('/api/framey/events',{id:activeFrameyId,session:frameySession,events:[],cursor:0}).catch(()=>{});
frameyEventsActive=false;
}
byId('frameyClose').onclick=()=>{closeFrameyEvents();activeFrameyId=null;frameySession=null;byId('frameyFrame').removeAttribute('src');byId('frameyPanel').hidden=true;};
function 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))||'{}');