Compare commits

...
Author SHA1 Message Date
SAPHID 42f3b2e1e7 Record post-tag Framey scroll button exercise 2026-10-26 08:47:27 +00:00
SAPHID ca4c370b33 Document v0.4.43 tagged release verification 2026-10-26 08:41:10 +00:00
Alex Southwell c50f96510f Merge pull request #313 from saphid/feat/framey-installed-app-context
fix(framey): restore Junk-Store About with installed metadata
2026-10-26 08:31:28 +00:00
Alex Southwell 43e801091a Merge pull request #312 from saphid/feat/framey-scroll-buttons
Add visible scroll controls to Framey plugin panels
2026-10-26 08:30:07 +00:00
Alex Southwell b4e632b218 Merge pull request #311 from saphid/release/v0.4.43
Prepare Frame Control v0.4.43
2026-10-26 08:25:09 +00:00
SAPHID d84491c0ec fix(framey): project installed metadata for Junk-Store About 2026-10-26 08:23:50 +00:00
SAPHID 83b8d13846 Add visible scroll controls to Framey plugin panels 2026-10-26 08:20:11 +00:00
SAPHID 340ca88359 Record test-only main merge in v0.4.43 candidate 2026-10-26 08:10:20 +00:00
SAPHID e62589417b Merge remote-tracking branch 'origin/main' into release/v0.4.43 2026-10-26 08:09:54 +00:00
Alex Southwell 03b3796d8d Merge pull request #310 from saphid/fix/windows-wireless-live-retry
Retry transient Windows wireless inventory smoke timeout
2026-10-26 08:09:31 +00:00
SAPHID 93da4e466f Prepare Frame Control v0.4.43 candidate 2026-10-26 08:05:12 +00:00
Alex Southwell 05093ea4d4 Merge pull request #309 from saphid/feat/framey-script-entrypoints
fix(framey): restore executable scripts for Junk-Store
2026-10-26 08:03:32 +00:00
SAPHID 232b253e7a Merge Web Browser compatibility into Junk-Store fix 2026-10-26 07:53:39 +00:00
Alex Southwell de8abe32c6 Merge pull request #308 from saphid/feat/framey-web-browser
Framey: handle missing Steam React root in legacy panels
2026-10-26 07:51:52 +00:00
SAPHID 42492e7879 Retry transient Windows wireless smoke timeout once 2026-10-26 07:45:31 +00:00
SAPHID f50139fe6a Merge main into Framey Web Browser compatibility 2026-10-26 07:34:55 +00:00
SAPHID 698a643eb7 Merge remote-tracking branch 'origin/main' into feat/framey-script-entrypoints
# Conflicts:
#	docs/framey-compatibility.md
2026-10-26 07:34:03 +00:00
SAPHID d0cef31656 fix(framey): restore executable plugin script entrypoints 2026-10-26 07:33:14 +00:00
Alex Southwell 4ac59f1a44 Merge pull request #307 from saphid/feat/framey-volume-boost-arm64
docs: verify Volume Boost sink controls on ARM64 Frame
2026-10-26 07:32:50 +00:00
SAPHID c91cfff797 docs: record exact Framey unworn regression gate 2026-10-26 07:28:07 +00:00
SAPHID d5e62f643d Framey: tolerate missing Steam React root in legacy panels 2026-10-26 07:26:21 +00:00
Alex Southwell f3f574666f Merge pull request #306 from saphid/docs/v0442-release-evidence
Document v0.4.42 tagged release verification
2026-10-26 07:13:01 +00:00
SAPHID 40ece4fef0 docs: verify Volume Boost sink controls on ARM64 Frame 2026-10-26 06:19:34 +00:00
Alex Southwell f52c020310 Merge pull request #305 from saphid/docs/framey-running-postmerge-vr
Document post-merge Framey running panel check
2026-10-26 06:04:36 +00:00
SAPHID efe0c508f4 docs: record v0.4.42 tagged release verification 2026-10-26 06:03:42 +00:00
SAPHID 1af2bb45d9 docs: record post-merge Framey running panel exercise 2026-10-26 05:56:16 +00:00
Alex Southwell daee952873 Merge pull request #304 from saphid/feat/framey-running-context
Framey: bridge running-game context and Steam lifetime notifications
2026-10-26 05:53:58 +00:00
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 7172be17fc Document running receipt guard and its verification boundary 2026-10-26 05:39:17 +00:00
SAPHID 27af46dd20 Record receipt guard in v0.4.42 candidate 2026-10-26 05:38:34 +00:00
SAPHID 761e952d8f Merge remote-tracking branch 'origin/main' into feat/framey-running-context 2026-10-26 05:38:07 +00:00
SAPHID 0237e342d2 Bind Framey running polls and cleanup to the installed plugin receipt 2026-10-26 05:38:07 +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
SAPHID 10b196beaa Merge remote-tracking branch 'origin/main' into feat/framey-running-context 2026-10-26 05:23:14 +00:00
SAPHID 3f74ff4bb0 Add read-only running-game context and Steam lifetime relay for Framey 2026-10-26 05:23:13 +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
SAPHID 0c9bed1254 Merge remote-tracking branch 'origin/main' into release/v0.4.41 2026-10-26 04:40:51 +00:00
Alex Southwell 741735aa98 Merge pull request #296 from saphid/test/framey-native-scroll
Measure native Framey scroll in an overflowed test region
2026-10-26 04:40:21 +00:00
SAPHID 4133ea1c7d Update support matrix for merged ARM64 audio evidence 2026-10-26 04:39:11 +00:00
SAPHID 3aa510a0e0 Merge remote-tracking branch 'origin/test/framey-native-scroll' into release/v0.4.41 2026-10-26 04:36:17 +00:00
SAPHID 637813024f Record v0.4.41 candidate Frame smoke 2026-10-26 04:34:32 +00:00
SAPHID c87e5c6e8a Prepare Frame Control v0.4.41 2026-10-26 04:32:18 +00:00
Alex Southwell c9777851c9 Merge pull request #293 from saphid/feat/framey-audio-arm64
Verify official audio mixers on the ARM64 Frame
2026-10-26 04:30:38 +00:00
SAPHID af03081f27 Record exact-main Framey sample check after input guard 2026-10-26 04:27:58 +00:00
SAPHID ef99d4953b Merge remote-tracking branch 'origin/main' into test/framey-native-scroll 2026-10-26 04:27:37 +00:00
Alex Southwell 17a7b0b80e Merge pull request #295 from saphid/docs/v0440-release-evidence
Document v0.4.40 release and post-tag Frame checks
2026-10-26 04:26:56 +00:00
Alex Southwell c38e7e8da4 Merge pull request #294 from saphid/fix/framey-sample-setting-guard
Guard Framey sample setting writes against rapid input
2026-10-26 04:25:34 +00:00
SAPHID fc00878a23 Measure Framey native scroll on an overflowed test region 2026-10-26 04:24:43 +00:00
SAPHID 657b4449f7 Document v0.4.40 release and post-tag Frame verification 2026-10-26 04:16:43 +00:00
SAPHID 30a65facde docs(framey): verify official per-app mixers on ARM64 Frame 2026-10-26 04:14:17 +00:00
SAPHID 9135f355f4 Guard sample plugin setting writes against rapid input 2026-10-26 04:14:10 +00:00
Alex Southwell 784118b71e Merge pull request #292 from saphid/fix/framey-quick-action-guard
Guard Framey quick actions against repeated taps
2026-10-26 04:13:32 +00:00
SAPHID e0115f356d Guard Framey quick actions against repeated taps 2026-10-26 04:04:53 +00:00
Alex Southwell 56bde24be1 Merge pull request #290 from saphid/fix/framey-pending-job
Hold Framey plugin changes until saved job resolves
2026-10-26 03:56:02 +00:00
Alex Southwell 59c270a7ad Merge pull request #289 from saphid/feat/framey-module-exports
Enable local Decky module lookups and TabMaster docs
2026-10-26 03:55:43 +00:00
Alex Southwell a4d8073e31 Merge pull request #291 from saphid/release/v0.4.40
Prepare Frame Control v0.4.40
2026-10-26 03:54:16 +00:00
SAPHID c0275b483a Record combined main regression checks for Framey module lookup 2026-10-26 03:39:24 +00:00
SAPHID ff49b44350 Record v0.4.40 candidate real Frame gates 2026-10-26 03:39:09 +00:00
SAPHID 7e6fb7e342 Keep Framey store locked while job result is unknown 2026-10-26 03:38:51 +00:00
SAPHID 57c1c4564b Merge remote-tracking branch 'origin/main' into feat/framey-module-exports
# Conflicts:
#	docs/framey-compatibility.md
#	scripts/framey-compatibility-doc.py
2026-10-26 03:35:18 +00:00
SAPHID 9aa26a8322 Merge remote-tracking branch 'origin/main' into release/v0.4.40 2026-10-26 03:34:08 +00:00
Alex Southwell 38c388f3f3 Merge pull request #288 from saphid/fix/framey-stale-status
Clear stale Framey store status after refresh failures
2026-10-26 03:33:52 +00:00
Alex Southwell 27e3c42ed0 Merge pull request #286 from saphid/feat/framey-chunked-frontend
Load chunked Decky frontends in Framey
2026-10-26 03:33:21 +00:00
SAPHID c2be12fa75 Merge remote-tracking branch 'origin/main' into feat/framey-module-exports 2026-10-26 03:32:53 +00:00
SAPHID c164606fe3 Support bounded Framey-local Decky module and CSS lookup 2026-10-26 03:32:53 +00:00
SAPHID 8d9d4807c3 Prepare Frame Control v0.4.40 version 2026-10-26 03:26:00 +00:00
SAPHID 7f8a743cb5 Clarify real Frame gate scope in Deck Shelves evidence 2026-10-26 03:21:40 +00:00
SAPHID 40df801cdb Clear stale Framey store state after status failure 2026-10-26 03:21:16 +00:00
SAPHID 35d53e6010 Merge remote-tracking branch 'origin/main' into feat/framey-chunked-frontend 2026-10-26 03:21:10 +00:00
SAPHID 1b02d7f558 Record exact Frame gates and verify installed chunk on fake Frame 2026-10-26 03:21:09 +00:00
Alex Southwell edfa48d1f5 Merge pull request #287 from saphid/fix/framey-settings-only-panel
Framey: hide empty Panel tab for settings-only plugins
2026-10-26 03:20:12 +00:00
Alex Southwell c89ddffcbd Merge pull request #285 from saphid/docs/mac-v0439-runtime
Document official Intel Mac runtime test and x64 download
2026-10-26 03:12:42 +00:00
Alex Southwell 5183f30068 Merge pull request #284 from saphid/fix/mac-server-shutdown
Fix server cleanup interrupted by late SIGTERM on Mac quit
2026-10-26 03:12:06 +00:00
SAPHID 37e2faacc7 Hide empty Panel tab for settings-only Framey plugins 2026-10-26 03:10:40 +00:00
SAPHID b0c4647b85 Support chunked official Decky frontends in Framey 2026-10-26 03:09:57 +00:00
Alex Southwell ff3b0dd6f9 Record official Intel Mac runtime checks and remaining opening limits 2026-10-26 14:02:08 +11:00
Alex Southwell 2b6b05a175 Let SIGTERM request server shutdown without interrupting cleanup 2026-10-26 13:59:17 +11:00
Alex Southwell 5c9ca110ed Merge pull request #283 from saphid/docs/v0439-release-evidence
Document v0.4.39 tagged release evidence
2026-10-26 02:57:01 +00:00
SAPHID e3540cab5e Separate released and post-release Framey coverage 2026-10-26 02:41:33 +00:00
SAPHID 0eaa9f2088 Record post-release Framey hardware checks 2026-10-26 02:39:39 +00:00
SAPHID 59f7348aa5 Merge remote-tracking branch 'origin/main' into docs/v0439-release-evidence 2026-10-26 02:39:07 +00:00
Alex Southwell 67b9151513 Merge pull request #282 from saphid/feat/framey-library-context
Framey: add read-only Steam library context for plugin panels
2026-10-26 02:37:07 +00:00
SAPHID 67a95d4f4c Document v0.4.39 tagged release verification 2026-10-26 02:31:10 +00:00
SAPHID ce6e8f807f Merge remote-tracking branch 'origin/main' into feat/framey-library-context 2026-10-26 02:26:52 +00:00
SAPHID e7fe34d865 Add detached Steam library context for Framey panels 2026-10-26 02:26:52 +00:00
Alex Southwell fd0b0a946f Merge pull request #281 from saphid/fix/framey-stale-installed-receipt
Framey: reject stale plugin update and remove requests
2026-10-26 02:19:25 +00:00
Alex Southwell 2895d5711e Merge pull request #280 from saphid/fix/framey-vr-standby-gate
Require standby before Framey VR wake or exercise
2026-10-26 02:18:50 +00:00
SAPHID 1ad6a97a36 Reject stale Decky plugin update and remove requests 2026-10-26 02:10:13 +00:00
SAPHID b795c1dc56 Wait briefly for real Framey overlay visibility 2026-10-26 02:08:41 +00:00
SAPHID db079eec9f Stop unworn input when SteamVR overlay stays hidden 2026-10-26 02:06:34 +00:00
SAPHID bc72c41be5 Update v0.4.39 support matrix with new smoke and VR evidence 2026-10-26 02:00:32 +00:00
SAPHID 697e68f098 Merge remote-tracking branch 'origin/main' into fix/framey-vr-standby-gate 2026-10-26 01:59:54 +00:00
Alex Southwell 3921e59035 Merge pull request #279 from saphid/fix/frame-smoke-shared-lock
Guard real Frame smoke with shared lock and standby preflight
2026-10-26 01:58:29 +00:00
Alex Southwell b863527c33 Merge pull request #278 from saphid/feat/framey-audio-compat
Framey: support exact slider values and verify official audio backends
2026-10-26 01:55:51 +00:00
SAPHID c08f5df677 Refuse unworn Framey wake on active headset 2026-10-26 01:54:19 +00:00
Alex Southwell c62bffc8ef Merge pull request #277 from saphid/release/v0.4.39
Prepare Frame Control v0.4.39
2026-10-26 01:51:17 +00:00
SAPHID aeb6bb5294 Guard real Frame smoke with shared lock and standby preflight 2026-10-26 01:50:32 +00:00
SAPHID 65964f0e46 Support exact slider values and verify isolated official audio backends 2026-10-26 01:34:53 +00:00
SAPHID 792e9c016e Merge remote-tracking branch 'origin/main' into release/v0.4.39 2026-10-26 01:14:31 +00:00
Alex Southwell aeac07cf54 Merge pull request #276 from saphid/docs/framey-rich-toast-sample
Framey: show rich toasts in the author sample
2026-10-26 01:14:15 +00:00
SAPHID d90d111218 Prepare Frame Control 0.4.39 2026-10-26 01:07:02 +00:00
SAPHID 8fc1117209 Demonstrate rich Framey toasts in author sample 2026-10-26 01:05:55 +00:00
Alex Southwell 442b5ecfd8 Merge pull request #264 from saphid/docs/real-mac-air-test
Document the physical Intel Mac test and honest opening steps
2026-10-26 01:05:41 +00:00
Alex Southwell 5afffbef89 Merge pull request #262 from saphid/fleet/es4ntcpvra
Build native Intel Mac installers and check bundled architectures
2026-10-26 01:05:16 +00:00
Alex Southwell 76f619280a Merge pull request #275 from saphid/docs/framey-localsend-arm64
Framey: verify a reproducible ARM64 LocalSend core in isolation
2026-10-26 00:58:53 +00:00
Alex Southwell 2b92599d94 Merge pull request #274 from saphid/feat/framey-steam-reads
Framey: relay three read-only SteamClient APIs from the Frame
2026-10-26 00:58:12 +00:00
Alex Southwell d87b863aa3 Merge pull request #273 from saphid/release/v0.4.38
Prepare Frame Control v0.4.38
2026-10-26 00:51:38 +00:00
SAPHID 6aa74619d5 Document reproducible ARM64 LocalSend core and isolated Frame health probe 2026-10-26 00:46:39 +00:00
Alex Southwell 0a463dea23 Merge remote-tracking branch 'origin/main' into docs/real-mac-air-test
# Conflicts:
#	docs/current-support-matrix.md
2026-10-26 11:44:18 +11:00
SAPHID e44ea65080 Record refreshed store and full frontend regression results 2026-10-26 00:43:11 +00:00
SAPHID ebb1091a37 Merge remote-tracking branch 'origin/main' into release/v0.4.38 2026-10-26 00:41:48 +00:00
Alex Southwell e6430c39ed Merge pull request #272 from saphid/docs/v0437-smoke-evidence
Record v0.4.37 real Frame smoke evidence
2026-10-26 00:41:35 +00:00
SAPHID b518f8c741 Note managed plugin name collision guard in release matrix 2026-10-26 00:41:18 +00:00
SAPHID 1bb5ed6216 Merge remote-tracking branch 'origin/main' into feat/framey-steam-reads 2026-10-26 00:40:46 +00:00
SAPHID 93a4ae95fa Prepare Frame Control 0.4.38 2026-10-26 00:40:46 +00:00
SAPHID e3bb608dde Read allowlisted SteamClient data from the Frame CEF context 2026-10-26 00:40:46 +00:00
Alex Southwell f2e73d98f4 Merge pull request #271 from saphid/feat/framey-ui-fallbacks
Framey: restore detached settings and private Steam JSON cache
2026-10-26 00:39:30 +00:00
Alex Southwell 78d284b154 Merge pull request #270 from saphid/feat/framey-next-compat
Framey: relay real Decky backend events to open panels
2026-10-26 00:33:44 +00:00
SAPHID 2c4581f122 Record v0.4.37 real Frame release smoke 2026-10-26 00:31:56 +00:00
SAPHID 65fee8ca8d Fill detached Steam UI and private JSON cache fallbacks 2026-10-26 00:29:30 +00:00
Alex Southwell 4372c913a6 Merge pull request #269 from saphid/fix/framey-update-name-collision
Framey: reject plugin name collisions on update
2026-10-26 00:26:08 +00:00
Alex Southwell 5239dae1a8 Merge pull request #268 from saphid/release/v0.4.37
Prepare Frame Control v0.4.37
2026-10-26 00:25:20 +00:00
SAPHID 52a0974a84 Merge remote-tracking branch 'origin/main' into feat/framey-next-compat
# Conflicts:
#	docs/frame-plugin-author.md
2026-10-26 00:19:38 +00:00
SAPHID f910c3b278 Relay scoped Decky backend events to Framey panels 2026-10-26 00:19:19 +00:00
SAPHID 9bc5294f36 Reject Decky plugin name collisions during updates 2026-10-26 00:15:48 +00:00
SAPHID 7ed08c5bbd Document Framey toast limits for 0.4.37 2026-10-26 00:14:17 +00:00
SAPHID 449146bbdb Merge remote-tracking branch 'origin/main' into release/v0.4.37 2026-10-26 00:14:04 +00:00
Alex Southwell e115006ac9 Merge pull request #267 from saphid/fix/framey-react-toasts
Framey: render React content in plugin toasts
2026-10-26 00:13:48 +00:00
SAPHID 30fbadbb23 Prepare Frame Control 0.4.37 2026-10-26 00:12:23 +00:00
Alex Southwell cae1270796 Merge pull request #266 from saphid/feat/framey-compatibility-catalog
Audit every official Decky plugin and show Frame compatibility
2026-10-26 00:11:02 +00:00
SAPHID 587a7fc8c3 Render React content in Framey panel toasts 2026-10-26 00:06:00 +00:00
SAPHID dd62de4f6d Merge remote-tracking branch 'origin/main' into fix/pr266-windows-doc 2026-10-26 00:01:16 +00:00
Alex Southwell 081d133084 Merge pull request #257 from saphid/test/framey-css-sgdb
Framey: pin CSS Loader and SteamGridDB compatibility checks
2026-10-26 00:00:41 +00:00
Alex Southwell 94e050d5a0 Merge pull request #263 from saphid/feat/framey-safe-ui-shims
Expand isolated Framey controls and cache fallbacks
2026-10-25 23:50:11 +00:00
Alex Southwell cd6ecf073f Merge pull request #265 from saphid/fleet/n781xzqt5q
Verify Framey in unworn VR and fix live settings and reopen bugs
2026-10-25 23:48:49 +00:00
SAPHID 5a4848cab9 Use UTF-8 for Framey compatibility documentation 2026-10-25 23:48:02 +00:00
SAPHID 480c6dcc36 Merge remote-tracking branch 'origin/feat/framey-safe-ui-shims' into feat/framey-compatibility-catalog 2026-10-25 23:42:47 +00:00
SAPHID 30b20cbdd6 Audit all official Decky plugins and show digest-scoped Frame compatibility 2026-10-25 23:42:47 +00:00
SAPHID 3d6bd463ce Merge remote-tracking branch 'origin/test/framey-css-sgdb' into test/framey-css-sgdb 2026-10-25 23:39:17 +00:00
SAPHID 981173a1f2 Merge remote-tracking branch 'origin/main' into test/framey-css-sgdb
# Conflicts:
#	tests/e2e/test_framey.py
2026-10-25 23:38:57 +00:00
Alex Southwell 19e8b826c6 Merge remote-tracking branch 'origin/main' into docs/real-mac-air-test 2026-10-26 10:35:28 +11:00
SAPHID 9146234758 Merge remote-tracking branch 'origin/main' into fix/pr263-main-sync 2026-10-25 23:35:28 +00:00
Alex Southwell aea65bb282 Merge remote-tracking branch 'origin/main' into fleet/es4ntcpvra 2026-10-26 10:34:56 +11:00
Alex Southwell 2c7b93f756 Fix lipo architecture verification argument order 2026-10-26 10:34:56 +11:00
SAPHID c6e54041e0 Merge remote-tracking branch 'origin/main' into fix/pr257-main-sync
# Conflicts:
#	tests/e2e/test_framey.py
2026-10-25 23:33:05 +00:00
SAPHID e67a90b397 Record real Framey VR evidence and restored device state 2026-10-25 23:31:50 +00:00
Alex Southwell 91f60c26e3 Merge pull request #261 from saphid/fleet/kp0z857q58
Framey: support legacy Decky panels and keyword RPC
2026-10-25 23:31:28 +00:00
Alex Southwell 879e84468b Record physical Intel Mac testing and clarify unsigned app opening 2026-10-26 10:30:30 +11:00
SAPHID cb9ad1b6cb Drain stale mirror frames and record native scroll delivery 2026-10-25 23:27:34 +00:00
SAPHID 285871bc39 Add locked unattended Framey VR capture and native input checks 2026-10-25 23:25:07 +00:00
SAPHID 779f9c5a53 Preserve coordinator sync of legacy bridge 2026-10-25 23:24:32 +00:00
SAPHID 43112f5143 Merge current main and preserve parameterized routes 2026-10-25 23:24:01 +00:00
SAPHID 0e6c00525d Expand isolated Framey controls and cache fallbacks 2026-10-25 23:23:47 +00:00
SAPHID 416c574240 Merge remote-tracking branch 'origin/main' into fleet/n781xzqt5q 2026-10-25 23:22:33 +00:00
Alex Southwell d77e2bd056 Merge pull request #260 from saphid/docs/framey-route-sample
Framey: demonstrate local routes in author sample
2026-10-25 23:21:51 +00:00
Alex Southwell 6ea9eb2618 Build native Intel Mac installers and verify bundled architectures 2026-10-26 10:20:58 +11:00
Alex Southwell 2a6736a02c Merge pull request #258 from saphid/docs/v0436-smoke-evidence
Record v0.4.36 real Frame smoke evidence
2026-10-25 23:20:31 +00:00
Alex Southwell c8ef81ad93 Merge pull request #259 from saphid/fix/framey-store-stale-list
Framey: clear stale store choices during refresh
2026-10-25 23:17:09 +00:00
SAPHID e529d60554 Merge remote-tracking branch 'origin/main' into fix/pr261-main-sync
# Conflicts:
#	tests/page/framey_actions.mjs
2026-10-25 23:16:34 +00:00
SAPHID e9273beaa1 Keep CSS Loader fixture neutral about legacy panel support 2026-10-25 23:16:00 +00:00
SAPHID 48b404bc3b Merge remote-tracking branch 'origin/main' into fleet/n781xzqt5q 2026-10-25 23:14:50 +00:00
SAPHID aeb991bc1c Fix Framey sample settings path and same-plugin reopen 2026-10-25 23:14:50 +00:00
Alex Southwell 63820d1909 Merge pull request #256 from saphid/feat/framey-steamgriddb
Framey: support local plugin routes with parameters
2026-10-25 23:12:51 +00:00
SAPHID 1aaa3c8c06 Support legacy Decky panels and keyword backend calls 2026-10-25 23:12:28 +00:00
SAPHID be18ed1a8b Show local route navigation in Framey author sample 2026-10-25 23:11:01 +00:00
SAPHID 3f4aaf539c Clear stale Framey store listings during refresh 2026-10-25 23:09:14 +00:00
SAPHID 3acc2d91e6 Record v0.4.36 real Frame release smoke 2026-10-25 23:08:07 +00:00
SAPHID 0867debebd Pin CSS Loader and SteamGridDB store compatibility cases 2026-10-25 23:07:09 +00:00
SAPHID e400cb1dcf Support local Framey routes with named parameters 2026-10-25 23:04:02 +00:00
Alex Southwell 1701e622dc Merge pull request #255 from saphid/release/v0.4.36
Prepare Frame Control v0.4.36
2026-10-25 22:58:26 +00:00
SAPHID 31edf6b737 Prepare Frame Control v0.4.36 2026-10-25 22:49:34 +00:00
Alex Southwell 005ad9b71d Merge pull request #253 from saphid/fix/framey-store-version-race
Reject store versions changed after review
2026-10-25 22:48:38 +00:00
Alex Southwell aaf2668882 Merge pull request #252 from saphid/test/framey-bluetooth-store
Provide Steam token fallbacks for Bluetooth panel
2026-10-25 22:39:14 +00:00
Alex Southwell cfb0310e9b Merge pull request #254 from saphid/fix/appimage-smoke-isolation
Isolate AppImage release smoke state
2026-10-25 22:32:18 +00:00
SAPHID 493ab35635 Keep stale version check within existing store flow 2026-10-25 22:29:45 +00:00
SAPHID 313d1ec9c8 Merge shipped HLTB support into Bluetooth compatibility 2026-10-25 22:25:48 +00:00
SAPHID 6ee7bacc47 Merge remote-tracking branch 'origin/main' into fix/appimage-smoke-isolation 2026-10-25 22:23:50 +00:00
SAPHID 027bbfa701 Isolate AppImage smoke network and extraction state 2026-10-25 22:23:49 +00:00
SAPHID 0f8d69a263 Use readable Bluetooth label fallbacks 2026-10-25 22:22:44 +00:00
Alex Southwell 2a9d4f886e Merge pull request #251 from saphid/feat/framey-hltb-compat
Extend Framey shim for HLTB settings panel
2026-10-25 22:22:27 +00:00
SAPHID 34cd35056b Reject store versions changed after review 2026-10-25 22:20:44 +00:00
Alex Southwell 734cd4f36c Merge pull request #250 from saphid/test/appimage-launch-ci
Smoke test the Linux AppImage in release CI
2026-10-25 22:20:18 +00:00
SAPHID 44ca81f527 Provide Steam token fallbacks for Bluetooth panel 2026-10-25 22:17:12 +00:00
SAPHID 3e5349ec39 Extend Framey shim for HLTB settings panel 2026-10-25 22:14:11 +00:00
SAPHID 3c184f5741 Smoke test packaged Linux AppImage in release CI 2026-10-25 22:10:57 +00:00
Alex Southwell 93bffc21d7 Merge pull request #249 from saphid/release/v0.4.35
Prepare Frame Control v0.4.35
2026-10-25 21:50:02 +00:00
SAPHID 8423a309ff Document API v1 Framey limits for v0.4.35 2026-10-25 21:42:29 +00:00
SAPHID 698a3b11d9 Merge remote-tracking branch 'origin/main' into release/v0.4.35 2026-10-25 21:42:13 +00:00
Alex Southwell 5952bf8e9d Merge pull request #247 from saphid/feat/framey-api-v1-panels
Host modern Decky API v1 plugin panels in Framey
2026-10-25 21:41:58 +00:00
SAPHID d186dfbc60 Merge remote-tracking branch 'origin/main' into feat/framey-api-v1-panels
# Conflicts:
#	docs/frame-plugin-author.md
#	docs/framey-loader.md
2026-10-25 21:33:37 +00:00
SAPHID c7e76fdd64 Document v0.4.35 Framey coverage limits 2026-10-25 21:33:29 +00:00
SAPHID 559eec96b3 Merge remote-tracking branch 'origin/main' into release/v0.4.35 2026-10-25 21:32:22 +00:00
Alex Southwell 020f93a187 Merge pull request #248 from saphid/docs/framey-sample-panels
Show Framey settings and quick action in author sample
2026-10-25 21:32:00 +00:00
Alex Southwell 69a912cc7d Merge pull request #245 from saphid/feat/framey-plugin-cache
Persist bounded Framey plugin cache across panel reopen
2026-10-25 21:31:53 +00:00
SAPHID aa6078c11a Keep fake Frame store test scoped to remote installs 2026-10-25 21:31:01 +00:00
SAPHID 01d1fba915 Merge remote-tracking branch 'origin/main' into release/v0.4.35 2026-10-25 21:28:14 +00:00
Alex Southwell e89ff39cff Merge pull request #246 from saphid/fix/framey-store-catalog-edges
Handle root tags and ambiguous Decky store IDs
2026-10-25 21:27:24 +00:00
SAPHID 4cfbd5883f Merge remote-tracking branch 'origin/main' into feat/framey-api-v1-panels
# Conflicts:
#	docs/framey-loader.md
2026-10-25 21:25:09 +00:00
SAPHID 33fdd9d296 Show Framey settings and quick action in author sample 2026-10-25 21:23:09 +00:00
SAPHID 45bd789cb7 Reconcile Framey cache bridge with session shim 2026-10-25 21:21:13 +00:00
SAPHID 1db65d6c82 Merge remote-tracking branch 'origin/main' into feat/framey-plugin-cache 2026-10-25 21:20:22 +00:00
SAPHID 6b6ec64381 Host modern Decky API v1 plugin panels in Framey 2026-10-25 21:20:14 +00:00
SAPHID 1e9aa42282 Prepare Frame Control v0.4.35 2026-10-25 21:19:04 +00:00
Alex Southwell 1e69ca4194 Merge pull request #244 from saphid/fix/framey-session-cache
Give isolated Framey panels a session cache
2026-10-25 21:17:49 +00:00
SAPHID 8296657a45 Handle root tags and ambiguous Decky store IDs 2026-10-25 21:13:56 +00:00
SAPHID 2b2d72ac9a Persist bounded Framey plugin cache across panel reopen 2026-10-25 21:12:30 +00:00
SAPHID fe4e09032d Provide session cache to isolated Framey panels 2026-10-25 21:09:45 +00:00
Alex Southwell 1356ea4df1 Merge pull request #243 from saphid/docs/framey-steam-status-browser
Record local Steam Status Framey frontend result
2026-10-25 11:04:33 +00:00
SAPHID c79ba7571f Record local Steam Status Framey frontend check 2026-10-25 10:55:46 +00:00
Alex Southwell 76dcd0c426 Merge pull request #242 from saphid/release/v0.4.34
Prepare Frame Control v0.4.34
2026-10-25 10:43:24 +00:00
SAPHID f668a10982 Prepare Frame Control v0.4.34 release 2026-10-25 10:35:56 +00:00
Alex Southwell 29f2f57bc0 Merge pull request #236 from saphid/feat/framey-ui-controls
Support more Decky settings controls in Framey
2026-10-25 10:35:17 +00:00
Alex Southwell a257ec0bf9 Merge pull request #241 from saphid/docs/framey-official-api-audit
Document official Decky frontend compatibility by manifest
2026-10-25 10:31:17 +00:00
Alex Southwell 99504554e0 Merge pull request #240 from saphid/feat/framey-sample-recovery
Recover Framey sample settings from damaged data
2026-10-25 10:28:41 +00:00
Alex Southwell b53293eddd Merge pull request #239 from saphid/feat/framey-zip-collisions
Reject colliding Decky plugin ZIP paths
2026-10-25 10:23:20 +00:00
Alex Southwell 229f17696e Merge pull request #238 from saphid/feat/framey-official-fixtures
Test official Decky plugin archives in fake Frame
2026-10-25 10:22:58 +00:00
SAPHID f6a88f1a51 Merge remote-tracking branch 'origin/main' into feat/framey-ui-controls
# Conflicts:
#	docs/frame-plugin-author.md
#	tests/page/framey_actions.mjs
#	ui/framey-host.js
2026-10-25 10:19:20 +00:00
Alex Southwell b7caa7d7d3 Merge pull request #237 from saphid/fix/framey-disabled-controls
Honor disabled state in Framey plugin controls
2026-10-25 10:18:09 +00:00
SAPHID a61dfdd3ab Recover sample Decky settings from damaged data 2026-10-25 10:17:29 +00:00
SAPHID 840f53f032 Reject colliding Decky plugin ZIP paths 2026-10-25 10:16:03 +00:00
SAPHID 5932b6a8a4 Document official Decky frontend API compatibility audit 2026-10-25 10:15:48 +00:00
SAPHID 3f2e32db87 Test pinned official Decky plugins in fake Frame 2026-10-25 10:14:14 +00:00
SAPHID a6a1e0b258 Honor disabled state in Framey plugin controls 2026-10-25 10:09:57 +00:00
SAPHID db74e3be96 Support more Decky panel controls in Framey 2026-10-25 10:09:56 +00:00
Alex SouthwellandSAPHID faf7bc6224 Prepare Frame Control 0.4.33 (#235)
* Prepare Frame Control 0.4.33

* Record Framey sample and Decky recovery limits

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:51:10 +00:00
Alex SouthwellandSAPHID 289a971c90 Add Decky settings sample for Framey plugin authors (#233)
* Provide Decky automatic JSX runtime in Framey panel

* Add Decky settings sample for Framey authors

* Use official Decky settings path in sample backend

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:39:01 +00:00
Alex SouthwellandSAPHID f2f6085887 Allow Decky plugin removal when loader is stopped (#234)
* Allow safe Decky plugin removal when loader is stopped

* Stop failed Decky unit before recovery removal

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:34:39 +00:00
Alex SouthwellandSAPHID d1541099b8 Clear stale Framey job IDs after app restart (#232)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:30:52 +00:00
Alex SouthwellandSAPHID 0440959834 Provide Decky automatic JSX runtime in Framey panel (#231)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:26:47 +00:00
Alex SouthwellandSAPHID 482d031a18 Serialize managed Decky changes across desktop and headset (#230)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:24:48 +00:00
Alex SouthwellandSAPHID 20a5ced63d Test Framey settings persistence across plugin lifecycle (#229)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 09:20:19 +00:00
Alex SouthwellandSAPHID 02fe664583 Prepare Frame Control 0.4.32 (#228)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 08:06:51 +00:00
Alex SouthwellandSAPHID e2ffcd34f3 Support Decky panel routes and toasts in Framey (#227)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

* Show Decky plugin job progress in VR

* Show plugin quick actions and settings in Framey panel

* Add local Decky routes and toasts to Framey

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:56:38 +00:00
Alex SouthwellandSAPHID 2002b9d70e Show Decky plugin quick actions and settings in VR (#226)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

* Show Decky plugin job progress in VR

* Show plugin quick actions and settings in Framey panel

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:46:32 +00:00
Alex SouthwellandSAPHID 9b86264813 Show Decky plugin install progress and errors in VR (#225)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

* Show Decky plugin job progress in VR

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:38:25 +00:00
Alex SouthwellandSAPHID ba97a67580 Browse and search Decky store in VR (#224)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

* Browse and search the full Decky store in VR

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:28:23 +00:00
Alex SouthwellandSAPHID c7a17478a3 Host Decky plugin panels in Frame Control VR (#223)
* Host Decky plugin panels in Frame Control VR

* Record Framey VR verification limits

* Lock Frame before worn checks and clean private captures

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:16:05 +00:00
Alex SouthwellandSAPHID a625800d62 Bump Frame Control to 0.4.31 (#222)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 07:04:38 +00:00
Alex SouthwellandSAPHID f0f39ac823 Prepare Frame Control 0.4.31 and record round-two validation (#221)
* Prepare Frame Control 0.4.31 with round-two validation

* State the x86 Linux runtime limit in smoke evidence

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:54:03 +00:00
Alex SouthwellandSAPHID 1252ecc7af Add read-only large status screen to headset panel (#220)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:46:01 +00:00
Alex SouthwellandSAPHID 7b53af1c18 Fix OBS lifecycle cleanup and validate real headset capture (#219)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:42:11 +00:00
Alex SouthwellandSAPHID 1255ef2bbf Fix Steam clip export collisions across headsets, accounts and sessions (#217)
* Fix Steam clip export collisions across headset and session identities

* Group clip export changelog with screenshot copying

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:37:51 +00:00
Alex SouthwellandSAPHID fdd60d3ccd Fix auto-sync retry recovery and headset-bound listings (#218)
* Fix auto-sync retry recovery and headset-bound listings

* Read auto-sync Settings fixture as UTF-8 on Windows

* Keep auto-sync changelog separate from parallel fixes

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:35:47 +00:00
Alex SouthwellandSAPHID d5fa8feb31 Fix orphan smoke cleanup and portable smoke wrapper (#216)
* Fix orphan smoke cleanup and portable smoke wrapper

* Resolve smoke wrapper fixture working directory on macOS

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:31:45 +00:00
Alex SouthwellandSAPHID 4e07409627 Document Steam Frame plugin research and verification limits (#210)
* Record Steam Frame plugin research and fresh read-only evidence

* Update Decky research for official store audit

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:29:47 +00:00
Alex SouthwellandSAPHID da34d94009 Skip backup paths with symlinked ancestors (#215)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:19:38 +00:00
Alex SouthwellandSAPHID 234b0a1814 Add managed Decky store and Frame plugin compatibility audit (#208)
* Add managed Decky store plugins and Frame compatibility audit

* Reject malformed flags and linked plugin folders

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:15:42 +00:00
Alex SouthwellandSAPHID 7a8e919546 Validate extension bridge API versions before loading panels (#209)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 06:13:55 +00:00
Alex SouthwellandSAPHID d79efb4730 Bump Frame Control to 0.4.30 (#207)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:39:30 +00:00
Alex SouthwellandSAPHID 95643b7ea0 Support restricted split APKs in Android backup import (#206)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:29:21 +00:00
Alex SouthwellandSAPHID 9dab523af9 docs: distinguish current Frame OS checks from September notes (#205)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 05:13:12 +00:00
Alex SouthwellandSAPHID a29fac2594 Bump Frame Control to 0.4.29 (#204)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 04:21:10 +00:00
Alex SouthwellandSAPHID 6d5cb20847 Document ProtonDB Badges test on Steam Frame (#202)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 04:11:01 +00:00
Alex Southwell 987c36c5f6 Merge pull request #201 from saphid/fleet/se9k61wn4f-headset-actions
Add headset quick actions and extension panels
2026-10-25 04:10:29 +00:00
Alex Southwell 649185237f Merge pull request #198 from saphid/fleet/se9k61wn4f-headset-ui
Add removable Frame Control panel for SteamVR
2026-10-25 04:09:48 +00:00
Alex SouthwellandSAPHID 5b43170fda Bump Frame Control to 0.4.28 (#203)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:59:02 +00:00
SAPHID 0cb085dc50 Check POSIX screenshot mode only on POSIX 2026-10-25 03:56:24 +00:00
SAPHID 61db62edcf Make headset screenshot test portable 2026-10-25 03:52:23 +00:00
SAPHID b0a65129f1 Add headset quick actions and extension panels 2026-10-25 03:47:30 +00:00
Alex SouthwellandSAPHID 958743c79d Docs: guide SteamOS OBS host setup with test limits (#200)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:47:21 +00:00
Alex SouthwellandSAPHID 05308932e8 Docs: distinguish PC SteamVR stream failures from SSH (#199)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:46:57 +00:00
Alex SouthwellandSAPHID 4e4853e82b Docs: explain streaming lag and Japanese setup (#197)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:41:41 +00:00
SAPHID d44b6121ae Use POSIX member paths in headset bundle on Windows 2026-10-25 03:39:53 +00:00
SAPHID af6f97ca5a Add removable Frame Control headset panel 2026-10-25 03:33:58 +00:00
Alex SouthwellandSAPHID 3438c248d9 Bump Frame Control to 0.4.27 (#196)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:24:52 +00:00
Alex SouthwellandSAPHID 509ddbdc95 Add experimental Decky Loader manager for Steam Frame (#195)
* Add sandboxed local Frame Control extensions

* Add experimental Decky Loader manager for Steam Frame

* ci: verify Decky PR against main

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 03:14:42 +00:00
Alex SouthwellandSAPHID f2d293bd1a Add local Frame Control extensions (#194)
* Add sandboxed local Frame Control extensions

* docs: clarify local extension network boundary

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 02:48:37 +00:00
Alex Southwell a5a554d0c2 Document real Frame screenshot Save verification (#193)
Document real Frame screenshot Save verification
2026-10-25 02:36:30 +00:00
SAPHID af2dca8365 Document real Frame screenshot copy check 2026-10-25 02:29:56 +00:00
Alex Southwell 6a8e0093de Keep equal Steam screenshot filenames from overwriting each other (#192)
Keep equal Steam screenshot filenames from overwriting each other
2026-10-25 02:28:29 +00:00
SAPHID 53107fd91b Keep saved Steam screenshots separate by headset and game 2026-10-25 02:17:29 +00:00
Alex SouthwellandSAPHID 548f5d8496 Bump Frame Control to 0.4.26 (#191)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 01:48:22 +00:00
Alex SouthwellandSAPHID a03c94ae12 Attach SHA256SUMS and explain download verification (#186)
* Attach and verify release SHA256SUMS with download guidance

* Write release checksums as exact LF bytes on Windows

* Read release safety text as UTF-8 on Windows

* Clarify checksum rollout and macOS signing status

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 01:38:26 +00:00
Alex SouthwellandSAPHID dd3df85fd6 Stop stalled OBS relays and handle intentional shutdown (#190)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 01:34:13 +00:00
Alex SouthwellandSAPHID e7bfa80e09 Add opt-in Open at Login with tray startup (#189)
* Add opt-in Open at Login with tray startup

* Show login window when Linux tray has no visible icon

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 01:32:10 +00:00
Alex SouthwellandSAPHID b26e34ce09 Document current real-device, VM and untested coverage (#188)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 01:11:55 +00:00
Alex SouthwellandSAPHID ab4b58b1c7 Document OBS game audio capture issue and workaround (#187)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 01:08:10 +00:00
Alex SouthwellandSAPHID 74089fb2f1 Bump Frame Control to 0.4.25 (#185)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 00:43:52 +00:00
Alex SouthwellandSAPHID 7f828cbcf8 Compare Windows wireless snapshots after dropouts (#184)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 00:35:48 +00:00
Alex SouthwellandSAPHID 42665d14db Guide recovery for Windows Wireless Adapter dropouts (#183)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 00:23:39 +00:00
Alex SouthwellandSAPHID 8dd6eb3fe9 Bump Frame Control to 0.4.24 (#182)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 00:06:07 +00:00
Alex SouthwellandSAPHID ef309c9931 Add window switch control to desktop panels (#181)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:57:35 +00:00
Alex SouthwellandSAPHID de0f0eb0e3 Bump Frame Control to 0.4.23 (#180)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:39:29 +00:00
Alex SouthwellandSAPHID bdef125f68 Enable OBS source control on Windows (#179)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:31:23 +00:00
Alex SouthwellandSAPHID f64cbe5bac Add read-only wired connection doctor (#178)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:23:29 +00:00
Alex SouthwellandSAPHID d4714eadf7 Open Android catalogue without waiting for installed apps (#177)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:04:06 +00:00
Alex SouthwellandSAPHID bcdd69f6d8 Bump Frame Control to 0.4.22 (#176)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 22:45:11 +00:00
Alex SouthwellandSAPHID f5becdd2c0 Document real OBS Media Source verification (#175)
* Document real OBS Media Source video and audio verification

* Record packaged OBS Start and Stop headset check

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 22:33:11 +00:00
Alex SouthwellandSAPHID e185cb3b29 Bundle OBS source with desktop Start and Stop control (#174)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 22:05:20 +00:00
Alex SouthwellandSAPHID 2320dc4d0e Bump Frame Control to 0.4.21 (#173)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:40:55 +00:00
Alex SouthwellandSAPHID affe3eda65 Record Round 3 headset mirror and charge-limit checks (#172)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:30:51 +00:00
Alex SouthwellandSAPHID 9e9b25e9f4 Add local OBS headset view source with output audio (#170)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:29:23 +00:00
Alex SouthwellandSAPHID f7153b3ab1 Guide emulator setup and clarify ROM storage limits (#171)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 21:28:49 +00:00
Alex SouthwellandSAPHID 5c987ae8b8 Bump Frame Control to 0.4.20 (#167)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 13:32:38 +00:00
Alex SouthwellandSAPHID 42b0d729cb Document Frame OS 0.5.6 live compatibility findings (#168)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 13:24:32 +00:00
Alex SouthwellandSAPHID ae63fa584c Add read-only Windows Wireless Adapter doctor (#166)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:44:24 +00:00
Alex SouthwellandSAPHID 337ac99b3e Add a read-only Linux Wireless Adapter doctor (#165)
* Add read-only Linux PC Wireless Adapter doctor

* Cross-reference coordinated SteamOS compatibility notes

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:30:08 +00:00
Alex SouthwellandSAPHID aada257325 Handle Frame OS 0.5.6 discovery and SteamOS name changes (#164)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:24:04 +00:00
Alex SouthwellandSAPHID 8bc3d63126 Add local Linux helper for Steam PipeWire desktop sharing (#163)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:18:08 +00:00
Alex SouthwellandSAPHID c690e691a3 Bump Frame Control to 0.4.19 (#162)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 12:04:30 +00:00
e3fcd3eef8 Use gamescope input for Frame trackpad and keyboard (#66)
* Live view: a Desktop view that stays still, and Control to tap on the Frame

The live view gets a second source and a way to use the Frame from it:

- Desktop: the app panel in use in the headset, streamed from its own window
  (x11grab of gamescope's redirected window), so it doesn't move as the
  wearer looks around. A picker shows any other panel, view only.
- Control: on the Desktop view a tap or click lands exactly where you put it;
  drag is a mouse drag, press and hold right-clicks, two fingers scroll, and
  on a computer the mouse, wheel and keyboard work directly. On the headset
  view the view is a trackpad. A text field and key row type from a phone.

Input goes through gamescope's own EIS socket (the way Steam feeds Remote
Play input) with the libei already on the image: ui/frame_touch.py, over
the same long-lived ssh machinery as the keyboard agent, nothing to
install. It reaches the panel that has focus on either X display, which
the KDE Connect route can't. Verified on the Frame and from the iPhone app
in the Simulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: fixes from review

- Keys held on the Frame are released with buttons when Control stops or
  the view loses focus; keys for the Frame no longer trigger Frame Control's
  own shortcuts.
- Taps only act when the picture on screen is the panel in use; positions,
  presses, keys, text and scrolls name their panel (display and window: ids
  repeat across :0 and :1, told apart by pid), and the Frame drops them if
  focus has moved on. Releases always go.
- While connecting, a tap keeps its position; on an error only releases wait
  and retries back off; trimming a long queue never drops a release.
- Lifting one of two scrolling fingers ends the scroll; a cancelled touch
  isn't a tap; clicks and holds on the bars around the picture do nothing.
- A capture loop from before a Live restart can't stop the new video.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: close the targeting gaps from the second review

- The focused panel's display comes from GAMESCOPE_FOCUS_DISPLAY (gamescope
  packs ":1" into the first value), so a window id repeated across :0 and :1
  can't be mistaken; the pid is only the fallback.
- Presses, keys, text and scrolls read focus afresh on the Frame; only moves
  use a reading up to a second old.
- A gesture remembers the panel it started on and does nothing more if that
  stops being the one in use; a press with no panel to aim at isn't sent.
- Opening a screenshot clears the panel Control would act on; switching to
  another app releases held keys and buttons.
- Trimming keeps a click with its position; the error backoff holds for new
  input too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: fixes from the SWE-2 Max review

- The Frame side tracks keys as well as buttons and lets go of both when the
  session ends.
- A stale tap tells the page, which re-reads the panels at once.
- A paused input device waits instead of ending the session; only a
  disconnect does. An OS error on one event skips it.
- Presses check focus with two property reads and do the full lookup only
  when it changed.
- Writes to an agent's stdin are serialized, so two devices sending at once
  can't tear a line (the keyboard agent too).
- Connecting gives up with a message after 15 s instead of hanging on
  "Connecting…"; text goes in 100-character pieces so releases don't wait
  behind a long paste; a cancelled mouse gesture releases what's held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Control: stale clears, pauses reconverge, pastes split per request

- The Frame says it has caught up as soon as an aimed event lands after a
  stale one, so the page stops re-reading the panels.
- After a device pause it lets go of everything it holds (releases that
  arrived while paused were dropped), and waits for the device once per
  batch, not once per event.
- The quick focus check no longer freshens the panel geometry's age.
- Each request carries at most about 100 characters of text.
- Turning Control off while it connects doesn't report an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* frame_touch: build the socket path on the Frame, so Windows can import it for tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* frame_touch: any event that goes through clears the stale flag

A trackpad move names no panel, so waiting for an aimed event could leave
the page re-reading panels for the rest of the session; a release still
aimed at the old panel doesn't count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Trackpad and key row go through gamescope's input; KDE Connect only for accents and emoji

The pad reaches whichever panel has focus (Steam's menus too), needs nothing installed, and no longer clamps to 1280x720. Characters that aren't on a US keyboard start KDE Connect the first time they're typed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Touch: a bare release no longer clears the stale-focus notice before the page has seen it

The page leaves the panel off releases, so a release says nothing about focus.
Found in review of the stale-latch fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pad: keep typing in order and acknowledge it once, with accents through one KDE Connect queue

Review found accent-only input repeated forever (sent was false), text split
across two transports out of order, accents stuck until the next keypress,
duplicated on retry, and racy. A batch with an accent now sends its whole
keyboard through KDE Connect; later keys follow while any are queued; the
server owns one ordered, bounded queue drained by a single thread; sent means
the whole batch is taken.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pad: drain the KDE Connect queue without losing, stranding or reordering input

Second review found the queue could be trimmed under an in-flight send (deleting
unsent events), the worker could retire as a request enqueued, and ASCII could
overtake an accent still being typed (or the reverse). The worker now takes its
batch out while sending and puts it back on failure, retires under the lock, and
each way waits out the other's estimated typing time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Pad: count gamescope's real typing time, with the settle margin added once

Review found the estimate used 12 ms a character where the agent takes 16 ms
(32 shifted), and added the margin on every request so it piled up.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Document gamescope pad verification and shared-device limits

* Prevent Unicode input startup from deadlocking a headset switch

* Record real focused-panel click and ASCII key delivery

* Retain accepted Unicode input under queue saturation and failure

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 11:54:36 +00:00
Alex SouthwellandSAPHID 0da1fe5077 Bump Frame Control to 0.4.18 (#161)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 10:53:48 +00:00
1f7615b312 Keep theatre playback running without optional surround (#51)
* Never end playback over the cosmetic surround; always shut down OpenVR

Review (SWE-2 Max): a non-standby error re-sending the theatre surround
could end a healthy still, and a non-RuntimeError during DestroyOverlay
skipped VR_ShutdownInternal. drain() now drops a surround that fails for
other reasons, and close() shuts down in finally. Fake overlay handles no
longer depend on call order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Play without the theatre surround if SteamVR refuses it

Review (SWE-2 Max): a non-standby error creating the cosmetic surround
at startup still ended playback before the screen appeared. Theatre now
logs it and plays without the surround. close() treats any per-overlay
teardown error as best effort (signals are already ignored there).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Verify media surround fallback and guard server startup ownership

* Record exact-head real Frame theatre playback smoke

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 10:45:48 +00:00
Alex SouthwellandSAPHID 2ded4c5a0d Document saved panel layout feasibility on SteamVR 2.18.2 (#160)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 10:19:35 +00:00
Alex SouthwellandSAPHID 5b47cd3330 Document packaged Desktop Capture result and visibility limit (#159)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 10:03:36 +00:00
Alex SouthwellandSAPHID aead570b5d Clarify Return to Game Mode switch help (#158)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:47:24 +00:00
Alex SouthwellandSAPHID de931bc56f Document packaged Return to Game Mode button verification (#157)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:39:21 +00:00
Alex SouthwellandSAPHID 71288d8980 Document packaged desktop Steam clip Export verification (#156)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:19:46 +00:00
Alex SouthwellandSAPHID 64b9babe87 Document Linux PC-host feasibility blocker on lxso2 (#155)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:17:14 +00:00
Alex SouthwellandSAPHID 4e456480ae Document real 100-second Steam clip export (#154)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:05:12 +00:00
Alex SouthwellandSAPHID 241f538f99 Bump version to 0.4.17 (#153)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:43:00 +00:00
Alex SouthwellandSAPHID 21dacfcbe7 Distinguish unreadable panel discovery from an empty list (#152)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:34:56 +00:00
Alex SouthwellandSAPHID eb181d8996 Add opt-in desktop low-battery comfort alerts (#150)
* Add opt-in desktop low-battery comfort alerts

* Fix desktop battery alert test harness and Windows encoding

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:22:56 +00:00
Alex SouthwellandSAPHID 827ddcf14f Clarify desktop installation and first headset connection (#151)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:08:47 +00:00
Alex SouthwellandSAPHID 7c3894e630 docs: record real Frame clip Export button check (#149)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:04:06 +00:00
Alex SouthwellandSAPHID 040581a4c7 Bump Frame Control to 0.4.16 (#148)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:42:37 +00:00
Alex SouthwellandSAPHID 17f6d52717 Document real Steam clip export verification (#147)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:34:34 +00:00
Alex SouthwellandSAPHID b4e80cab0a Escape power report CSV metadata for spreadsheets (#146)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:28:26 +00:00
Alex SouthwellandSAPHID b98e3e496f Bump Frame Control to 0.4.15 (#145)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:08:23 +00:00
Alex SouthwellandSAPHID a148e02d39 Add bounded read-only power and heat reports to desktop Tools (#144)
* Add bounded desktop power and heat observations with offline downloads

* Fix desktop power tests on Python 3.9

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:58:19 +00:00
Alex SouthwellandSAPHID 0fb9174c23 Fix Return to Game Mode detection in full Plasma (#143)
* Fix Frame session detection for full Plasma Desktop Mode

* Document real Frame Desktop Mode return verification

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:46:17 +00:00
Alex SouthwellandSAPHID c7489a88eb Bump Frame Control to 0.4.14 (#141)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:09:59 +00:00
Alex SouthwellandSAPHID be74850a8e Add accessible offline power sampler exports with partial results (#140)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:59:53 +00:00
Alex SouthwellandSAPHID dc21428f85 Add guarded read-only doctor plans and before/after reports (#139)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:51:49 +00:00
Alex SouthwellandSAPHID fa43c83b06 Recover release uploads from untagged drafts (#138)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:35:49 +00:00
Alex Southwell 1d4c57d648 Merge pull request #137 from saphid/release/0.4.13
Bump Frame Control to 0.4.13
2026-10-24 05:11:37 +00:00
SAPHID c6215712fe Bump Frame Control to 0.4.13 2026-10-24 05:02:27 +00:00
Alex SouthwellandSAPHID cd7ab2d9f7 Import standalone APK/OBB bundles through the current backup flow (#136)
* feat(android): inspect standalone APK and OBB bundles safely

* test: decode Android page fixture as UTF-8

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:01:32 +00:00
Alex SouthwellandSAPHID 4b68971383 Add read-only power and heat sampler for issue #52 (#135)
* Add read-only power and heat sampler for issue 52

* Make power sampler IIO fixture portable on Windows

* test: normalize Windows fixture paths for power sampler

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 04:43:47 +00:00
Alex Southwell 54c001d537 Merge pull request #134 from saphid/release/0.4.12
Bump Frame Control to 0.4.12
2026-10-24 04:29:09 +00:00
SAPHID 5ce5c023b6 Merge remote-tracking branch 'origin/main' into release/0.4.12 2026-10-24 04:20:33 +00:00
Alex Southwell 709fef0dbd Merge pull request #133 from saphid/t3/android-compat-verdict-search
Android store: show compatibility reports without blocking search
2026-10-24 04:18:57 +00:00
SAPHID 49408dc171 Bump Frame Control to 0.4.12 2026-10-24 04:12:30 +00:00
SAPHID c728e64fbd Merge remote-tracking branch 'origin/main' into coord/resolve-133
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:11:32 +00:00
Alex Southwell 0626c52e12 Merge pull request #132 from saphid/t3/fix-log-scrubber-edge-cases
Fix display-name overlaps in optional log redaction
2026-10-24 04:10:58 +00:00
SAPHID b9b4a00485 Merge remote-tracking branch 'origin/main' into coord/resolve-132
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:00:26 +00:00
Alex Southwell eb125b59c7 Merge pull request #131 from saphid/fix/issue-87-recovery-polish
Fix remaining host-key recovery UI clipping and punctuation
2026-10-24 03:59:21 +00:00
SAPHID 7e53fe32cb Show Android compatibility reports without blocking search 2026-10-24 03:58:37 +00:00
SAPHID ebae409632 Fix overlapping display-name redaction in optional logs 2026-10-24 03:56:45 +00:00
SAPHID 08cb267ce4 Fix remaining host-key recovery clipping and tooltip punctuation 2026-10-24 03:52:38 +00:00
Alex Southwell 0119544c3e Merge pull request #130 from saphid/release/0.4.11
Frame Control 0.4.11
2026-10-24 03:42:24 +00:00
SAPHID 2a9e7dcabf Bump Frame Control to 0.4.11 2026-10-24 03:34:25 +00:00
Alex Southwell 5a9099dddb Merge pull request #129 from saphid/t3/test-windows-vm-headset
Fix Windows PC screen capture with the bundled GStreamer libraries
2026-10-24 03:33:31 +00:00
SAPHID ec4086db69 Probe capture properties in initialized native adapter 2026-10-24 03:25:28 +00:00
SAPHID 5a7c3541f8 Merge current main into Windows capture fix 2026-10-24 03:20:54 +00:00
SAPHID 525040162e Fix Windows PC screen capture with DXGI-only GStreamer bundles 2026-10-24 03:17:11 +00:00
Alex Southwell 21e771d16e Merge pull request #128 from saphid/t3/headset-game-storage
Show headset space and read-only Steam game storage
2026-10-24 03:15:37 +00:00
SAPHID 2cd095a8c4 Docs: align testing summary with real backup evidence 2026-10-24 03:08:37 +00:00
SAPHID bb9236b2b7 Merge main into storage and retain release notes 2026-10-24 03:08:15 +00:00
Alex Southwell 1bc090d00d Merge pull request #127 from saphid/feat/frame-doctor-read-only
Add read-only Frame doctor for setup and pairing diagnostics
2026-10-24 03:07:35 +00:00
SAPHID 63ba25ffdd Merge main into doctor and retain both changelog entries 2026-10-24 02:58:52 +00:00
Alex Southwell 98e6e90d84 Merge pull request #126 from saphid/t3/fix-smoke-process-cleanup
fix: stop test-owned smoke processes before removing titles
2026-10-24 02:58:08 +00:00
SAPHID 7c061846bd Tests: run doctor Bash wrapper only on POSIX 2026-10-24 02:55:40 +00:00
Alex Southwell a91d55151f Merge pull request #125 from saphid/feature/battery-health-v0410
Improve read-only Frame battery health monitoring
2026-10-24 02:51:38 +00:00
SAPHID 7f25e87b42 Docs: record real Frame doctor safety stop 2026-10-24 02:49:35 +00:00
SAPHID ff245953ac Docs: record read-only storage probe on Frame 2026-10-24 02:49:33 +00:00
Alex Southwell 46665d51c0 Merge pull request #124 from saphid/fleet/mppkd9qczt
Docs: record real Frame clipboard check and remaining test gaps
2026-10-24 02:48:12 +00:00
SAPHID 67615e85c3 Docs: record real Proton smoke cleanup verification 2026-10-24 02:47:26 +00:00
SAPHID 9e3c3cd31d Add privacy-safe read-only Frame doctor diagnostics 2026-10-24 02:45:44 +00:00
SAPHID 537754ebce Add read-only headset game storage inventory 2026-10-24 02:44:45 +00:00
SAPHID 573a9e2730 test: keep Linux-only signal checks off other desktop platforms 2026-10-24 02:42:53 +00:00
SAPHID 8b91b58336 Docs: record read-only battery parser check on Frame 2026-10-24 02:42:45 +00:00
SAPHID 12c60ec024 fix: inspect available process evidence without touching protected peers 2026-10-24 02:41:50 +00:00
SAPHID 60261f093f fix: stop owned smoke processes before removing titles 2026-10-24 02:40:26 +00:00
SAPHID 3120a3cacf Add read-only Frame battery health and capacity monitoring 2026-10-24 02:40:06 +00:00
SAPHID fecb226e43 Docs: record round-2 Frame verification and remaining gaps 2026-10-24 02:39:25 +00:00
Alex Southwell e9cbb8fd55 Merge pull request #122 from saphid/release/0.4.10
Frame Control 0.4.10
2026-10-24 01:22:05 +00:00
SAPHIDandClaude Opus 5.5 d72be95f25 Frame Control 0.4.10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:15:19 +00:00
Alex Southwell 65fedbf01e Merge pull request #121 from saphid/feat/windows-pc-in-headset
PC in the headset on Windows (Experimental)
2026-10-24 01:15:04 +00:00
SAPHIDandClaude Opus 5.5 311ee70783 CI: test the installed Windows app's PC libraries, after the installer check
Running the bundled Python against win-unpacked wrote __pycache__ into
the copy the installer check compares with, so the check failed. The
test now runs after that check, on the installed app, without writing
bytecode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:50:36 +00:00
SAPHIDandClaude Opus 5.5 f832d0b912 CI: retry the MSYS2 package install for the Windows PC host
A mirror answered HTTP 429 mid-install and failed the Windows installer
build. setup-msys2 still does the system update; the package install is
now its own step with three attempts, in release.yml and pc-host.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:42:47 +00:00
SAPHIDandClaude Opus 5.5 361bcf8a1b Merge origin/main into feat/windows-pc-in-headset
ui/index.html: main added the Back up tool link beside the Mac one;
both links and both host checks are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:34:09 +00:00
SAPHIDandClaude Opus 5.5 10f86d3e7a Docs: PC in the headset is experimental, Windows only, and unverified on a desktop
Rewrites docs/pc-in-headset.md for the Windows scope, with #38's
evidence kept under the revisions it was measured at. README marks it
Experimental; testing.md, mac-in-headset.md and THIRD_PARTY_NOTICES.md
(the bundled GStreamer) follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:29:12 +00:00
SAPHIDandClaude Opus 5.5 5fea1526ca Bundle the PC host in the Windows installer only, without x264
#38 built and bundled the native host into the Windows and both Linux
installers (adding a Linux ARM64 installer). Only the Windows installer
gets it now; the Linux and macOS installer jobs are as on main.

x264 is GPL and Windows encodes with Media Foundation, so the shipped
bundle leaves it out. desktop/build.py --with-x264 adds it for the
native CI jobs' headless protocol tests, which no longer upload their
bundles. The release job tests the packaged app's own bundle with its
bundled Python and checks x264 isn't in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:29:12 +00:00
SAPHIDandClaude Opus 5.5 7a7dca70de PC in the headset: Windows only, Experimental and off until turned on
host_view now picks the PC host on Windows only; Linux and macOS keep
the Mac view as on main, so #38's Linux adapter is never offered. The PC
host starts nothing, loads no native library and lists no windows until
the person ticks "Turn on PC in the headset" on the card (action
"enable" on /api/macview). Turning it off ends streams, viewers and the
helper. The choice is a client preference, like mvSeparate.

Tools lists "PC in the headset" on Windows only, beside the macOS-only
Mac link, with an Experimental tag and a short note on what hasn't been
tried. The Mac card's wording and Linux-portal tweaks from #38 go back
to main's, apart from the shared "your computer" hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:29:12 +00:00
Alex Southwell bf8c500a87 Merge pull request #118 from saphid/feat/backup-recovery-kit
Backup and recovery kit: saves, settings and Android data, restore after a reset, Return to Game Mode
2026-10-24 00:23:51 +00:00
SAPHIDandClaude Opus 5.5 71fe208fea Keep the Mac helper on its own Swift rate controller
#38 swapped the Mac agent's Swift RateController for a binding to
desktop/controller.c. That is a change to a shipped, headset-verified
feature that this Windows-only PR can't test on a Mac, so the Mac agent,
its build script and the staleness check go back to main. The C port
stays for the Windows host and is still checked against the frozen trace
of the Swift original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:17:05 +00:00
SAPHIDandClaude Opus 5.5 2b838ede9e Backup docs: what was tested on a real Frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:16:38 +00:00
SAPHIDandClaude Opus 5.5 8402a671ef Merge origin/pc-in-headset (PR #38) into feat/windows-pc-in-headset
Brings in #38's native PC host. The only conflict was ui/index.html: main
moved the macview card under Tools, so this keeps main's layout; #38's
loadMacView changes applied cleanly. Scoping to Windows and the
Experimental opt-in follow in separate commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:14:24 +00:00
SAPHID 310e778d00 Merge remote-tracking branch 'origin/main' into feat/backup-recovery-kit
# Conflicts:
#	ui/index.html
2026-10-24 00:13:26 +00:00
SAPHIDandClaude Opus 5.5 a909beb39e Return to Game Mode: recognise gamescope-wl, and switch only when Desktop Mode is seen
On a Frame (build 20261015.6141207) gamescope runs as gamescope-wl, so the session
check said unknown, and the button would have restarted a headset that was in
Game Mode. Game Mode now also counts gamescope-wl and start-gamescope-session, and
the switch runs only when Plasma is running without gamescope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:13:17 +00:00
Alex Southwell e2601965bd Merge pull request #120 from saphid/fix/record-stop
Recording: Stop actually stops ffmpeg on the Frame
2026-10-24 00:11:10 +00:00
SAPHID 96502e6e4d Merge remote-tracking branch 'origin/main' into fix/record-stop 2026-10-24 00:03:53 +00:00
Alex Southwell d5f26efc1a Merge pull request #119 from saphid/feat/auto-sync
Copy new screenshots and Steam clips to the computer automatically
2026-10-24 00:03:43 +00:00
SAPHIDandClaude Opus 5.5 eac9557286 Recording: stop ffmpeg with q on a FIFO, not SIGTERM, which SteamOS's ffmpeg ignores
On a Frame (SteamOS 0.5.6, build 20261015.6141207) Stop left ffmpeg recording:
ffmpeg n7.0 there catches SIGTERM and SIGINT and carries on. The recording shell
now gives ffmpeg a FIFO as stdin, types q into it on Stop or a dropped channel, and
SIGKILLs it if it's still there 10 s later. Tested end to end on that Frame: an
8 s 1080p H.264 + AAC .mp4 saved 1.3 s after Stop, nothing left on the Frame.

Also records the read-only charge-limit checks run on the same Frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:02:39 +00:00
Alex Southwell 4aa9f18fd1 Merge pull request #117 from saphid/feat/force-quit
Force quit a running game from Games and the Control Center
2026-10-23 23:47:42 +00:00
SAPHIDandClaude Opus 5.5 fa40e30f3c Force quit never lists or quits SteamVR: on the Frame it's the headset view, not a game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:38:14 +00:00
SAPHIDandClaude Opus 5.5 26206e85b4 Copy new screenshots and Steam clips to the computer automatically
Settings → Copy to this computer, off by default, with a switch each for
screenshots and Steam clips. While a headset is connected, a worker in the
server looks about once a minute and copies what's new with save_shots and
frame_clips.export_clip: only what's newer than turning it on, never what it
copied before (remembered per headset in auto-sync.json, so deleted files stay
deleted), never Steam's background recording or a clip written to in the last
30 s. It waits for other work on the headset, backs off to 15 minutes while
the headset is unreachable, and pauses when this computer is out of space.
Copy everything now copies the rest once.

The Frame-side clip listing now reports each recording's idle time, by the
Frame's clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:33:37 +00:00
SAPHIDandClaude Opus 5.5 d7a887b98c Force quit's unit tests run on Windows too: no SIGKILL or getuid there
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:29:10 +00:00
SAPHIDandClaude Opus 5.5 30cbe1c901 Backup and recovery kit: saves, settings and Android data off the Frame and back
Tools → Backup & recovery copies game saves (Proton prefixes' AppData,
Documents and Saved Games, Unity and Godot saves), Steam controller layouts,
desktop settings and Android app data (the existing backup-data helper) to
~/Documents/Frame Control Backups/<time>/ with a manifest of every file's size
and SHA-256 and the headset's BUILD_ID. Backing up only reads the headset;
sign-in files, keys and keyrings are refused by name wherever they are.

Restore checks each part against the headset first and skips, rather than
fails, games not installed yet, prefixes Proton hasn't made, a missing Steam
account and closed or missing Android apps. It asks before replacing anything,
writes only inside each part's folders and keeps every replaced file under
~/.local/share/frame-control/restore-previous/.

Return to Game Mode reads the SDDM session config and runs the switch Valve's
devkit tools use (steamos-session-select / holo-session-select gamescope),
only when the headset isn't already in Game Mode and only after confirmation.

Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:28:35 +00:00
Alex Southwell f4efefa958 Merge pull request #116 from saphid/feat/clips-record-export
Record the headset with its sound, and export Steam clips of any length
2026-10-23 23:23:33 +00:00
SAPHIDandClaude Opus 5.5 a5980d8f98 Force quit a running game from Games and the Control Center
Running now, at the top of Games, lists what's running on the Frame (Steam
games, sideloaded titles, non-Steam shortcuts, Android apps), with Force quit
on each, on a game's library tile and in a sideloaded title's menu. The
Control Center gets a Force quit a game card. Each asks first: unsaved
progress will be lost. Nothing running says so.

ui/frame_running.py finds games by Steam's `reaper SteamLaunch AppId=<id>`
process and its tree, never by name. It asks Steam to quit
(SteamClient.Apps.TerminateApp), then sends SIGTERM and SIGKILL to the tree,
skipping init, the SSH session, other users and Steam, gamescope, SteamVR,
sshd and the session. Android apps also get the Apps tab's container stop.

The fake Frame runs games under a stand-in reaper and lists them in
SteamUIStore.RunningApps, so tests/e2e/test_running.py force quits real
process trees. Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:17:59 +00:00
SAPHIDandClaude Opus 5.5 e179616ad0 Record the headset with its sound, and export Steam clips of any length
Record on Capture & Live runs ffmpeg on the Frame (x264 from /dev/video99, the
default output's monitor through PipeWire's PulseAudio server), stops it when the
SSH channel closes, remuxes to .mp4 and copies it to ~/Videos/SteamFrame. If the
audio can't be read, it records video only and says so. 20-minute limit, free
space checked on both ends.

Steam clips lists Steam's game recordings (DASH sessions under
userdata/<account>/gamerecordings) and Export joins each stream's init segment
and chunks, then has the Frame's ffmpeg copy them into one .mp4, so clips longer
than Steam's own 60 s export come out whole.

The logic is in ui/frame_clips.py for reuse. Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:13:16 +00:00
Alex Southwell 8f245cede7 Merge pull request #115 from saphid/feat/typing-and-clipboard
Type on the headset: one place for the keyboard and clipboard, and copy the headset's clipboard back
2026-10-23 23:12:24 +00:00
Alex Southwell 359407bd06 Merge pull request #114 from saphid/docs/charge-limit-research
Docs: research on an 80% battery charge limit for the Frame
2026-10-23 23:07:53 +00:00
SAPHIDandClaude Opus 5.5 810ef1822d Type on the headset: keyboard and clipboard in one place, and the headset's clipboard back to the PC
Home, Tools and the Control Center now call the keyboard "Type on the
headset", and T opens it from anywhere. The clipboard moves from Send to
Frame to sit beside the keyboard, with a new Copy the headset's clipboard
button: the server reads Klipper's getClipboardContents on the headset
desktop's bus (base64 back, 1 MB limit, empty and image clipboards say so),
and the desktop app, the iPhone app or frame_host.set_clipboard_text puts
it on the clipboard here. Clipboard text is never logged.

Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:04:41 +00:00
SAPHIDandClaude Opus 5.5 3dd8e982a3 Docs: research on an 80% battery charge limit for the Frame
Steam's charge limit goes through SteamOS Manager's BatteryChargeLimit1,
and upstream steam-frame.toml configures no backend for it. The Frame's
battery is a max1720x fuel gauge with no charge_control_end_threshold.
Conclusion: don't build it yet; read-only headset checks are listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:58:13 +00:00
Alex Southwell fce1800dcf Merge pull request #111 from saphid/feat/audit-ui-structure
Frame Control 0.4.9: Apps / Games / Tools / Settings, one Add / Install flow, whole-app review fixes
2026-10-20 13:08:58 +00:00
saphidandClaude Opus 5.5 3f377d8f56 Cast to this screen shows Capture once too, through the shortcuts' showNow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:54:15 +11:00
saphidandClaude Opus 5.5 9060142f5d Test: a Home shortcut keeps its destination's scroll and loads its tool once, even when hashchange beats the next frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:34:00 +11:00
saphidandClaude Opus 5.5 172851971c Frame Control 0.4.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:25:36 +11:00
saphidandClaude Opus 5.5 48e9ce58ee Home shortcuts show their tool once: pushState instead of a second showPage on hashchange
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:21:39 +11:00
saphidandClaude Opus 5.5 9a5d697579 Merge main (0.4.8: Android game backups) into the four-tab structure; Home's shortcuts focus on the first tap
Main's Import Android game panel joins Quest and Meta under Apps > Browse,
and its Import button (androidBackupOpen, which lived in the old Android
store header and would have thrown at startup here) is now an Add / Install
choice. docs/android-backups.md points to the new places.

Home's shortcuts (Type on the Frame and the rest) now show their tool before
focusing it, so typing works on the first tap and phones open the keyboard
(Sol review P3). A tool panel already in its workspace isn't moved again, which
also dropped focus on the follow-up hashchange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:12:49 +11:00
saphidandClaude Opus 5.5 71ef6a57c7 Service regression tests: Windows has no execute bit to check after an APK rollback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:04:18 +11:00
saphidandClaude Opus 5.5 be9e7b5cda Merge feat/android-store-redesign (with main) into the Apps / Games / Tools / Settings structure
Keeps the four primary destinations and ports main's newer work into them:
the Home "Right now" panel (volume, cast, quick actions) replaces the old
device block, whose readings now live in the header's headset popover; the
install queue sits above the Apps views; Import from my Quest and My Meta
library sit with Browse; the pad, panel switcher, Mac view and media panels
take main's markup. Scrolling follows main's #scroll container. Settings
stays a tab, so main's header gear is not added. The old .home-status
toggle is gone (main's startup test caught it touching a missing node).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:49:13 +11:00
saphidandClaude Opus 5.5 5c44d56f53 Sentence-case headings and tabs, the headset named once on phones, sideloaded games get Play and a menu
Section headings, the tabs, the brand and the activity bar drop small capitals
for sentence case at one heading size. On a phone with a headset picker, the
connection pill keeps only its status dot so the name shows once. Sideloaded
titles get Play and a ⋯ menu (Add artwork, Remove) like installed apps. Manage
sources moves under Source & compatibility. Library artwork is a Settings
page, so Tools no longer lists it.

Refs #101, #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:20:38 +11:00
saphidandClaude Opus 5.5 fed2249776 Apps / Games / Tools / Settings, one Add / Install flow, and the whole-app review fixes
Primary tabs become Apps (Installed / Browse / Linux apps), Games (Installed /
Owned / Store), Tools and Settings, with shortcuts 1-4 and one Add / Install
chooser for catalogs, Steam, Linux apps, files and install links. Installed
rows show identity, one state and Open/Stop; the rest moves to app details.

Review fixes (#106): input, touch, approvals, artwork repair, keyboard helper
delivery and catalogue jobs stay bound to the headset they were meant for;
report preview follows the latest consent; failed Store reads are not an empty
library; keyboard access to device details and the Store sheet; the iOS
bundle ships the artwork renderer and OpenXR resources; typed addresses and
custom SSH ports survive setup; WebKit crashes recover; native preferences
survive relaunch; APK update rollback and repair stay retryable.

Refs #101, #104, #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:08:09 +11:00
Alex Southwell 1e44a74165 Merge pull request #110: import Android APK and OBB backups together
Import Android APK and OBB backups together
2026-10-20 04:06:25 +00:00
SAPHID 2a3e52f708 Detect atomic device registry saves with identical timestamps 2026-10-20 03:55:08 +00:00
SAPHID b2485500e1 Import Android APK and OBB backups together 2026-10-20 03:54:17 +00:00
SAPHID c5521a917f fix(ui): reconcile PR 97 with current main 2026-10-18 00:37:46 +00:00
SAPHID a13f4fafab fix(ui): reconcile PC host controls with current main 2026-10-18 00:36:47 +00:00
Alex Southwell f9496d67da Merge pull request #56 from saphid/ui-4-store
Make the Android store look and read like the rest of the app
2026-10-18 11:31:01 +11:00
Alex Southwell 7eae209b1c Merge pull request #55 from saphid/ui-3-home
Lead Home with what's happening, and give settings a page
2026-10-18 11:30:16 +11:00
Alex Southwell 28bc0506a6 Merge pull request #54 from saphid/ui-2-connection
Change the headset's addresses from the connection pill
2026-10-18 11:29:47 +11:00
Alex Southwell 0a4f008d03 Merge pull request #53 from saphid/ui-1-layout
Fit the window at every size and show the battery once
2026-10-18 11:26:14 +11:00
SAPHID ea0b6a80bd Merge main and resolve native host documentation and build triggers 2026-10-18 00:06:01 +00:00
Alex Southwell 16b37bc667 Merge pull request #50 from saphid/flat2vr-mods
VR mods: UEVR install, play in VR and remove from Frame Control
2026-10-18 11:04:17 +11:00
SAPHID a8e5d7cf61 merge: preserve repository logins alongside store refinements 2026-10-18 00:01:10 +00:00
SAPHID 5cdfee4f62 merge: integrate verified connection base into home and settings 2026-10-18 00:00:08 +00:00
SAPHID dd80ca7b74 test(connection): align pill expectations with connection redesign 2026-10-17 23:59:41 +00:00
SAPHID 612a766057 test(layout): provide page helpers in isolated control center fixtures 2026-10-17 23:58:37 +00:00
SAPHID d546568a11 fix(store): preserve resolver cleanup and test lookup budgets deterministically 2026-10-17 23:58:05 +00:00
Alex Southwell 5b531c84fb Merge pull request #90 from saphid/feat/repo-auth
Store: repositories behind a login, and a GitHub token in source settings
2026-10-18 10:57:12 +11:00
Alex Southwell ae8cc1b4a6 Merge pull request #58 from saphid/fix/flat2vr-mods-windows
UEVR helper tests: import and skip cleanly on Windows (fixes PR #50 CI)
2026-10-18 10:54:26 +11:00
Alex Southwell e89912d936 Merge pull request #64 from saphid/chore/windows-vm-rdp-repro
Windows test VM: a driver script and how to set one up
2026-10-18 10:52:10 +11:00
Alex Southwell 4c27a3397c Merge pull request #57 from saphid/fix/ci-failures
CI: stop recurring false failures on main and pull requests
2026-10-18 10:52:06 +11:00
SAPHID 75acf4a3e9 merge: resolve #56 conflicts with ui-3-home 2026-10-17 22:36:26 +00:00
SAPHID 629a2fafb6 merge: resolve #55 conflicts with ui-2-connection 2026-10-17 22:34:58 +00:00
SAPHID b0d848dc5c merge: resolve #54 conflicts with ui-1-layout 2026-10-17 22:30:09 +00:00
SAPHID 57a9d8c717 chore: resolve conflicts with main 2026-10-17 22:10:19 +00:00
SAPHID fa9ee01cc1 chore: resolve conflicts with main 2026-10-17 22:10:07 +00:00
SAPHID 63017e1969 chore: resolve conflicts with main 2026-10-17 22:10:04 +00:00
SAPHID b351fe7ad6 chore: resolve conflicts with main 2026-10-17 22:09:58 +00:00
SAPHID 6aab62983e chore: resolve conflicts with main 2026-10-17 22:08:52 +00:00
Alex Southwell bcdd9d0830 Merge pull request #99: Queue Android and Quest installs
Queue Android and Quest installs; prepare Frame Control 0.4.7
2026-10-15 13:32:54 +11:00
saphid ddd2d1c295 Merge landed Quest and Meta library stack 2026-10-15 13:10:26 +11:00
Alex Southwell 8f2e34a40e Merge pull request #93: My Meta library (Experimental)
My Meta library (Experimental): install and test Quest games you own
2026-10-15 13:10:18 +11:00
Alex Southwell 351230fcad Merge pull request #92: Import from my Quest
Import from my Quest: copy games you own off a USB-connected Quest
2026-10-15 13:10:04 +11:00
saphid 5b35e9cc5d Merge current main and prepare Frame Control 0.4.7 2026-10-15 13:09:59 +11:00
saphidandClaude Opus 5.5 baaddd7622 Headset store: pending game data belongs to the installed package, in every view, and goes when the app does
From review round 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:48:00 +11:00
saphidandClaude Opus 5.5 671c4c06e7 Install queue: only the install showing its progress hides the bar
Removing a waiting import hid the progress of the one still running.
Found by GPT-6.1 Sol review round 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:43:18 +11:00
saphidandClaude Opus 5.5 07fc7ca3ff Headset store review fixes: ship ui/apk_sources in the Frame bundle; live installed state; GitHub installs remembered; game data its own job; no stuck loading; lookup waits count against the budget
The iPhone app's Frame bundle (also used by store-on-frame.sh) left out
ui/apk_sources, so ui/server.py couldn't start from it; a test now runs
the server's imports from an extracted bundle.

From review round 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:42:09 +11:00
saphidandClaude Opus 5.5 443813a088 Install queue: Android store, Quest and Meta installs wait their turn
Installs from the Android store, Import from my Quest and My Meta library
now go into one server-side queue and run one after another, instead of
being refused while another runs. The page shows the queue with Remove
for installs that haven't started; each queued install runs only on the
headset it was queued for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:38:53 +11:00
saphidandClaude Opus 5.5 e885e9adba APK Store in the headset on the new store; images load first time; phone layout
- ui/store.html, scripts/store-on-frame.sh and the /store route (built in
  another thread and left uncommitted in the main checkout) join this branch,
  rebuilt on /api/search: the same sources, categories, phone-only rule and
  artwork as the Store, as a 1280x800 laser panel. A category rail with
  Installed, eight art cards a page, a featured VR app on Home, a detail
  sheet with screenshots, install progress and game data, the thumbstick
  turning pages and the on-screen keyboard.
- Artwork: the proxy allowed four name lookups at once and failed the rest
  immediately, so a page of cards lost most of its images until the retry.
  Public answers are now kept for five minutes and a lookup waits for a slot
  within its own budget.
- Phones: one-line shelf headings, a shorter store header, wider shelf
  cards, and a two-column library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:31:34 +11:00
saphidandClaude Opus 5.5 7aa7c55be9 Store categories: "does not require root" isn't a root tool
From review round 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:08:05 +11:00
saphidandClaude Opus 5.5 92a68285b5 Meta library: show sizes first, warn about left-out game data, count apps
From a live test with a real account (71 owned entries):
- the size was cut off behind the package name; sizes now come first;
- a required data file Frame Control can't place as an OBB (The Climb's
  1.8 GB main OBB) was never mentioned; the row now says it won't be
  installed;
- the count said games, but Meta's list includes apps and environments.
Problems and warnings get their own coloured line; the verdict colours
now win over the row's grey text (they were grey before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:07:36 +11:00
saphidandClaude Opus 5.5 2554309c7d Store categories: searches always find phone-only apps; messengers and broad-only sources escape a phone side; name-only phone words; hardware the Frame lacks
From review round 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:01:44 +11:00
saphidandClaude Opus 5.5 5878c45ddb Meta library: count PERMANENT entitlements as owned
A real account's entitlements all came back with active_state PERMANENT
(92 records, 71 of them Quest apps), and the library only accepted ACTIVE,
so it showed no games. Both now count as owned; inactive and expired
entitlements are still left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:47:23 +11:00
saphidandClaude Opus 5.5 adc1e44cf1 Store categories: keep useful apps with a phone side, ignore "no root", map IzzyOnDroid's Office; keep sidebar focus; never feature an app that won't run
From review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:47:10 +11:00
saphidandClaude Opus 5.5 7f46cd54a4 Android tab: a Store with Frame categories and a card Library
The store browsed F-Droid alphabetically, so it opened on proxies, port
checkers and root tools. It now groups apps by what a headset is good for
(VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web &
chat, Tools) from F-Droid's own categories, hides phone-only apps unless
asked, and browses each source's best-illustrated, newest apps first.

- Store: a category sidebar (chips on narrow windows), one shelf per
  category on the home page with no repeats, a featured VR app, and cards
  that show a banner, phone screenshots side by side, or the icon over its
  own blur instead of a letter, plus whether it fits the Frame.
- Library: its own view with art cards, Play, and a menu for Test, Report
  and Remove; filters for VR, flat and running apps; display settings and
  reports below. Artwork comes from the saved source indexes.
- Images: listings register three screenshots per app instead of twelve,
  handles are indexed by URL, and the bound is 16,384, so a large browse no
  longer forgets its own images before the page loads them.
- F-Droid cache version 3 keeps each app's categories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:37:27 +11:00
Alex Southwell ba0ac6022b Merge pull request #96 from saphid/feat/frame-control-center
Add comprehensive Steam Frame Control Center
2026-10-14 18:51:07 +11:00
saphid 8b9bd2119c Add searchable Steam Frame Control Center with live metrics 2026-10-14 18:40:59 +11:00
saphidandClaude Opus 5.5 d773babaa3 Meta sign-in: allow Instagram, and say where a stopped redirect went
A live sign-in with a real Meta account goes auth.oculus.com -> auth.meta.com
-> www.facebook.com -> www.instagram.com -> auth.meta.com. The window only
allowed oculus.com, meta.com and facebook.com, so it stopped the Instagram
step and sat blank. instagram.com is now allowed (same HTTPS-only checks;
the cookie is still read only from oculus.com). A page change the window
stops is remembered by host only, so closing the stuck window says why; it
is forgotten once the window loads a page again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:59 +11:00
Alex Southwell a8888cb2fc Merge pull request #94 from saphid/feat/telemetry-timezone
Usage statistics: send a country (from the time zone setting), and show the notice again when it changes
2026-10-14 13:22:27 +11:00
saphidandClaude Opus 5.5 1f51cc89d2 Frame Control 0.4.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:14:46 +11:00
saphidandClaude Opus 5.5 bcdb4c3b78 Usage statistics: no country for clock-rule zones, and Johnston Atoll is UM
From review: EET and WET link to Athens and Lisbon but name clock rules,
not Greece or Portugal, so they and the other rule names (CET, EST5EDT, …)
send nothing. Pacific/Johnston has left zone.tab, which made its Honolulu
group look only American; it's set to UM by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:01:46 +11:00
saphidandClaude Opus 5.5 e9b02d61e7 Usage statistics: don't give an old zone name the country of the zone it now links to
From review: tzdata links an old name to any zone with the same clocks,
even in another country (Africa/Asmera, Eritrea, links to Africa/Nairobi).
An old name now gets a country only when every zone sharing its target is
in that country, or when it's a hand-checked old name for a place listed in
zone.tab. The rest (UTC, CET, GMT, …) send nothing. Every zone Node's ICU
reports has an entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:55:18 +11:00
saphidandClaude Opus 5.5 02419ffbd4 Usage statistics: send a country, not the time zone; only the notice itself counts as seen
From review: the time zone setting could hold any text (a TZ like
home/alex/private passed the name check), so the app now looks the zone up
in a table made from the tz database (scripts/timezones.py) and sends only
its two-letter country; anything not in the table sends nothing.

Turning a setting on no longer counts as having seen the new notice, and
the country is taken out at send time until it has been, so it can't
leave early whatever order things happen in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:48:46 +11:00
saphidandClaude Opus 5.5 5db9631571 Usage statistics: send the time zone, and show the notice again when it lists something new
Opens, installs and updates carry the computer's time zone name (from
Electron, which knows it on every OS), so we can see roughly which
countries Frame Control is used in without ever seeing an address.

The first-run notice now has a version. Copies that saw the older notice
see it again, and keep sending their usual statistics, but leave the time
zone out until they've seen the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:41:19 +11:00
saphidandClaude Opus 5.5 c060df4b50 My Meta library: one Disconnect at a time, guarded from the confirm until the delete returns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:17:39 +11:00
saphidandClaude Opus 5.5 c7125bc3de My Meta library: Sign in with Meta closes two sign-out races
- cancel_signin marks an in-flight attempt and, independently, deletes the
  account if that attempt saved it, so a cancel landing after the save but
  before the connect's cleanup still removes the token.
- Sign-out runs one at a time (a second call joins it) and refuses new
  sign-ins while it runs; it goes round until no window, connect or cancel is
  left. The page keeps Sign in with Meta off from pressing Disconnect until
  the server has deleted the token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:10:59 +11:00
saphidandClaude Opus 5.5 9c33711e6f My Meta library: Sign in with Meta cancels only after its connect settles
When the sign-in window is closed, times out or is signed out of while a
connect is under way, the page is answered at once, but cancel-signin is
posted only after that connect settles (saved, refused or failed), so the
server always sees the cancel after the connect and deletes the token if
that connect saved it. Nothing sent means no cancel. Sign-out, Disconnect's
first step, waits for any such cancel (also from a window closed earlier)
before the page posts its disconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:00:36 +11:00
saphidandClaude Opus 5.5 9d92396d49 My Meta library: Sign in with Meta cancels by in-flight attempt and saved owner
- frame_meta keeps only in-flight sign-in attempts (removed when their connect
  finishes) instead of a bounded list of cancelled ids, so other cancels can't
  push one out, and records which attempt saved the account. Cancelling an
  attempt being checked stops its save; cancelling the attempt that just saved
  deletes that token; a later sign-in's or a pasted token's account is never
  touched; unknown attempts change nothing.
- will-redirect applies the per-frame rule: frames may be redirected to any
  https: page, the window only to Meta's sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:54:38 +11:00
saphidandClaude Opus 5.5 be2eda8304 My Meta library: Sign in with Meta lets frames load any HTTPS page
Meta's login can embed captcha and CDN frames from other sites (Arkose Labs,
hCaptcha, reCAPTCHA, fbcdn), so frames inside the sign-in window may now go to
any https: address (or about:blank/about:srcdoc); other schemes (http:, data:,
blob:, javascript:, file:...) are still stopped. The window itself stays on
the Meta/Oculus/Facebook allow-list. Frames can't reach the cookie read or IPC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:44:32 +11:00
saphidandClaude Opus 5.5 f5bed08931 My Meta library: Sign in with Meta drops abandoned attempts, holds frames to the list
Review fixes for the sign-in window:
- Each sign-in has a random attempt id. Closing, timing out or signing out
  stops cookie intake first, then tells the server (new cancel-signin action)
  not to save a connect from that attempt, even one still waiting on Meta
  (frame_meta.connect/cancel_signin; 409 from the server). Disconnect signs
  out of the window before deleting the token.
- will-frame-navigate holds every frame to the allow-list; a frame going
  elsewhere is stopped, never opened outside.
- The window is destroyed, not closed, and will-prevent-unload is overridden,
  so beforeunload can't keep it alive; sign-out clears the session only after
  the window is gone.
- Permission checks refused as well as requests; no developer tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:39:25 +11:00
saphid e144325375 Merge branch 'feat/quest-import' into feat/meta-library 2026-10-14 09:26:42 +11:00
saphidandClaude Opus 5.5 291b8ec295 My Meta library: Sign in with Meta in a Frame Control window
The desktop app can now open Meta's own login page (https://secure.oculus.com,
as BSManager's "Log in with Meta") in a window with its own session
(persist:meta-signin: sandboxed, context isolation, no preload or Node,
permissions refused). Navigation stays on https oculus.com, meta.com and
facebook.com; other links open in the system browser and other redirects are
stopped. The oc_ac_at cookie is read only from that session and only for
oculus.com, sent once per value through the server's existing connect path
(check_token, who am I, 0600 file), and the window closes. The page only gets
the server's answer, never the token. Disconnect also signs out of Meta in
Frame Control by clearing that session. Pasting a token stays as the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:26:18 +11:00
saphidandClaude Opus 5.5 9697812984 Merge origin/main into feat/quest-import
One conflict, in ui/frame_android.py where main added COPY_MIN_RATE (size-scaled
copy timeouts) beside the install lock this branch made reentrant: both kept,
the lock stays an RLock. ui/index.html and ui/server.py merged on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:22:56 +11:00
saphidandClaude Opus 5.5 0fc6fbe596 My Meta library: find overlapping token matches so a partial match can't hide the real start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:39:57 +11:00
saphidandClaude Opus 5.5 f2261bac1c My Meta library: find every redaction span in the original text and merge overlaps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:34:03 +11:00
saphidandClaude Opus 5.5 7c45d0ff24 My Meta library: redact token-shaped values before the known token's encoded forms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:23:08 +11:00
saphidandClaude Opus 5.5 19233fbf7d My Meta library: close the last gaps in "every accepted token is redacted"
- check_token refuses values with no lower-case letter (real tokens are mixed
  case), so the kept upper-case-constant shape can never hold an accepted
  token, even with a _SUFFIX appended.
- The token-shape pattern has no word boundary: a token is found after any
  character (_OC..., /OC..., libOC...), and its run takes every following
  token character, so an accepted token is always inside one match. Still
  one run per match, linear (timing tests unchanged).
- Whole-token redaction runs before access_token= and Authorization
  redaction, so OCaccess_token=123456 goes whole, prefix included.

The property test now also surrounds each accepted random token with random
printable-ASCII neighbours (and _FLAG, access_token=) and checks the output is
a start of the left side, one <redacted>, and an end of the right side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:16:39 +11:00
saphidandClaude Opus 5.5 3ef978e232 My Meta library: one token character set for accepting and redacting
check_token accepted any printable character, more than the generic redaction
looked for, so an accepted value like OCAbcdef.GhijkLmnopQrstUvwx wasn't
redacted and could show as a library name. TOKEN_CHARS (letters, digits and
the base64 / URL-safe base64 characters + / = - _) and TOKEN_MIN (20) now
define both: check_token refuses anything else ("That doesn't look like a
Meta token"), including upper-case underscore constants the redaction keeps,
and the generic pattern is built from the same constants. The public tools
only describe tokens of letters and digits; widen TOKEN_CHARS in one place
if a real token needs more. Property test: 3000 random OC/FRL values over the
allowed set; every one check_token accepts is redacted in full in five
contexts and never shown as a library name. Dotted regression too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:04:47 +11:00
saphidandClaude Opus 5.5 8afa1113c9 My Meta library: redact digit-free token shapes too
check_token accepts tokens without digits (OCAbcdefGhijkLmnopQrstUvwx), but the
generic redaction kept OC/FRL runs that had none, so such a value in a log line
(dlopen failed: "<token>.so") was shown as the library name. The generic pattern
now redacts every run of the shape check_token accepts (OC or FRL prefix, 20+
characters of token characters), digits or not, still as one linear match.
Upper-case underscore constants (OCULUS_PLATFORM_SDK_VERSION) are kept, as are
short words, package names and runs too short to be a token. Tests for
digit-free redaction, the 20-character boundary, the excerpt, and identifiers
that must stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:54:13 +11:00
saphidandClaude Opus 5.5 7fe7e504da My Meta library: never surface a failed log read; linear-time log and token scans
- A Frame error during a test (an ssh failure with empty stderr raises its
  stdout, which can be the game's log) becomes the fixed "couldn't read the
  game's log from the Frame" message, with no original text and no chained
  cause. run_test wraps every step; the /api/meta test job also maps any
  other exception to a fixed text, and the CLI and Install and test go
  through run_test. Close failures no longer echo their error text.
- Log scans are linear: signatures look at the first 4 KB of each line and
  every repetition followed by more required text is bounded; excerpts
  split the redacted line once and fullmatch only words of 64 characters or
  less; the generic OC/FRL token pattern matches each run once (no
  lookahead) and keeps it unless it has a digit. Timing tests: 100 KB
  adversarial lines through classify, redact and every excerpt each finish
  well under 200 ms (the old library and token patterns took 19 s and 9 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:38:46 +11:00
saphidandClaude Opus 5.5 f678b4c8a2 My Meta library: redact twice-encoded tokens; never show a cut-off token as a name
- _clean matches each token character literally, percent-encoded (%XX) or
  percent-encoded twice (%25XX), either letter case, hex in any case, still
  with 8+ character starts. This restores the double-encoding the port from
  feat/itch-purchases dropped; regression cases for double-encoded, recased
  and prefix forms.
- A shown log excerpt (library, extension, exception or signal name) is
  taken from the line after the whole line is redacted, never cut from
  something longer, and dropped unless it looks like a real identifier
  (64 characters at most, no 32+ alphanumeric run, no 16+ run mixing upper,
  lower and digits). Tested with the token, and the tail of a longer one,
  as a library name in a dlopen failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:21:38 +11:00
saphidandClaude Opus 5.5 33e4d2c092 My Meta library: fix review findings (token on redirects, addresses, redaction, budget, tests)
- Redirects off Meta's hosts are refused, before any lookup or connection,
  when the address carries the token in any form anywhere (host, path, query,
  fragment; literal, recased, percent-encoded, 8+ character starts) or an
  access_token parameter, instead of stripping only access_token.
- Connections resolve once, require every address to be public (an
  allow-list that refuses ff02::, fec0::, NAT64, 6to4, Teredo and the IPv4
  special blocks), connect to exactly a checked address with hostname-
  verified TLS within one shared deadline, and use no environment proxies.
- Redaction uses the itch branch's _clean (each character literal or
  percent-encoded in either case, 8+ character starts).
  These three mirror _web.public_address / resolve_public /
  _public_connection and _itch_account._clean / _carries_key on
  feat/itch-purchases, so the two can be unified when both merge.
- One byte budget per install (16 GB) counts every byte as it arrives,
  including files of unknown size; overflow stops and removes the file.
- Test causes are fixed texts; reports never carry log text. A short
  excerpt (extension, library, exception or signal name) is shown only,
  redacted before it is shortened.
- A running game must be confirmed closed before a test; a failed close
  after a hang is reported ("the game may still be running").
- A licence failure needs explicit failure evidence; a game open without a
  VR-session line is "inconclusive" and not reported to the compat DB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:59:19 +11:00
saphidandClaude Opus 5.5 621330d397 My Meta library (Experimental): install and test Quest games you own
Opt-in, off until you connect: paste your own Meta access token, which is
checked with a read-only "who am I" query and kept owner-only in
data_dir/meta-account.json. Lists the Quest apps the account owns, downloads
an owned app's APK and OBB from Meta's download server into the install
cache (size limits, declared-size and optional SHA-256 checks, cleaned up
after every install), and installs it through Import from my Quest's checks,
install and two-step data copy. Install and test runs the game, classifies
the outcome from its log (runs, runs with issues, quits, crashes, hangs) with
a plain-English cause, and records it through the existing compatibility
report path with source meta-store and the version code, shared only per the
Privacy settings.

The token goes only to graph.oculus.com and securecdn.oculus.com over
HTTPS, is dropped on redirects elsewhere, and is redacted (any case,
percent-encoded, before truncation) from every message. A disconnect wins
over a connect in flight and stops downloads. No licence-check bypass.
Protocol facts come from the public QAVS, OculusGraphQLApiLib, DownQuest and
BSManager sources (docs/meta-library.md); the code is our own.

UI: Android -> My Meta library. CLI: ui/frame_meta.py. API: /api/meta.
Tested offline with a fake Meta and fake Frame (tests/test_meta.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:32:28 +11:00
saphidandClaude Opus 5.5 15e14c0d47 Quest import: warn about Meta's licence check instead of refusing
A game linking libovrplatformloader.so now installs with a warning ("uses
Meta's licence check; it may quit, hang, or run without online features
on the Frame. Frame Control won't remove the check."): linking the SDK
doesn't mean the check is enforced (VRC.Quest.Security.1 has only been
Recommended since 2021). 32-bit-only and API > 30 are still refused. The
VrApi warning says "may not run". The data step (copy into the open game,
then close it) becomes add_game_data, for reuse by My Meta library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:32:28 +11:00
Alex Southwell 141b95ddde Merge pull request #89 from saphid/feat/headset-telemetry-id
Frame Control 0.4.5: count headsets, lighter Comfort checks, sturdier APK installs
2026-10-13 17:18:52 +11:00
saphidandClaude Opus 5.5 4ec2ff40c3 APK installs: decode the Frame's ssh output as UTF-8 on every OS
Windows decoded it with its code page, so the Windows CI saw 'ok ÿ' where
the test expects a replacement character, and real UTF-8 messages from the
headset came out garbled there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:07:12 +11:00
saphidandClaude Opus 5.5 39e819872a Privacy notice and setting: say headsets are counted, by a one-way hash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:00:08 +11:00
saphidandClaude Opus 5.5 ee7951e8d0 Release 0.4.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:55:41 +11:00
saphidandClaude Opus 5.5 77c6c286cd Split merge: accept 64-bit data descriptors on small streamed ZIP entries
Streaming writers (Python's unseekable ZipFile, force_zip64) write ZIP64
data descriptors even for small files, so the descriptor width can't be
taken from the final sizes. The reader now accepts the signed or unsigned,
32- or 64-bit layout that matches the central directory and fits before
the next entry, preferring one that ends exactly there, then the width the
local header's ZIP64 metadata implies. Format tests cover streamed ZIP64
stored and deflated entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:54:55 +11:00
saphidandClaude Opus 5.5 ca53b26701 Quest import: fix round-3 review findings (legacy versions, strict ZIP, closing)
- Installs made before records kept a version code no longer get replaced by
  an older Quest copy: the installed code is read from app.apk on the Frame
  (stdlib parser, only the manifest crosses the link); if it can't be read,
  replacing needs allowDowngrade. The data-copy guard uses the same lookup.
- Split members are checked against both ZIP headers: same local name and
  method, local sizes and checksum (or a validated data descriptor, with
  ZIP64 extra fields understood) equal to the central directory, no overlap
  with the next entry, and no trailing bytes after a deflate stream.
- The game is closed in a finally block, so a local OSError or Ctrl-C part-way
  through a copy still closes it; the original exception is kept and a failed
  close is reported separately.
- running_instances(strict=True) fails closed (a failed `podman ps` raises),
  used by the Quest import; other callers keep the lenient listing.
- The dumpsys version check reads only the package's own block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:43:03 +11:00
saphidandClaude Opus 5.5 baec984449 Quest import: fix round-2 review findings (versions, locking, strict split reads)
- An older Quest copy no longer replaces a newer install on the Frame: installs
  now record versionCode, and a downgrade is refused unless allowed
  (--allow-downgrade, "allowDowngrade", or the panel's checkbox).
- The game-data copy only targets this package's own install record and
  instance at the Quest's exact version code, and checks Android's own
  dumpsys in that instance before copying.
- After the (possibly long) pulls the target is checked again, and the final
  checks, the transfer and closing the game hold the shared install lock,
  now reentrant so install_obb and install can be called under it.
- Closing the game is confirmed; a failed or incomplete close is reported
  instead of claiming the game was closed.
- Split members are read from their raw bytes: stored members must be exactly
  their declared size, deflated streams must end exactly there, then the
  checksum; forged sizes with prefix checksums are refused.
- A split's resource table counts as empty only if no type chunk defines any
  entry (bags included) and every chunk is understood.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:24:30 +11:00
saphidandClaude Opus 5.5 18e5053b64 Quest import: fix review findings (bounded split merge, no game launch, streamed data)
- Split merging only accepts splits holding lib/<abi>/*.so, assets/, their own
  signature/manifest and an empty resource table. Code (classes*.dex), other
  files, resource splits, feature splits and split manifests declaring
  components or libraries are refused before anything reaches the Frame.
- Merged bytes are bounded (512 MB per file, 1 GB in all) from the headers and
  again by bounded reads with a checksum, so a zip bomb or a forged header
  can't fill memory.
- Import never starts the game. The first import installs it; game data (OBB,
  Android/data) is copied by a second import while the person has the game
  open, never replacing an open game's APK, then the game is closed.
- Android/data is streamed as a tar straight into the SSH transfer, not
  staged; the space check counts split payloads uncompressed and data once.
- The panel shows small sizes in KB rather than "1 MB".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:02:42 +11:00
saphidandClaude Opus 5.5 4ecc03280e Source logins: refuse addresses with a backslash and a login; decode escapes in one pass
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:41:40 +11:00
saphidandClaude Opus 5.5 2f1aeaac72 Source logins: refuse an invalid address that holds a login; keep undecodable bytes as typed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:35:50 +11:00
saphidandClaude Opus 5.5 8347c2c3f7 Source logins: let the URL parser find a pasted login; text matching only as a fallback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:30:54 +11:00
saphidandClaude Opus 5.5 a94c45d014 Source logins: strip a pasted login even with tabs, newlines or backslashes in the address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:26:18 +11:00
saphidandClaude Opus 5.5 7faf80fc2b Import from my Quest: copy games you own off a USB-connected Quest
New ui/frame_quest.py (CLI: devices | list | import), GET/POST /api/quest and
an Import from my Quest panel on the Android page. It finds a Quest on this
computer's adb (never Frame Control's own tunnelled serials), lists third-party
apps with split, OBB and Android/data details, pulls the APKs into a per-user
cache and checks them before copying anything else: Meta's Platform SDK
licence check (libovrplatformloader.so) and 32-bit-only builds are refused,
VrApi-only builds flagged. It then installs through the normal APK path, adds
OBBs with the existing helper and, if asked, Android/data files, and always
removes the cache. No licence-check bypass is bundled, called or linked.

Tests use a fake adb (tests/fakeadb.py). Docs: docs/quest-import.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:17:11 +11:00
saphidandClaude Opus 5.5 6ea20a2806 Android installs: accept split APKs by merging them into one APK
Lepton runs one APK per instance, so frame_android.install(base, splits=[...])
(and `frame_android.py install base.apk split.apk ...`) merges splits that add
native libraries or assets into the base, drops the manifest's isSplitRequired
flag, and re-signs with Frame Control's key. Density and language splits,
splits of another package and clashing entries are refused before anything
reaches the Frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:17:11 +11:00
saphidandClaude Opus 5.5 e20225604d Source logins: keep repository logins inside the repository directory
- Every request made with an F-Droid repository's login (entry.jar, index,
  APKs), redirects included, now uses the scoped check: same origin and
  inside the repository directory by whole plain path segments. A
  same-origin redirect elsewhere on the server gets no login.
- _child() refuses file names a server could normalise outside the
  repository: double-encoded dots, ';' path parameters, encoded slashes or
  backslashes (anything _credentials.path_segments rejects).
- Store page: moveLogin() strips the login from the address with plain
  text matching first, then decodes with a fallback to the text as typed,
  so '100%done', '%FF' or an address the URL parser rejects can no longer
  leave the password in the field.

Tests: outgoing headers for '%252e%252e/...' and '..;/...' payloads and a
same-origin redirect outside the repository carry no Authorization; the
page test covers undecodable and unparseable logins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:12:10 +11:00
saphidandClaude Opus 5.5 2d81c92114 Source logins: fix review findings (artwork scope, file lock, page, backoff)
- Artwork: a saved login is only sent for URLs under the repository by whole
  path segments. Paths with dot segments (plain or percent-encoded), encoded
  slashes/backslashes, double encoding or ';' get nothing, since a server
  could normalise them outside the repository. Checked again on every
  redirect hop; Handler(scoped=True) does the same for urllib callers.
- Credentials file: every read-modify-write holds an inter-process lock
  (source-credentials.lock, flock or msvcrt), so the CLI and the app can't
  undo each other's saves or bring back a removed login.
- Store page: a login typed into the address moves into the login fields,
  and out of the address field, before the add starts, and stays there if
  it fails.
- Rate-limit backoff is keyed per login (anonymous or each token), so saving
  a GitHub token after anonymous requests were limited is used at once.

Regression tests: outgoing artwork headers for traversal URLs and a
redirect to one, two processes churning the credentials file, anonymous
limit then token, and the page's login handling in node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:00:33 +11:00
saphidandClaude Opus 5.5 fe73906f1f Docs: repositories behind a login and GitHub tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:46:35 +11:00
saphidandClaude Opus 5.5 84912ea4e1 Store: repositories behind a login, and a GitHub token in source settings
F-Droid repositories can now be added with HTTP basic auth, either as
https://user:pass@host/fdroid/repo (as F-Droid's client accepts) or with
separate username/password fields (store form, server API, and the CLI's
--username/--password-stdin). The login is stripped from the saved URL and
kept in a new shared module, apk_sources/_credentials.py, which writes
source-credentials.json in the data folder with mode 0600.

A credential is only ever added per request to its exact origin (scheme,
host, port): redirects elsewhere get nothing and have Authorization
removed. HTTPS only. 401 (or 403 with a login) on add gives a "check the
username and password" error and saves nothing; signature, pin, TOFU,
hash and rollback checks are unchanged. Removing a repository deletes its
login.

GitHub gets a user-settable token (FRAME_GITHUB_TOKEN still wins), sent as
Bearer only to api.github.com. If GitHub answers 401, the request is
retried once without it and the rejection is reported in source settings.
The source list shows only whether a token is set, never its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:46:35 +11:00
saphidandClaude Opus 5.5 d456aa1f3d Take the #81 log scrubbing rework out of this release
Three review rounds kept finding ways that matching names, ssh operands and
user@host in one pass shows something the scrubber on main hid (URLs, home
folders, keys). The two #81 edge cases are minor; the scrubber goes back to
main's, and #81 stays open with the findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:07:35 +11:00
saphidandClaude Opus 5.5 6c7b2ca22f Log scrubbing: match names in the text as it came, with user@host found alongside
Round 2 of the review: scrubbing the text before matching names stopped a
name the scrubber partly rewrote ("frame.local Jane Doe headset") from
matching whole, and cut the text before names were hidden. Instead the
shared scrubber's user@host patterns are found in the original text as
spans too, so a name can't take their @. A randomised comparison with the
scrubber on main (160,000 cases) found nothing it hid that this shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:59:42 +11:00
saphidandClaude Opus 5.5 8a06848b5f Log scrubbing: find overlapping ssh operands; scrub user names before display names
Review of #81 found two cases that scrubbed less than before: "to hostname X"
left X visible, and a display name ending in "@<host>" took the @ the user
name scrubber needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:53:04 +11:00
saphid 9cca67529b Merge rel/issue87 into the release batch 2026-10-12 22:42:12 +11:00
saphidandClaude Opus 5.5 b656f80922 Host-key recovery UI: whole reasons, no doubled stops, header fits, selection follows (#87)
Follow-up to #85 from #87:
- A failure can carry a short reason (SSH identity changed, Saved SSH identity
  missing, This computer's key refused). The address that failed shows it
  instead of repeating the whole message, so the offline banner no longer
  reads "...may be answering here.." or "...must be on).."; the banner also
  drops a closing full stop before adding its own and keeps "SSH"/"Frame"
  capitalised.
- The header pill leads with that reason (then the countdown), so a narrow
  pill cuts the countdown, not the reason. The Devices tab's changed-key and
  refused-key explanations wrap; the list row shows the short reason with the
  whole message as its tooltip.
- The header's pill and buttons sit in one group pushed right with tighter
  gaps; the pill and headset picker give way, the buttons don't, and the
  shortcut hints hide at 1366 px and below. Refresh no longer overhangs at
  1280 px.
- Use this headset selects the headset it switched to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:41:56 +11:00
saphid 7cc418662d Merge rel/apkcat into the release batch 2026-10-12 22:33:17 +11:00
saphid b2bbff4305 Merge rel/comfort into the release batch 2026-10-12 22:33:17 +11:00
saphid 2cfcdaaf0e Merge rel/issue81 into the release batch 2026-10-12 22:33:17 +11:00
saphidandClaude Opus 5.5 2960581541 APK installs: time large copies by size, readable errors, no leftovers
- _copy allowed a fixed 10 minutes, failing any APK over about 300 MB on a
  4 Mbit/s link; it now allows at least 512 KiB/s.
- ssh() and _copy decode output with errors='replace', so a message in
  another encoding is a FrameError, not a UnicodeDecodeError; ssh() with
  bytes input no longer reports "b'...'".
- A failed update removes the partial app.apk.part it copied.
- frame_apk.apk_info turns a damaged APK's zlib.error, EOFError,
  ValueError or RuntimeError into ApkError (the upload path didn't catch
  them, so they gave a 500 and no install report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:32:54 +11:00
saphidandClaude Opus 5.5 8b1c290730 Telemetry: name the APK install failures that were 'other'
steam_not_running, steam_not_responding, steam_js_error and artwork_failed
for the Frame-side Steam client script (its "Connection refused" and
"timed out" were filed as frame_unreachable), copy_timeout and copy_failed
for rsync/scp, internal_error for Python's own errors (KeyError,
JSONDecodeError, ...) whatever they say, and apk_unreadable now covers
every frame_apk.ApkError wording. docs/privacy.md lists them all, and a
test keeps it in sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:32:54 +11:00
saphidandClaude Opus 5.5 b64082c974 Comfort: run the helper cached on the headset; send the sources only when missing
Every call (one per 5 s poll) sent ~35 KB of frame_comfort.py, frame_status.py
and frame_steam.py over ssh, though they are already cached on the headset in a
content-addressed folder. Now the first ssh only checks that folder and runs
the cached copy; if any file is missing it answers {"comfort": "missing"} and
a second call writes the sources as before (umask 077, temp file then rename,
same version hash).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:29:32 +11:00
saphidandClaude Opus 5.5 7c1d698400 Comfort: don't report the poll meeting an unreachable Frame, or poll while it is
The page polled POST /api/comfort status every 5 s whatever the connection,
and every POST Failure >= 500 went to diagnostics. ssh already marks link
failures, but those are still sent once per session per category, so each
session with a sleeping, away or re-keyed headset sent up to three reports
from this poll (most of 0.4.2's error reports). GET /api/status never reports
Failures at all.

- server: a polled POST read (POLLS: comfort status) that couldn't reach the
  headset isn't a diagnostic, as with GET /api/status. Real comfort errors,
  and link failures from starting or cancelling a session, still are.
- page: the comfort poll starts after the first status check and skips its
  ssh while the Frame is offline (online === false); status checks bring
  it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:29:32 +11:00
saphidandClaude Opus 5.5 b6406a90a4 Log scrubbing: find names and ssh operands in the original text (#81)
Follow-up to #79. Known names and the hosts ssh names are now located as
spans in the text as it came, overlapping spans merged, and replaced in one
pass. A display name containing a scrubber token ("<host> Jane Doe headset")
now goes whole, and a name like "to host" can no longer hide ssh's
"connect to host X" operand. Tokens on their own are still left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:27:44 +11:00
saphidandClaude Opus 5.5 9b26c907ce Telemetry: count headsets, not just installs
frame_connected now carries headset_id, a one-way hash of the headset's
/etc/machine-id worked out on the headset (frame_status.py), and
headsets_saved, how many headsets this copy of the app has set up. It is
sent once per build for each headset. The hash goes to usage statistics
only: the status endpoint and MCP clients don't get it. docs/privacy.md
says what is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:42:03 +11:00
Alex Southwell 5f3798b79b Merge pull request #88 from saphid/fix/tests-real-ssh-dir
Tests: keep every test (and the servers they start) off the real ~/.ssh
2026-10-12 16:34:34 +11:00
saphidandClaude Opus 5.5 6e0b140efc e2e: run only inside scripts/e2e.sh's container, and never start a server otherwise
test_agents had no harness.require() and FrameTestCase started ui/server.py
unconditionally, so plain discovery over tests/e2e started a server on this
computer's real app data, ~/.ssh and cache. FRAME_E2E=1 alone outside the
container did the same with the real cache.

require() now also needs FRAME_E2E_CONTAINER=1 (set in compose.yaml) and the
container runtime's marker file, and says why it refuses; FrameTestCase and
Server.start call it before starting anything; test_agents calls it at
module level. test_sandbox checks discovery over tests/e2e, with and
without FRAME_E2E=1, skips everything and starts no server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:21:09 +11:00
saphidandClaude Opus 5.5 226ec40720 Tests: keep the e2e container's real home; sandbox the per-user cache folders too
tests/e2e/test_android imported its APK helpers from the sandboxed
test_frame_apk, so the HOME move reached the whole e2e process: frame_connect
wrote keys and config into the temp home while OpenSSH, which finds ~/.ssh
from the password database, kept reading the container user's. The helpers
now live in the sandbox-free tests/apk_fixtures.py, and the e2e harness sets
the data dir, telemetry and compat DB guards itself (it used to get them
through that import).

sandbox.py also points LOCALAPPDATA, APPDATA and the XDG folders into the
throwaway home, so test servers on Windows and Linux no longer warm indexes
or sweep staging folders in the real app cache. test_sandbox checks both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:56:26 +11:00
saphidandClaude Opus 5.5 f1aaed6279 Tests: keep every test (and the servers they start) off the real ~/.ssh
Servers started by test_server's OneServer and LocalMode tests got no
FRAME_CONTROL_SSH_DIR, so each one imported the real ~/.ssh/config into a
fresh throwaway devices.json and seeded a pin into the real
~/.ssh/frame-control-hosts: about a dozen files per suite run.

tests/sandbox.py now gives the whole run a throwaway HOME/USERPROFILE and
FRAME_CONTROL_SSH_DIR (inherited by child processes), every test module
imports it first, and frame_connect.SSH_DIR honours FRAME_CONTROL_SSH_DIR
like frame_devices.ssh_dir(). test_sandbox checks the paths and reruns the
tests that leaked from the original environment, asserting nothing under
the real ~/.ssh changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:40:01 +11:00
saphidandClaude Opus 5.5 b89538f506 Release 0.4.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:36:27 +11:00
Alex Southwell 311fb5f6c7 Merge pull request #85 from saphid/fix/host-key-recovery
Recover from changed, missing and refused SSH keys inside the app
2026-10-12 15:24:25 +11:00
saphidandClaude Opus 5.5 93ae2b1bd1 Host key recovery: quote the jump hop's ProxyCommand; IPv6 jumps (review pr85-review-r3)
- Every word of the hardened ProxyCommand is quoted for the shell ssh runs
  it with (shlex.quote; list2cmdline on Windows) and its % doubled for
  ssh's token expansion. The ssh path is always the absolute one, spaces
  and all; an ssh at a path with $(), backticks, quotes or ; no longer runs
  shell code.
- The forward is -W [%h]:%p (an IPv6 headset), and an IPv6 jump host is
  passed bare with -p.
- The stand-in ssh now runs ProxyCommand as OpenSSH does (tokens, then
  /bin/sh -c exec), with regressions for hostile paths and IPv6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:15:44 +11:00
saphidandClaude Opus 5.5 12bd10ee42 Host key recovery: harden jump hops, keep identity history (review pr85-review-r2)
- Key check through a ProxyJump host: build the hop as our own ProxyCommand
  (no agent/X11/port forwarding, no LocalCommand or RemoteCommand, no tty,
  no further proxy), with -F none for the device; ProxyJump's own ssh would
  inherit ForwardAgent and LocalCommand from ~/.ssh/config. Refuse
  ProxyCommand and multi-hop routes, which can't be hardened.
- Identity history is judged on the current registry (named_route used a
  frozen copy and fell back to accept-new).
- A key ssh saves on first contact is recorded, made private and the route
  tightened as soon as it's saved, even if the login is then refused, and
  for keys commands save (the loop settles them every tick).
- identity_established is a device-level flag that never clears (migrated
  from last_ok), so editing or removing the address that worked can't make
  a headset new again.
- The handshake waits for its reader's end marker before taking ssh's exit
  as the end, so a late "Permission denied" isn't lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:56:02 +11:00
saphidandClaude Opus 5.5 218df4f312 Release 0.4.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:48:49 +11:00
Alex Southwell ce1a271064 Merge pull request #86 from saphid/fix/flaky-deadline-assert
Tests: stop the SteamGridDB deadline check flaking on Windows
2026-10-12 14:44:39 +11:00
Alex Southwell dbf6ffc79c Merge pull request #83 from saphid/fix/no-app-panel-73
Desktop view: say why no panel was found; desktop-closed clipboard is a 409 (#73)
2026-10-12 14:44:29 +11:00
saphidandClaude Opus 5.5 a8d5c4b44d Panels: one deadline for the whole run; read gamescope's properties once
Round-2 review of #83: the display search ran before the usual 5 s property
and geometry reads, and its 30 s memory never helped because the server
starts a fresh `python3 - panels` per request. With X tools answering in
4.6 s the run passed the server's 20 s.

- One xprop call reads all of gamescope's root properties; :0 is read first
  and, when it has them, used with no other probe. Other displays are only
  asked (2 s each) if :0 has none.
- focus() and focusable() reuse that reading instead of re-reading.
- A `panels` run has one deadline (12 s); every xprop/xwininfo is cut to
  what's left, and past it the run returns what it found with a "why"
  saying the headset was too slow.
- The display memory is documented as being for the long-lived input helper
  only.
- Tests: `frame_touch.py panels` run as a fresh process with fake xprop and
  xwininfo on PATH (lists panels; 4.6 s tools return in about 12 s, where
  the previous head ran past 60 s), plus unit tests for the single read,
  probing, the deadline and partial results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:36:53 +11:00
saphidandClaude Opus 5.5 b365a85665 Tests: SteamGridDB deadline check no longer flakes on Windows' coarse clock
Compare the deadline passed on with the caller's own deadline instead of a
fresh time.monotonic(), which failed on windows-latest with
5.000000000000028 > 5 (CI run 38200136884).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:36:13 +11:00
saphidandClaude Opus 5.5 8aeeaef666 Host key recovery: fail closed on a lost pin, read keys only (review pr85-review-r1)
- Key check: ssh writes the key during the key exchange and is stopped at once;
  no authentication, agent, forwarding, commands (-N), terminal or local
  command, all forced ahead of ~/.ssh/config, and -F none unless a jump host
  needs the config. A device accepting "none" gets nothing.
- accept-new only for a headset that never had an identity: the saved key's
  fingerprint is recorded in devices.json, and a recorded identity, an
  existing pin file or a connection that ever worked makes a deleted, empty,
  unreadable or unparseable pin fail closed ("missing or can't be read").
- forget-identity is refused (it let the next contact save any key unseen).
- A key read before the headset's port, user or that address changed can't be
  trusted.
- Without a ControlMaster (Windows) the handshake's stderr goes to a file read
  as it grows, not a pipe.
- Pin files and the check's throwaway known_hosts get make_private.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:32:08 +11:00
saphid 4e374de040 Merge remote-tracking branch 'origin/main' into fix/no-app-panel-73
# Conflicts:
#	ui/index.html
2026-10-12 14:20:08 +11:00
Alex Southwell d17e911c5b Merge pull request #84 from saphid/fix/android-tab-blank-68
Android store: app pages open again instead of a bare line (#68)
2026-10-12 14:19:35 +11:00
saphidandClaude Opus 5.5 6746a8220d Review fixes: parser-based duplicate check, quiet panel polls, safe error state
- The duplicate-name check now compiles each page's scripts together as one
  module with node --check, where a top-level name declared twice is a
  SyntaxError. Node's parser handles nested scopes, comments and strings,
  which the old line scanner did not. Fixture tests show it catches same-line,
  cross-block and const/function duplicates and ignores nested ones.
- The live view's loadPanels no longer logs expected offline or stale poll
  failures (as refresh() does).
- After a render failure, openStoreApp skips the details request, and
  loadOfferDetails no longer writes to a missing #detailInstallNote, which
  threw an unhandled TypeError. store_detail.mjs now covers the full open flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:10:54 +11:00
saphidandClaude Opus 5.5 5fb02770db Recover from changed, missing and refused SSH keys inside the app
0.4.2 sent $exception reports from the status poll (POST /api/comfort status,
frame_auth) on Linux and Windows for three states the person could not fix from
the app:

- A changed host key (REMOTE HOST IDENTIFICATION HAS CHANGED): the advice was
  "Run Set Up Connection again", which checks ~/.ssh/known_hosts and never
  replaces the headset's pinned key; Forget identity trusted whatever answered
  next without showing it.
- No pinned key ("No ED25519 host key is known for frame-control-<id> and you
  have requested strict checking"): every command said
  StrictHostKeyChecking=yes, so any command that ran before (or without) the
  connector's first handshake saving the key failed.
- Permission denied (publickey,password): not something a retry fixes.

Commands now use accept-new until a key is saved and yes after (accept-new
never replaces a saved key). A changed key fails at the identity stage with its
fingerprint; the banner and Devices tab offer "Check the new key", which reads
the key with a throwaway known_hosts file and no login, shows it beside the
saved one, and pins exactly that key only when the person confirms. Refused
keys point to Set Up Connection. ssh's own host key and login refusals are
marked as link failures and frame_auth joins the expected categories, so each
is reported at most once a session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:10:50 +11:00
saphidandClaude Opus 5.5 62d233f9c9 Panels: bound the gamescope display search; show a failed first listing in Control
Review of #83: the display search shares one 6 s deadline (2 s per display)
and is remembered (found for good, not found for 30 s), so a stalled display
can't use up the server's 20 s. Control now shows a failed panel listing
instead of "Finding the headset's panels..." when the first one fails.
Page test (tests/page/desk_panels.mjs) covers the Desktop listing: the
headset's reason, initial failure and recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:10:13 +11:00
saphidandClaude Opus 5.5 2861567d15 Android store: app pages open again instead of a bare line (#68)
Clicking any app in the Android tab opened an empty dialog, which shows as
a single horizontal line (its 1px border). The store's renderDetail was
replaced by the Devices page's renderDetail, declared further down the same
script: a later function declaration silently wins. The store's call then
rendered "Pick a headset." into the Devices panel and left #detailBody empty.
Every platform and both 0.4.x releases were affected.

The store's renderer is now renderAppDetail, and a render failure shows a
clear message with a Close button rather than an empty dialog. The same
clash hid the live view's loadPanels behind the Tools panel switcher's; that
one is now loadPanelSwitcher. Both names match the open store PR (#56).

Tests: a guard that no top-level name is declared twice in the page's
shared script scope, and tests/page/store_detail.mjs, which runs the real
store functions for a good listing and a broken one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:00:03 +11:00
saphidandClaude Opus 5.5 fcf2d12411 Desktop view: say why no panel was found; desktop-closed clipboard is a 409
Issue #73 (SteamOS 0.4.5): Capture on the Desktop view failed with "no app
panel is open in the headset" while the Plasma desktop was plainly open.
frame_touch.py's panels() came back empty and the page only knew to say
"none open".

- frame_touch.py: read gamescope's properties from :0 first, probing the
  other displays if :0 has none; list displays from /proc/net/unix's
  abstract X sockets as well as /tmp/.X11-unix; when nothing is found,
  return a "why" (X tools missing, listed windows that aren't X11 windows,
  desktop running but not listed, or nothing open).
- index.html: the Desktop view's Capture and Control show that reason, or
  the listing error, instead of a bare "no app panel".
- server.py: /api/clipboard answers 409 when the headset's desktop isn't
  open (plasmashell not running), so it no longer reaches telemetry as an
  $exception; other paste failures stay 502.

Fixes #73

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:59:30 +11:00
Alex Southwell b5c8d2f19d Merge pull request #82 from saphid/fix/release-draft-id
release.yml: read the new draft's id from the REST POST, not the lagging release list
2026-10-08 20:54:18 +11:00
saphidandClaude Opus 5.5 4a489e4606 tests: skip the release draft step test on Windows
There `bash` is the WSL launcher (no distribution on GitHub's runners), so
the step couldn't run; it only ever runs on ubuntu-latest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:46:55 +11:00
saphidandClaude Opus 5.5 0304575e5b release.yml: create the draft through the REST API so its id comes straight back
v0.4.2's first run created the draft with gh release create, then looked its
id up in the release list, which didn't show the fresh draft yet; the PATCH
went to releases/ (404) and the builds were skipped. Now the draft is POSTed
and the id read from the response, after checking the tag exists on GitHub
(what --verify-tag guarded). A rerun still reuses the draft found by tag_name,
and an empty id stops the job instead of PATCHing releases/.

tests/test_release_workflow.py runs the step against tests/fakegh, which now
answers POST releases and applies --jq through jq when installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:28:07 +11:00
saphidandClaude Opus 5.5 81bf646b0e Release 0.4.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:20:54 +11:00
Alex Southwell 4f99cd75d0 Merge pull request #80 from saphid/fix/server-sigterm-segfault
Server: no more occasional segfault on SIGTERM (exit without interpreter teardown)
2026-10-08 20:17:41 +11:00
Alex Southwell 3e814cfa1a Merge pull request #79 from saphid/feat/report-connection-diagnostics
Reports: always include a scrubbed connection summary; log connector failures
2026-10-08 20:09:21 +11:00
saphidandClaude Opus 5.5 6e566db1a7 Test: stop the server the way each platform really does
On Windows, terminate() is TerminateProcess (a hard kill, exit 1, no cleanup);
the app stops the server there by closing stdin. The staging-folder test now
stops it by closing stdin on every platform, and also by SIGTERM off Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:07:09 +11:00
saphid f221a5f588 Merge remote-tracking branch 'origin/main' into feat/report-connection-diagnostics 2026-10-08 20:00:12 +11:00
saphidandClaude Opus 5.5 b1f33c804c Connection log: whole known names before ssh's operands; spare only real tokens
Round-4 review (pr79-review-r4), opt-in log only:
- The headset's own names are replaced whole (longest first, any case) before
  ssh's hostname/host/to operands, so "talking to Jane Doe's work headset"
  can't be cut to "<host> Doe's work headset".
- Only the scrubbers' own tokens (<host>, <user>, <ip>, ...) are exempt from
  name replacement, so a display name like "<Jane Doe>" goes too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:00:10 +11:00
saphidandClaude Opus 5.5 db9bee2903 Server: clear in-flight staging folders on the way out, and a dead server's at start
Leaving through os._exit skips the weakref finalizers that clean up a
TemporaryDirectory still in use by a background thread, so quitting during
a patched VR APK transfer left the APK behind (and likewise a file staged
for the assistant, or a half-downloaded app index in the cache folder).

Those folders now carry the server's PID (frame-vr-, frame-agent-, and
.download- in the apk-sources cache folder), like the web-install and title
staging folders already did. sweep_tmp covers all five; it still runs at
start for dead servers' folders, and with own=True at the end of the
shutdown cleanup for this server's. The exit comment now says which exit
work is skipped and why that is safe.

Tests: the sweep test covers every prefix and own=True; a new server test
stops a server with in-flight folders and checks they are gone, and that a
dead server's are removed at the next start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:58:44 +11:00
Alex Southwell 22df550ff2 Merge pull request #78 from saphid/fix/telemetry-connection-noise
Telemetry: stop flooding error tracking with "Frame unreachable"
2026-10-08 19:56:50 +11:00
saphidandClaude Opus 5.5 7871aa1ca0 Connection log: redact ssh's host operands first, and the headset's alias and name
Round-3 review (pr79-review-r3), opt-in log only:
- Each attempt's redaction set now includes the headset's ssh alias and
  display name (any case), and "has no addresses" no longer names it.
- ssh's hostname/host/to operands are redacted before the known names, known
  names never replace inside an existing <placeholder>, and names that are
  ssh's own words (host, hostname, to, port) aren't treated as names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:49:57 +11:00
saphidandClaude Opus 5.5 0a9bb2e0c3 Telemetry: keep the link-failure mark through re-raised errors
Review round 4 of #78: /api/android and /api/titles re-raise frame_android's
FrameError as Failure(str(e)), which dropped frame_link_failed, so their
connection failures were still reported after the status poll's first one.
Both now raise ... from e, and diagnostic() follows __cause__
(frame_telemetry.link_failed). server.ssh() judges a link failure on ssh's
stderr only, never the command's stdout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:46:54 +11:00
saphidandClaude Opus 5.5 b25855d13a Server: leave without interpreter teardown after a clean stop (SIGTERM segfault)
On Linux with Python 3.13 (GitHub's ubuntu-latest runner, CPython 3.13.16),
about 1 stop in 100 crashed the server with SIGSEGV. A native backtrace
taken at the crash shows background threads inside OpenSSL
(X509_STORE_set_default_paths_ex, called from _ssl while the app-index
download threads build their TLS context) while the main thread was already
in exit(), where libcrypto's own atexit cleanup frees the state those
threads are using.

After the shutdown cleanup has run, the server now flushes stdout/stderr and
calls os._exit(0). Daemon threads (index downloads, stdin watcher,
telemetry, contact, headset link, request handlers) cannot be stopped
promptly, and nothing in the server registers atexit work; the OS frees the
one-server lock with the process. The stop test now runs its scenario five
times with faulthandler on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:41:38 +11:00
saphidandClaude Opus 5.5 9e70cdb48a Telemetry: decide "couldn't reach the Frame" where ssh runs, not from text
Review round 3 of #78: matching ssh's words in the message could still be
spoofed by a download's file name ("Connection closed by frame port 22.zip"
in a checksum failure lost its $exception after a poll timeout). Now
server.ssh and frame_android.ssh mark the exception (frame_host.link_failure)
when ssh itself failed to reach the headset: a time out, or exit 255 with a
line ssh starts (frame_host.ssh_link_failed). diagnostic() holds back only
marked errors in frame_unreachable / frame_not_set_up, once per session;
everything else keeps the 10-minute window. Web-link download and checksum
failures are classified download_failed before any other category.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:34:25 +11:00
saphid 9da1dea974 Merge remote-tracking branch 'origin/main' into feat/report-connection-diagnostics 2026-10-08 19:32:34 +11:00
saphidandClaude Opus 5.5 241e35d3b6 Reports: no custom alias, banner-only ssh version, whole user@host fields
Round-2 review (pr79-review-r2):
- The summary names the alias only as default ("frame") or custom, and the
  ~/.ssh/config line says "for the active alias" without naming it.
- ssh -V is parsed only as a banner at the start (OpenSSH_N.N[pN], optional
  LibreSSL/OpenSSL N.N.N) and rebuilt from fixed names and numbers; anything
  else is "unknown".
- scrub: ssh's whole user@host field (spaces, DOMAIN\ prefix, full domain
  names, "user@host's password:") goes before the email rule; a home folder's
  whole name runs to the next separator (apostrophes too), prefixes matched
  whatever their case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:32:34 +11:00
saphidandClaude Opus 5.5 70309d3cec Reports: connection summary in fixed words only; scrub log lines at capture
Independent review (pr79-review-r1) found leaks in the first version: a
failure's raw text was hidden with the current headset's hosts (so switching
headsets let the old one's name through), unknown hosts and other cases
survived, Windows user names with spaces partly survived, the PATH scan ran on
every preview, and the log throttle only caught consecutive repeats.

- The always-on summary has no free text: the current error and last failure
  are a stage plus the telemetry error category (decided when the failure
  happens) and how long ago. ssh -V is reduced to its version words.
- The opt-in server log line is scrubbed when written, with that attempt's
  hosts (case-insensitive), any name ssh gives after hostname/host/to, and
  then the shared scrubber.
- frame_telemetry.scrub: a Windows home folder's whole name (spaces too) and
  the whole user part of ssh's user@host (spaces, DOMAIN\user) become <user>.
- The ssh discovery runs once in the background from server start; a report
  waits at most 0.3 s for it.
- The log throttle keeps per-failure timestamps and counts, bounded to 32.
- Regression tests for each case; privacy.md says exactly what's sent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:21:14 +11:00
saphid 76878403c3 Merge origin/main into fix/telemetry-connection-noise
# Conflicts:
#	tests/test_telemetry.py
2026-10-08 19:21:09 +11:00
saphidandClaude Opus 5.5 105b92a0dd Telemetry: count only lines that start as ssh's link failures
Review round 2 of #78: FRAME_LINK_FAILED matched its words anywhere, so a
web-link checksum mismatch for a file named kex_exchange_identification.zip,
after a status-poll timeout, lost its $exception. Each pattern is now
anchored to the start of a line as ssh prints it (re.M), and our own
"Timed out talking to HOST" must be the whole line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:19:21 +11:00
Alex Southwell d356d9a9d5 Merge pull request #75 from saphid/fix/apk-install-instant-failures
APK installs: ship the OpenXR layer so the Windows installer installs it; CI checks the install; categorize failures
2026-10-08 19:17:53 +11:00
saphid 6f5aa33197 Merge remote-tracking branch 'origin/main' into feat/report-connection-diagnostics 2026-10-08 19:13:53 +11:00
saphidandClaude Opus 5.5 e47eefec7c Telemetry: hold back only ssh's own link failures for the session
Review of #78: the once-per-session hold applied to anything classified
frame_unreachable, so after one status-poll timeout a web-link download whose
connection reset ("download failed: ... Connection reset by peer") lost its
$exception. Now the hold needs ssh's own wording (connect to host, could not
resolve, timed out talking to, banner exchange, mux/client_loop, connection
closed/reset by HOST port N); everything else keeps the 10-minute window.
Web-link download failures are download_failed. A bare "ssh exited 255" is
no longer frame_unreachable: the command on the Frame may have exited 255.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:13:05 +11:00
saphid d92a9fc7ae Merge origin/main into fix/telemetry-connection-noise 2026-10-08 19:09:12 +11:00
saphid 875e92ea7b Merge remote-tracking branch 'origin/main' into fix/apk-install-instant-failures 2026-10-08 19:03:54 +11:00
saphidandClaude Opus 5.5 f9a87e76e8 Web-link APK installs report an unexpected install failure once
Review pr75-review-r2: _webinstall_run catches non-FrameError APK install
failures without a diagnostic, and apk_installed now leaves those to the
caller, so they went unreported. Report them there; test the full worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:03:54 +11:00
saphidandClaude Opus 5.5 95ccae5494 Show the missing-layer warning on every install path; report an unexpected install failure once
Review findings on PR #75 (pr75-review-r1):
- Catalogue, alternate-version and web-link installs now append the
  "installed without the OpenXR layer" warning to their completion message
  (frame_android.layer_note); dropped files already showed vr_issues.
- A non-FrameError that install() re-raises is reported by whoever catches
  it (job or request handler, with its traceback), so apk_installed sends
  install_finished without its own diagnostic for it (install_finished
  diagnose=False). Still exactly one install_finished.
- Tests run the real job path for both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:58:01 +11:00
Alex Southwell d58a926257 Merge pull request #76 from saphid/fix/publish-release-draft
Releases: publish drafts through REST and link update.json to the tag page
2026-10-08 18:57:25 +11:00
Alex Southwell 9292ce0a4c Merge pull request #74 from saphid/fix/catalog-arm64-only
Android installs: say which APK to get on a wrong ABI; wrong-file failures don't mark an app broken
2026-10-08 18:54:04 +11:00
saphidandClaude Opus 5.5 f5f3a1f9ca Releases: exact draft match and a fixed release page (review fixes)
- publish-release.sh: the fallback takes only an untagged-... draft titled
  exactly "Frame Control X.Y.Z" (optionally ": subtitle"); a pre-release's
  draft ("9.8.7-rc.1") or one bound to another tag is refused.
- updater.js: ignore the page in update.json/the API entirely and always
  link to releases/tag/v<version> built from the validated version, so a
  path like tag/..\..\other/repo can't reach shell.openExternal.
- Tests for both, including backslash and %2e%2e traversal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:50:00 +11:00
saphidandClaude Opus 5.5 9d062b7ba0 Ship the OpenXR layer gzipped so the Windows installer installs it; CI installs and checks
electron-builder's 7-Zip compresses a bare arm64 ELF with its ARM64 branch
filter (method 0A), which the NSIS installer's extractor can't decode and
silently skips. Every installed Windows copy (0.4.0, 0.4.1) lacked
libXrApiLayer_FRAME_compat.so, so VR APK installs failed instantly.

- Commit and package the library as libXrApiLayer_FRAME_compat.so.gz
  (byte-identical when decompressed; SHA-256 checked against the README);
  frame_android decompresses it, and a corrupt file counts as missing.
- build.sh writes the .gz.
- check-resources.js also runs from the command line against an installed
  copy, comparing every file with the unpacked build.
- release.yml: the Windows job installs the NSIS installer silently and runs
  that check.
- Tests: no bare ELF under prebuilt/, packaging filter ships only the .gz.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:47:00 +11:00
saphidandClaude Opus 5.5 a46c28d6c8 Drop compat reports in search from this change (moved to #77)
Review (T3 task pr74-review-r1, gpt-6.1-sol) found that search blocked on
frame_compat_db.load() after a cache expiry (up to ~40 s with an unreachable
database, or an untimed Keychain lookup without a key), and that the verdict
showed only in the detail view, not on cards. Revert ui/apk_sources/search.py,
ui/server.py, ui/index.html and their tests to main; keep the wrong-ABI error
text, the reports.py wrong-file filter and the ABI-selection tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:46:35 +11:00
saphidandClaude Opus 5.5 a41f635a7c Releases: publish drafts through REST and link update.json to the tag page
publish-release.sh took update.json's "page" from the draft's url, which is
releases/tag/untagged-... and dies on publishing; 0.4.0's manifest shipped
that dead link. It also looked the draft up with `gh release view <tag>`,
which reports "release not found" while a draft's tag_name still reads
untagged-... (and uses rate-limited GraphQL).

- publish-release.sh: REST only. Checks the tag exists, finds the release by
  tag_name or else the one untagged draft titled "Frame Control X.Y.Z",
  keeps the 8-installer digest check, replaces update.json via the uploads
  API, then PATCHes tag_name/draft/prerelease/make_latest. Adds --dry-run.
- updater.js: trust only this repo's releases/tag/<tag> pages (never
  untagged-...); otherwise link to releases/tag/v<version>.
- release.yml: one draft job before the matrix (no racing creates), with
  --verify-tag and tag_name set explicitly.
- Tests: tests/test_publish_release.py with a stand-in gh (tests/fakegh/gh),
  and an updater test for the page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:41:37 +11:00
saphidandClaude Opus 5.5 67bb71a708 Reports: always include a scrubbed connection summary; log connector failures
Three Windows reports said "ssh frame works in the terminal, but the app can't
find the headset", and their diagnostics held only versions: activity and the
server log are opt-in, and the connector never logged its failures anyway.

- frame_report.diagnostics always adds a connection summary (under "Include
  diagnostics"): connector phase, failed stage, attempt and reason; headset
  count and active alias; the current error and last failure plus ssh's last
  line, with the headset's addresses/hosts and user@ hidden and then the usual
  scrub; each address tried as its kind only (.local, ipv4, ipv6 link-local,
  tailscale, hostname, alias, and what a name resolved to) with its probe
  state; gateway/Tailscale; the ssh the app runs by kind (never its path),
  `ssh -V`, other ssh kinds on PATH; and whether ~/.ssh/config has the managed
  block and a hand-written Host for the alias.
- frame_link prints each failed attempt (stage, message, ssh's last line,
  address kinds) to stderr, scrubbed, so it lands in server.log; a failure that
  repeats every retry is written at most every 5 minutes.
- docs/privacy.md and the report dialog say exactly what the summary contains.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:39:32 +11:00
saphidandClaude Opus 5.5 0b9a165d83 APK installs: install VR apps without a missing OpenXR layer, fail packaging without it, categorize failures
- frame_android: a missing, unreadable or empty OpenXR compat layer no longer
  aborts a VR install; it installs without the layer and says so (vr_issues),
  unless the layer was asked for explicitly. Patch failures are named, and any
  exception (not only FrameError) reaches the install hooks/telemetry.
- app/build/check-resources.js (electron-builder afterPack): the build fails if
  the layer's arm64-v8a library or other required resources are missing.
- frame_telemetry: new fixed error categories layer_missing, apk_repack_failed,
  tool_missing; ok=false without an error reports "other".
- server: install_finished carries xr_layer_missing when a VR APK installed
  without the layer. docs/privacy.md lists every category and the new field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:38:15 +11:00
saphidandClaude Opus 5.5 88a2fe6728 Android store: say which APK to get on a wrong ABI; show compat reports in search
PostHog (0.4.0): Grayjay installs failed twice with apk_wrong_abi, once with
an armeabi-v7a file and once with an x86_64 file. Grayjay's own site offers
one APK per ABI; the FUTO F-Droid repo's Grayjay 391 is universal and does
contain arm64-v8a, and the catalogue and repo search already drop builds
without arm64-v8a, so the wrong files did not come from those paths.

- The wrong-ABI error now tells the user to download the arm64-v8a (or
  arm64, or universal) APK instead, so they don't guess again.
- An install_failed report caused by a wrong-ABI (or too-new-Android) file
  no longer rates the whole app as broken in the catalogue.
- Search results and details carry the compatibility reports the catalogue
  uses: "Reported not working on the Frame" (a warning, not a block) or
  "Works on the Frame". A report never overrides a hard blocker.
- Tests: per-ABI split builds (offer and download the arm64 one), the new
  error text and its telemetry category, wrong-file reports, and search
  verdicts from reports, with the database hook off by default in tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:37:08 +11:00
saphidandClaude Opus 5.5 ba9706b69e Telemetry: stop flooding error tracking with "Frame unreachable"
The status poll (POST /api/comfort status) meets an asleep or absent headset
every few seconds, and each ssh failure became a $exception: ~760 in two weeks
from 0.4.0, nearly all connection timeouts. Errors whose category only means
the Frame couldn't be reached (frame_unreachable, frame_not_set_up) are now
sent at most once per category per session; other errors keep the 10-minute
per-message window.

The classifier now also files "Host is down", Windows' "Unknown error" and
"banner exchange: Connection to UNKNOWN port -1", "ssh exited 255", and
dropped shared connections (mux_client_, client_loop) as frame_unreachable
instead of "other".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:32:03 +11:00
saphidandClaude Opus 5.5 551cc54bbc Release 0.4.1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 23:07:51 +11:00
Alex Southwell c3e651cd25 Merge pull request #67 from saphid/fix/contact-email-review-followups
Contact email: withdrawal covers reports, strict consent, review follow-ups to #59
2026-10-05 23:06:11 +11:00
Alex Southwell f0ba42bfba Merge pull request #70 from saphid/fix/windows-ssh-config-acl
Windows: fix ssh config ACL, link-local IPv6, and Set Up Connection under python -I
2026-10-05 23:00:44 +11:00
saphid 99fc15bd79 Merge remote-tracking branch 'origin/main' into tmp/contact67 2026-10-05 22:55:13 +11:00
saphid e63dc43c2f Merge remote-tracking branch 'origin/main' into fix/windows-ssh-config-acl 2026-10-05 22:52:52 +11:00
Alex Southwell 80f433a2d3 Merge pull request #61 from saphid/fix/windows-rdp-report
Remote desktop from Windows: sign in as steamos, and say why when the Frame doesn't answer
2026-10-05 22:52:44 +11:00
saphidandClaude Opus 5.5 07f44f9082 Windows: fix ssh config ACL, link-local IPv6, and setup under python -I
- ~/.ssh/config writes swapped in a temp file that inherited the .ssh folder's
  ACL; Windows' OpenSSH refuses one granting another account (even a deleted
  one) more than read: "Bad owner or permissions". Writes now give the file an
  owner-only ACL (frame_host.make_private), and the server repairs a refused
  config once per run and retries.
- frame_link.probe named a link-local IPv6 zone with if_indextoname, which on
  Windows is "ethernet_32769"; Windows' ssh can't resolve that, so a headset
  found at fe80:: showed as "can't find the Frame". Use the zone number there.
- frame_connect.py imports frame_host (since #60), but the app runs it with
  python -I, which leaves its folder off sys.path: Set Up Connection exited
  with ModuleNotFoundError. Add the folder, as server.py does.

Verified on a Windows 11 VM against OpenSSH_for_Windows 9.5p2: the old write
reproduces the reported error with an orphan SID's Modify ACE; the new write,
repair and server retry all leave a config ssh accepts; ssh to %ethernet_32769
fails to resolve while %5 connects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 22:43:51 +11:00
saphid c305daae15 Merge remote-tracking branch 'origin/main' into tmp/rdp61
# Conflicts:
#	ui/frame_host.py
#	ui/server.py
2026-10-05 22:41:12 +11:00
Alex Southwell a4031db052 Merge pull request #60 from saphid/fix/windows-test-suite
Windows: stop ssh/scp/ssh-keygen hanging when stderr is captured
2026-10-05 22:39:14 +11:00
saphid 87884f382b Merge branch 'ui-3-home' into ui-4-store 2026-10-01 23:26:07 +10:00
saphid f98f3e728b Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 23:21:07 +10:00
saphidandClaude Opus 5.5 90034540ae Keep the toast's fade-in, and join the test's threads on failure
From the review: opening the toast popover with .show already set skipped the
fade-in; it now opens hidden and then shows. And the superseded-test test now
joins its threads in finally, so a failure can't leave them running into
cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:21:07 +10:00
saphid cc9af9eba4 Merge branch 'ui-3-home' into ui-4-store 2026-10-01 23:08:06 +10:00
saphid bc0e0fc043 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 23:03:08 +10:00
saphidandClaude Opus 5.5 5b4efc87b3 Show toasts above open dialogs, and harden the superseded-test test
From the review of the Reconnect change:

- A toast raised while a dialog was open (Reconnect refused during an install,
  say) sat under the dialog's backdrop, dimmed. The toast is now a manual
  popover, raised again when a dialog has opened since, so it shows on top.
- The new test could wait for ever if the first test never reached its probe,
  and didn't check its threads finished. It now waits on events with time
  limits, releases everything in finally, and also checks the first test
  finishing doesn't mark the second, still running, done.
- docs/devices.md: Reconnect applies your changes by reconnecting; which
  address wins still depends on ranking and timing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:03:08 +10:00
saphidandClaude Opus 5.5 c2c1cf0168 Check every JavaScript script type, and keep the name check without acorn
From the review of the parser change: scripts typed text/ecmascript,
application/x-javascript and the other JavaScript MIME types run as classic
scripts too, so they're checked now. And the duplicate-name check (V8 only)
no longer skips with the var check when a Node lacks acorn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:37:43 +10:00
saphid 01ba796881 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 b9f96866e5 Replace Use now with a plain Reconnect, and ignore superseded tests
Three review rounds kept finding ways Use now could point at an address a
reconnect wouldn't pick: the page was predicting the outcome of the server's
race from test results that could be unfinished, from another network, or out
of date after a reorder or a Tailscale change. Adding the offered LAN address
first in the list is what makes it win; Use now only hurried that along.

The dialog's Retry button now also shows while connected, as Reconnect: it tries
the addresses again in their current order and promises nothing about which
answers first. The test results go back to plain rows.

The review also found a test started earlier could overwrite a newer one still
running, and mark it done. Each headset's tests now have a generation; only the
newest one publishes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:37:12 +10:00
saphid 3fb876ad5a Merge branch 'ui-3-home' into ui-4-store 2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 9bd6890fe8 Find var declarations with a parser, and check only classic inline scripts
The fix review showed the var regex flagged the word in comments and strings and
missed var/*c*/x and var {x}. The test now uses the acorn parser Node ships
(--expose-internals; it skips if a Node ever drops it) to find real var
declarations, and reads the page with html.parser so it checks inline classic
scripts only: no src, and no module or JSON data type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:29:12 +10:00
saphidandClaude Opus 5.5 0d181b676e Retry from the hero after a failed install, and keep var out of the page
From the fix review:

- The hero's Install skipped installing whenever a job existed, including one
  that had failed, so it only opened the details. It now tries again after a
  failure, and still only opens the details while installing or once installed.
- Declaring a var twice is legal, so the strict-mode check couldn't see it. The
  page's one var (a block-local in the capture code) is now let, and the test
  rejects var outright (CSS var() in strings is fine). It also checks every
  inline script, whatever its attributes.
- Clear filters still set the removed source filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:04:52 +10:00
saphid 8de0292568 Merge branch 'ui-3-home' into ui-4-store
# Conflicts:
#	ui/index.html
2026-10-01 22:03:10 +10:00
saphidandClaude Opus 5.5 8e8c24f370 Don't break startup at Games links, and reload titles in the iPhone app too
The fix review found the Games reload added to showPage() read `link`, which
the page declares later: opening Frame Control at #games, #library,
#sideloaded or #getgames threw at startup and skipped the rest of the setup.
And the iPhone app never watches the connection, so `link.s` stayed null and
titles still never reloaded there.

The reload now runs only when navigating to Games (the hashchange), where the
first load is already done, and doesn't depend on connection state; a failed
load shows its error in the list as Refresh did. tests/test_page_startup.py runs
the real showPage() at every page and section link with everything declared
later still uninitialised, and fails on the old line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:02:53 +10:00
saphid 79dda60bde Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 22:00:27 +10:00
saphidandClaude Opus 5.5 0bb2f9eb03 Offer Use now from the finished test's own order
The fix review found Use now could still point the wrong way: the button was
worked out from per-address ranks in the devices list while the test was still
running (a better-ranked address might yet answer), and the test's successes
changed those ranks before the list was reloaded.

The test now finishes by publishing the order a reconnect on this network would
try, worked out after it has recorded where each address works, together with
the network it ran on. The page offers Use now only once the test is done, only
for that network, and only on the first address in that order that passed. The
ranks in the devices list are gone again. docs/devices.md no longer promises
where a reconnect lands: a slow address loses to a later one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:54:16 +10:00
saphidandClaude Opus 5.5 049d50f43a Merge main into fix/contact-email-review-followups
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:38:54 +10:00
saphidandClaude Opus 5.5 3a95d3b638 privacy.md: a report saves the address first; sending it may wait
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:36:46 +10:00
saphid 30509da71f Merge branch 'ui-3-home' into ui-4-store 2026-10-01 21:32:42 +10:00
saphid d4b34e1bad Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:32:42 +10:00
saphid 22548bc42f Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 21:32:41 +10:00
saphidandClaude Opus 5.5 1863c92101 Show the focus ring on the title form's and display picker's selects too
The fix review found two older rules (#titleForm select:focus, .disp select:focus)
that outranked the new select:focus-visible outline and still removed it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:32:28 +10:00
saphidandClaude Opus 5.5 989962aecc Contact email: a report's rev is its own change; another address starts fresh
- from_report applies its change and reads the id and rev together, so a
  removal made while that change is sending is newer than the report; the
  report's redaction window now starts before the address is saved.
- A report with a different address replaces the saved one with follow-up
  questions only: update notices aren't carried over to an address nobody
  agreed them for, and the form says so before sending.
- Settings refreshes after every report send, whatever the box shows by then.
- privacy.md: a report with follow-up ticked also saves and sends the address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:25:22 +10:00
saphidandClaude Opus 5.5 497b5d9ae6 Don't launch from the hero's Install, and check page names with V8
From the independent review of this PR:

- After installing the featured app, the hero's Install on the Frame took the
  installed branch and launched it on the headset. It now opens the details,
  whose button says Open in Steam, without acting.
- The duplicate-name test matched declarations at column zero only, so an
  indented second loadPanels (the bug it was written for) passed, and so did
  classes, destructuring and later declarators. It now compiles both inline
  scripts as one strict-mode block, where V8 rejects any name declared twice
  in the shared scope, with fixtures for each case and for same-named helpers
  in different functions (which are fine).
- A card's "Won't run" overstated the check's "Might not work"; it now says
  May not run.
- Removed styles and the source-filter state that nothing used any more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:21:34 +10:00
saphid 04995c066f Merge branch 'ui-3-home' into ui-4-store
# Conflicts:
#	ui/index.html
2026-10-01 21:10:07 +10:00
saphidandClaude Opus 5.5 0bceb5c928 Reload titles on Games, show Home uploads' progress, reset the comfort form
From the independent review of this PR:

- Phones lost the Refresh button, and nothing else reloaded sideloaded titles,
  so one added or removed from another computer never showed. Opening Games
  now reloads them.
- Send files... on Home uploaded with its progress bar in the hidden Tools
  page, and phones have no Activity bar. Choosing files from Home now opens
  the file panel on Tools.
- A session that finished while the page was open left its settings (1 / 0 / 0
  after a test) in the form. The form goes back to its defaults when the
  session ends. The test now covers that, and its mock uses the page's real
  break default (20 minutes, not 30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:09:58 +10:00
saphidandClaude Opus 5.5 08d75e3ffb Contact email: follow-up given with a report is kept and removable; match by rev
- Ticking follow-up questions on a report makes that address the contact
  email (follow-up ticked, update choice unchanged), so Settings shows it
  and Remove my email withdraws it like any other.
- Reports carry contact_rev; the inbox takes a report's follow-up
  permission back when a later change from that copy (higher rev) no
  longer agrees, whatever the clocks say.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:08:16 +10:00
saphidandClaude Opus 5.5 22bb9c6469 Offer Use now only where a reconnect goes, and keep newer edits and headsets apart
From the second independent review of this PR:

- Use now sends a plain reconnect, which picks the first-ranked address that
  answers, but it was offered on every tested address listed above the one in
  use. The devices list now carries each address's rank on the current network
  (frame_devices.order_addresses), and only the address a reconnect would pick
  gets the button.
- Saving one address, cancelling, then editing another: the first save's answer
  closed the second editor and lost what was typed. Each edit now has its own
  session, and a late answer leaves a newer one alone.
- Switching headsets with the dialog open drew the address offer from the
  previous headset's status before it was cleared, so Add could save its IP to
  the new headset. The dialog now renders after the old status is cleared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:03:13 +10:00
saphid fab04f0558 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:03:13 +10:00
saphidandClaude Opus 5.5 01d5c612c0 Contact email: withdrawal covers earlier reports; consent is a real true
- A report with follow-up ticked carries this copy's contact id, and the
  inbox marks its permission withdrawn when a later choice from that copy
  no longer agrees to follow-up questions at that address.
- The one-time prompt never appears in a visit that showed the privacy
  notice, even if the Frame connects just after it's dismissed.
- Saving contact details isn't headset work: it can't hold up switching
  headsets or be refused after a switch.
- Consent flags must be JSON true/false; "false" is no longer consent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:59:23 +10:00
saphidandClaude Opus 5.5 c0ae75d56b Keep the headset menu reachable and on screen, and keep focus visible
From the independent review of this PR:

- Without a battery reading the chip was hidden, and with it the only way to
  the headset's details. It now stays, labelled Headset details.
- On a 375 px phone the menu lined up with the chip and ran 82 px off the
  left edge. It now stays 12 px inside the window.
- A long headset name pushed the menu wider; it's now cut short with an
  ellipsis (the full name is in the tooltip).
- Reduced motion turned off smooth scrolling for the page but not for the
  new scroll area.
- Selects lost their focus ring; keyboard focus now shows a blue outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:56:32 +10:00
saphid 0520082586 Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 20:56:32 +10:00
Alex Southwell 28073e212a Merge pull request #65 from saphid/fix/server-stdin-abort
A stop signal no longer crashes the server, and the app restarts it by itself
2026-09-30 22:58:55 +10:00
saphidandClaude Opus 5.5 6abc765e22 App: Try Again also works when the server is up but its page failed to load
The button was accepted only while no server was known. If the server answered
and the page then failed to load, the error page showed with the server still
known, and the button did nothing. It's now accepted from the error page itself
(the window's only data: page).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:51:24 +10:00
saphid f73efe414c Merge main into fix/server-stdin-abort 2026-09-30 22:39:14 +10:00
Alex Southwell 704e5d7780 Merge pull request #59 from saphid/feat/contact-email-opt-in
Optional contact email with separate update and follow-up consent
2026-09-30 22:09:42 +10:00
saphidandClaude Opus 5.5 36dd5a05f2 Make the offered home address the one that gets used, and keep edits safe
An interrupted review pass pointed at four problems in the pill's dialog, each
confirmed against the Frame:

- The offer to add the Frame's LAN address appended it after the Tailscale name,
  which then kept winning on that network, so nothing changed. The offer now adds
  it first in the list (address-add takes first: true); away from home the
  Tailscale name still leads. A tested address that ranks above the one in use
  gets a Use now button that reconnects through it.
- A half-typed edit was thrown away when the connection state changed after
  focus left the input, and when the server refused the save. The edit row now
  stays until it is saved or cancelled.
- Pressing Enter twice sent the update twice.
- The offer's button could act on the previously selected headset.

Keyboard focus also stays on the same button of the same address when the rows
are rebuilt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 21:28:38 +10:00
saphid 002c040223 Merge branch 'ui-3-home' into ui-4-store 2026-09-30 20:58:23 +10:00
saphidandClaude Opus 5.5 4b1bb1557d Make room in the header for the settings button
With one more button in the header the pill lost its route at a few widths
(970-1020, 1160-1180 and 1350-1410 px). The header's spacing now tightens below
1500 px, the shortcut hints go at 1200, and the compact header starts at 1030,
so the pill reads "Connected · Tailscale" in full at every width above that,
including the 1400 px default window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:58:23 +10:00
saphidandClaude Opus 5.5 73b97fedc5 windows-vm.sh: stop the input helper before giving up on a click
On a timeout or a setup error the request now stops the scheduled task and
waits for it to end before releasing the mutex, so a stalled helper can't
act later inside another request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:50:13 +10:00
saphidandClaude Opus 5.5 924e37aa30 windows-vm.sh: serialise clicks with a named mutex instead of a lock file
Windows releases the mutex when the request's process ends, so there's no
stale lock to recover and no window where two requests can take each
other's. Requests sent together now run one after another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:44:35 +10:00
Alex Southwell edbe8d4109 Merge pull request #63 from saphid/screenshot-copy
Screenshots: Copy, right-click menu, and new shots appear on their own
2026-09-30 20:43:22 +10:00
saphid b151ae691e Merge branch 'ui-2-connection' into ui-3-home
# Conflicts:
#	ui/index.html
2026-09-30 20:40:03 +10:00
saphidandClaude Opus 5.5 36db6b1ac6 Never cut the pill's status word short
At 1260 to 1400 px wide the header was a few pixels too tight and the pill read
"Connected · Tails…"; on a phone it read "Connected · T…". The status word now
always shows whole: when the route doesn't fit beside it, the route drops out
instead of being clipped. The wordmark gives way a little earlier so the route
shows at every desktop width above 960 px, and the smallest phones lose the
logo rather than squeeze the pill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:39:43 +10:00
saphidandClaude Opus 5.5 bd24436fbc windows-vm.sh: an atomic click lock, and setup errors fail the click
input.txt is now created with CreateNew, so only one request can hold it; a
lock older than 15 s is treated as left by a dead run. The outer PowerShell
stops on any error, says why in one line, releases the lock and exits 1.
docs/testing.md names both keys Set Up Connection can add to the headset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:39:07 +10:00
saphidandClaude Opus 5.5 895cc0ebbd windows-vm.sh: review fixes for the headset key and input helper
- frame-key remove deletes only the exact line add wrote, through a private
  temporary file, and leaves authorized_keys alone if it can't read or
  rewrite it. add checks the VM's key is a plain ed25519 key, keeps a last
  line without a newline intact, and doesn't duplicate a key the headset
  already trusts.
- The input helper stops on any error before acknowledging, refuses a
  second click while one is in flight, and coordinates are bounded.
- Host, container and user names are checked before they reach a remote
  shell; PowerShell errors come back as plain text.
- docs/testing.md says what frame-key does and doesn't undo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:32:35 +10:00
saphidandClaude Opus 5.5 f2c8466ba9 Screenshots: Refresh retries every failed preview, even if a background check lands meanwhile
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:32:28 +10:00
saphidandClaude Opus 5.5 12aaec7888 Keep tool headings on one line and label the battery temperature
Mac in the headset's picture quality moves into the panel with a label, and the
panel switcher's Open in headset button sits under its list, so neither heading
wraps in a narrow column. Headings line up across panels whether or not they
hold a button. An odd last figure in a stats grid spans the row instead of
leaving a hole. The battery menu says which temperature is the battery's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:31:02 +10:00
saphid 7ea999d5b4 Merge branch 'ui-1-layout' into ui-2-connection 2026-09-30 20:31:02 +10:00
saphidandClaude Opus 5.5 6cf01729e2 Screenshots: fixes from review
- A right-click menu open when the headset changes closes, so it can't act on the other headset's shot.
- A preview being retried by Refresh is no longer dropped when a background check lands first.
- A late failure from a headset switched away from no longer drops the new headset's preview.
- Tab and Escape close the menu and give focus back to where it was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:26:48 +10:00
saphidandClaude Opus 5.5 63c1a9ff55 docs: xrdp sign-in from Windows verified on a real Frame
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:25:49 +10:00
saphidandClaude Opus 5.5 f7dbad500a Docs: signing in over RDP as steamos reaches a Plasma desktop (verified)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:24:48 +10:00
saphidandClaude Opus 5.5 47a29afb4c Screenshots: recheck fixes
- In Control, a right-click on the viewer goes to the Frame only; the copy menu stays out of the way.
- Thumbnails no longer hold up the next check: a save shows as saved straight away, and a
  new shot appears while older previews are still loading.
- Refresh (or a save) during a background check reads again after it, so the answer is fresh.
- A preview that failed is retried on Refresh, not by every background check.
- Copy reports a failure if the app refuses the image, and if the browser can't copy text.
- Windows: Show in File Explorer works when the path has spaces.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:19:03 +10:00
saphidandClaude Opus 5.5 a0f810c018 App: restart the server by itself when it stops, and a Try Again button on the error page
A server that had been up for a minute starts again without asking. One that
stops sooner shows the error page, now headed "Frame Control stopped", with a
Try Again button (the menu item was the only way, and hard to find).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:17:24 +10:00
saphidandClaude Opus 5.5 e8db571a2c Remote desktop: say which way the Frame didn't answer
Second review: only a refused port 3389 means xrdp is off. A name that
doesn't resolve, a timeout or no route now say so, rather than telling the
person to turn on Developer Mode. All are Unreachable (a 400, no error
diagnostic). The .rdp file name is a digest of the address, since
fe80::1%2 and fe80::1:2 sanitised to the same name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:10:45 +10:00
saphidandClaude Opus 5.5 d9cd40c035 Remote desktop: review fixes
- Write the .rdp file through open(newline=), since Path.write_text(newline=)
  needs Python 3.10 and CI's checks job runs 3.9
- One .rdp file per address, so overlapping launches can't swap headsets
- xrdp not answering is NotListening, a 400 with its message rather than a
  500 filed as an error diagnostic
- /source-image/ lets ClientGone through instead of answering 404 mid-reply

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:06:14 +10:00
saphidandClaude Opus 5.5 865e8dc17f Align continuation lines after the run_ssh rename
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:58:28 +10:00
saphidandGPT-6.1 Sol 34a334fa27 Fix Windows OpenSSH stderr capture in app and tests
Windows OpenSSH 9.5 blocks while writing captured stderr to a pipe, even with stdin disconnected and a connection timeout. Capture stderr in a temporary file for one-shot OpenSSH calls on Windows, preserving subprocess output, text, check, and timeout behavior. Leave POSIX capture unchanged.

Use the shared runner for SSH, scp, key lookup, and streamed app-data transfers. Bound the real ssh-keygen hashing tests and keep their assertions; move the transfer-error mock to the runner seam. Add ten regression tests.

Verified the full suite on Windows 11 with bundled Python 3.12.14: 628 tests, OK (110 existing skips), 42.685s. Verified macOS Python 3.9.6: 628 tests, OK, 67.934s. Independent Codex gpt-6-sol high-reasoning review found no actionable issues. Protected RDP code is unchanged.

Co-Authored-By: GPT-6.1 Sol (Codex) <noreply@openai.com>
2026-09-30 12:52:24 +10:00
saphidandClaude Opus 5.5 3f20e5f9d3 Windows test VM: a driver script and how to set one up
scripts/windows-vm.sh drives a dockur/windows Windows 11 VM from the Mac over
ssh -J: start/stop, PowerShell, copy files in, screenshots via QEMU's
screendump, clicks and scrolls replayed by a scheduled task in the signed-in
session, QEMU key names, and a tagged, removable headset key for tests
against a real Frame. docs/testing.md explains setting it up (Windows from
Microsoft, loopback-only ports) and the pitfalls found reproducing the
Windows "RDP not working" report. docs/streaming.md records what Remote
Desktop from Windows does against the Frame's xrdp (verified 2026-09-30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:29:56 +10:00
saphidandClaude Opus 5.5 3f273ca37a Remote desktop on Windows: say to choose Connect on mstsc's file prompt
Seen on Windows 11: an unsigned .rdp file makes mstsc ask about the
publisher before the certificate warning. Plain '>' in the message, which
a cp1252 console can print.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:07:39 +10:00
saphidandClaude Opus 5.5 72ffec45c2 Remote desktop: mention the Frame's certificate warning
Seen on Windows 11 against a real Frame: mstsc warns about xrdp's own
certificate before xrdp's login box appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:53:01 +10:00
saphidandClaude Opus 5.5 2ca0e6924a Contact email tests: pin the in-gap save and same-second report timing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:38:54 +10:00
saphidandClaude Opus 5.5 cf2db32721 Contact email: don't strand a change saved as a send finishes; time reports exactly
Third review follow-ups:
- A sender that found nothing waiting checks again after letting go of the
  send lock, so a change saved in that moment is sent, not left for a retrier.
- A report is compared with a removal using its full-precision start time, so
  a report sent after the address was removed is logged as sent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:35:25 +10:00
saphidandClaude Opus 5.5 3f0f09b138 Contact email: don't block Save on a slow send; redact reports still in flight
Second review follow-ups:
- Saving returns once the choice is stored; a send already under way picks up
  the newest change, or the background retry is woken.
- A problem report still being sent when its address is removed is logged as
  <removed>, checked under the same lock the removal holds.
- The prompt re-checks the privacy notice after fetching its state.
- docs/privacy.md: offline contact changes are sent later by themselves; the
  prompt never follows straight after the privacy notice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:29:17 +10:00
saphidandClaude Opus 5.5 b8ed53f2ff Contact email: newest choice wins by rev, removal wipes the local log
Review follow-ups:
- Each contact_consent event carries a rev that goes up with every change,
  sends are serialized, and `contacts` picks every field from the highest
  rev per copy, so a withdrawal can't lose to an earlier event sent in the
  same second or with a skewed clock.
- Removing the address also replaces it with <removed> in the local
  sent log (earlier contact events and problem reports).
- The prompt is rechecked when the Frame connects, not only at page load.
- No thanks hides the bar only once the dismissal is saved.
- docs/privacy.md: say that the analytics switches don't block a report or
  contact change the person sends deliberately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:22:45 +10:00
saphidandClaude Opus 5.5 19a0d0af18 Ask for an optional contact email, with separate update and follow-up consent
Problem reports arrive with no way to reply. People can now leave an email
address with two separate opt-ins: occasional update notices, and follow-up
questions from the maintainer.

- ui/frame_contact.py keeps the address and choices locally and sends each
  change privately to PostHog as a contact_consent event under its own random
  contact id; removing the address sends a withdrawal without it. Changes made
  offline wait and are retried.
- A one-time, dismissible prompt appears after the Frame first connects; No
  thanks and showing it once are both remembered.
- Privacy & updates gains a Contact email section to add, change or remove it.
- The report form's contact field now goes with a report only when "may
  contact me with follow-up questions" is ticked (contact_followup).
- frame_report.py contacts [updates|followup] lists who agreed to what,
  using the newest event per copy.
- docs/privacy.md says what is collected, why, where and how to remove it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:15:53 +10:00
saphidandClaude Opus 5.5 7308ac09b1 Remote desktop from Windows: sign in as steamos, and say when xrdp isn't there
A Windows user reported "RDP not working". Frame Control ran `mstsc /v:HOST`,
which offers the Windows account; the Frame's xrdp (TLS, no NLA) only accepts
steamos with the Developer Mode password. The app also said "Opened Remote
Desktop" without checking that anything answered on port 3389.

- open_rdp checks port 3389 first and explains how to turn xrdp on
- On Windows, launch mstsc with a .rdp file naming user steamos (CRLF)
- Every platform's message says to sign in as steamos with the Developer Mode password
- The server no longer logs a page closing mid-reply (WinError 10053 on
  Windows) as a 500 with an error diagnostic

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:11:57 +10:00
saphidandClaude Opus 5.5 d6fb5e917a UEVR helper tests: import and skip cleanly on Windows
frame_mods runs on the Frame, but the server tests import it on every
desktop OS; the module-level `import fcntl` failed test discovery on
Windows. Import it where the lock is taken, as frame_panels and frame_vr
do. Skip the Frame-side helper tests on Windows, as test_input does for the
input agent: they rely on /proc, flock and POSIX prefix paths. The server
routes and the usage-error check still run there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:28:57 +10:00
saphidandClaude Opus 5.5 8051687e1e CI: stop recurring false failures on main and pull requests
- checks: the runner's packages.microsoft.com apt source now 403s, failing
  `apt-get update` and with it "Install zsh"; tolerate the refresh error
  (Ubuntu's lists still update and the install still fails if it can't).
- e2e: a failed quiet image build is rebuilt with the full log, but the
  script exited 2 even when that rebuild succeeded; only fail if it fails.
- ArtworkTests: a timed-out lookup gives its resolver slot back from its own
  thread, which a busy macOS runner may schedule after the next test starts.
  Wait for every slot before each test, and always return slots a test took,
  so one late thread can't cascade into three failures.
- release: tagged builds no longer re-upload the installers as workflow
  artifacts (they are attached to the release; the artifact finalize timed
  out on v0.3.0). Pull requests build installers only when the Electron app,
  the macOS helper it compiles, or the workflow changes.
- checks and release: a newer push to a pull request cancels the older run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:18:58 +10:00
saphidandClaude Opus 5.5 1a5f089e57 Server: a stop signal no longer crashes it (SIGABRT) while the app holds stdin
The --exit-on-eof watcher read stdin with a buffered read, which holds stdin's
lock. When SIGTERM stopped the server first, Python aborted at exit trying to
take that lock back, and the app showed "The server stopped unexpectedly
(SIGABRT)". It now uses os.read. The startup line is printed inside the try,
so a signal that arrives while it's printed still runs the cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 20:12:47 +10:00
saphidandClaude Opus 5.5 9e25ae9025 Make the Android store look and read like the rest of the app
Apps without a banner or screenshot show their icon on a blurred wash of
itself, or a VR or app glyph, rather than a giant letter. Every card says
whether it will run on the Frame (works, should install, not checked yet,
won't run), in place of "Free" on every card.

The featured app isn't repeated in the rows below, and no row repeats
another. Its buttons stay in the app: Install on the Frame opens the details
with the install under way, and Details opens them. It's chosen from apps
that can run, with real artwork, VR first.

The stray "Browse SideQuest" link goes; the Sources dialog lists SideQuest
with a link to its website instead of a switch that did nothing. The "All
sources" drop-down goes too, as the Sources dialog already chooses them.
The copy is plain and British ("Apps and games for the Frame", no "Find
your next favorite"), and Games and Android point at each other.

Two functions were each declared twice across the page's scripts, so the
later one silently replaced the earlier everywhere: the store's details
dialog opened empty (the Devices tab's renderDetail ran instead), and Home's
Desktop view never found the headset's panels (the panel switcher's loader
ran instead). They're renamed, and a test
fails on any top-level name declared twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:05:43 +10:00
saphidandClaude Opus 5.5 ed4a0ad5cd Lead Home with what's happening, and give settings a page
Home answers "what's my headset doing, and what do I want to do now". A
Right now card says whether SteamVR is running and holds the volume and the
next things people do: cast to this screen, play a video or photo, show a
Mac window, send files, type on the Frame. With nothing captured, the
headset view is a short strip rather than a big black box; the keyboard and
trackpad fold to one line until turned on; VR performance folds away.
Screenshots and Family and comfort follow.

Tools is grouped: in the headset (Mac in the headset, the media player, the
panel switcher), then sending files, Linux apps, remote and power. Privacy
and updates, library artwork and the assistant move to a Settings page,
opened by a gear in the header (key 6), with About and licences.

Family and comfort no longer fills the form with a finished session's
settings, so it shows 30 / 20 / 30 rather than the last test's 1 / 0 / 0,
and its recent events carry their times.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:48:57 +10:00
saphidandClaude Opus 5.5 cb0a90a9c3 Change the headset's addresses from the connection pill
The pill says plainly whether the headset is connected and how
("Connected · Tailscale", or the network's name), and never shows a
truncated address. Its dialog now leads with that, then lists the headset's
addresses with what each answered; they can be added, edited, reordered and
removed right there, and a new one is tested straight away. When the Frame
reports a LAN IP on this computer's network that isn't saved, it offers to
add it, so at home the app connects directly rather than over Tailscale.

The connection steps and this computer's network fold away while it's
connected and open when it isn't. Retry now and Set Up Connection only show
when they'd help; the buttons stay in reach when the dialog scrolls.

On the Devices tab the SSH alias, user, port and Forget identity move under
Advanced, and the address kind is worked out from the address. Report a
problem is a speech bubble rather than a warning sign.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:39:52 +10:00
saphidandClaude Opus 5.5 32fbbfe475 Fit the window at every size and show the battery once
The page now scrolls between the header and the Activity bar on a computer,
so the bar never covers content, and nothing overflows sideways from the
760 x 560 minimum up. The header gives way as the window narrows: the
wordmark goes, the tabs tighten, and below 960 px the logo, Refresh (R still
works) and the pill's second line go. Phones keep scrolling the window.

The battery shows once, in the header chip. Clicking it opens a menu with
the headset's storage, memory, temperature, Wi-Fi, uptime, SteamOS build and
services; the big battery card is gone from Home, and the VR performance
table no longer repeats battery and temperature.

Heading rows wrap their buttons onto a new line instead of squeezing the
heading, and every drop-down is dark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:19:19 +10:00
saphidandClaude Opus 5.5 83d74548cd Screenshots: Copy button, right-click menu, and new shots appear on their own
Each screenshot card and the viewer get a Copy button that puts the image on
the clipboard (natively in the desktop app, as PNG in a browser). Right-click
a screenshot to open, copy, save, show it in Finder, or copy its path or name;
right-click the viewer to copy or save. The shelf re-lists the Frame's
screenshots every 8 s while the window is visible and connected, redraws only
when something changed, and keeps thumbnails it already has. Switching
headsets clears the list and ignores answers still on their way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:08:36 +10:00
saphid daf30515b1 Merge main into flat2vr-mods
# Conflicts:
#	README.md
#	ui/index.html
#	ui/server.py
2026-09-29 13:07:33 +10:00
saphidandClaude Opus 5.5 2f056dbcff UEVR: address the second review; recheck on the Frame
frame_inject.py declares every Win32 signature, checks each result, frees
its remote buffers and closes handles. Uninstall only accepts our folder and
UEVR's own per-game settings folders, and keeps the receipt if anything can't
be removed. The injector's reply is parsed defensively. Rechecked on a real
Frame: install, three cold starts injected (33-35 s), clean uninstall.

Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:04:12 +10:00
saphidandClaude Opus 5.5 e3176a2de8 UEVR: inject with Frame Control's own script instead of UEVR's GUI injector
UEVR's .NET injector, driven with XSendEvent, injected in only 6 of 8 cold
starts on the Frame: in bad sessions every new injector ignored input or never
showed a window. start() now runs frame_inject.py under python.org's
embeddable Python (pinned SHA-256) in the game's Wine session, repeating the
frontend's own Inject steps: nullify VR plugins, load openvr_api.dll, set
Frontend_RequestedRuntime, load UEVRBackend.dll. Verified 9/9 cold starts in
33-35 s and a clean UI install/play/remove cycle. No .NET download any more
(47 MB installed instead of about 190 MB).

Also from the SWE-2 Max review: one mod action at a time (flock), a download
size cap, errors always reported as JSON, a receipt can't name another app's
prefix, 0 frames counts as an answer, and a longer server timeout for start.

Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:53:28 +10:00
saphidandClaude Opus 5.5 416f3181cc Add a UEVR mod manager: install, play in VR and remove from Frame Control
Installed Unreal Engine games get a "VR mod" button. ui/frame_mods.py runs on
the Frame: it downloads the pinned official UEVR 1.05 release and Microsoft's
.NET 6 runtimes (hash-checked, safely unpacked) into the game's Proton prefix
with a receipt; starts the game, runs the injector in the game's own Wine
session and presses OpenVR + Inject with XSendEvent (gamescope keeps real
input on the game); confirms UEVRBackend.dll is loaded and SteamVR counts
frames; and removes exactly what it added.

Verified on a real Frame 2026-09-29 with Gravitas, through the UI: install,
four injections (three from a cold start), remove back to the pre-install
prefix listing. The headset was unworn, so the VR image, tracking and controls
are not verified. Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:52:02 +10:00
saphid 3cb3defaa7 Record blocked shared-Frame verification and portal limits 2026-09-29 11:13:08 +10:00
saphidandClaude Opus 5.5 a992f6d896 PC host: review fixes (idle watchdog, controller close race, malformed input, loader path)
- The 10 s watchdog no longer ends idle sessions. PipeWire and WGC deliver
  frames only on damage; fail only when a frame entered the encoder and never
  came out, or the pipeline never produced its initial frame.
- Controller.state/call/update are inert after close(), so a /status poll
  racing Stop cannot dereference the freed native controller.
- Malformed viewer fields drop the event instead of ending the session.
- PATH/LD_LIBRARY_PATH prepends add no empty (current directory) entry.
- portal.c: document fd ownership. pipewiresrc dups the fd it is given
  (gstpipewirecore.c, F_DUPFD_CLOEXEC), so the portal closing its own fd is
  correct; clear it after close.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:44:12 +10:00
saphidandClaude Opus 5.5 d9d538690e Merge origin/main into pc-in-headset
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:38:37 +10:00
saphid 391ecbeac5 Record final local test coverage and desktop verification limits 2026-09-28 23:14:36 +10:00
saphid 1dc2a99617 Map the exported PipeWire video adapter factory 2026-09-28 23:13:19 +10:00
saphid d6f3d7c380 Set Windows DPI coordinates before native plugin initialization 2026-09-28 23:10:00 +10:00
saphid 838eaa4e6b Cover the available PipeWire converter and its D-Bus support 2026-09-28 23:09:04 +10:00
saphid 6104c3f192 Refuse window input when Windows cannot focus its target 2026-09-28 23:07:38 +10:00
saphid da5c954ce4 Retain the final idle-window update while the stream gate is closed 2026-09-28 23:05:23 +10:00
saphid 2466c5aa14 Include and smoke-test PipeWire video adapter factories 2026-09-28 23:00:13 +10:00
saphid 060cf9cd57 Bundle PipeWire client modules and record the real Frame probe 2026-09-28 22:58:41 +10:00
saphid cd202433bb Verify shared adaptation and harden PC stream lifecycle 2026-09-28 22:50:58 +10:00
saphid c5fc552c60 Add native PC capture adapters behind the shared panel viewer 2026-09-28 22:29:34 +10:00
saphid 559caa2dd0 Share the desktop stream rate controller across host platforms 2026-09-28 22:23:10 +10:00
517 changed files with 89879 additions and 1679 deletions

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ desktop or panels.
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
+38 -5
View File
@@ -5,6 +5,11 @@ on:
branches: [main]
pull_request:
# A newer push to the same pull request supersedes a run still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
checks:
runs-on: ubuntu-latest
@@ -17,7 +22,9 @@ jobs:
with:
node-version: "24"
- name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: Script syntax
run: |
sh -n ui/local-bin/ssh
@@ -34,10 +41,14 @@ jobs:
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
- name: Server tests
run: python -m unittest discover -s tests -v
- name: Linux Wireless Adapter doctor (read-only PC smoke)
run: |
python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json"
python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1'
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
- name: Updater tests
run: node --test app/test/updater.test.js
run: node --check app/main.js && node --check app/obs-source.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
- name: App tests
run: node --test app/test/*.test.js
- name: Website
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
@@ -60,9 +71,29 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: OBS lifecycle tests
run: node app/test/obs-source.test.js
- name: Server tests
run: python -m unittest discover -s tests -v
# Fast, PC-only Windows query validation, without capture libraries or a headset.
wireless-windows:
runs-on: windows-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Wireless doctor fixtures, UI and live read-only Windows inventory
run: python -m unittest discover -s tests -p 'test_wireless.py' -v
# The iPhone app: builds for the Simulator and runs its unit tests.
ios:
runs-on: macos-latest
@@ -84,6 +115,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: End-to-end tests
run: scripts/e2e.sh
+53
View File
@@ -0,0 +1,53 @@
name: PC host libraries
# PC in the headset (Experimental). The Windows job builds the native adapter
# and runs the real-encoder protocol tests; the installer's own bundle is
# tested in release.yml. The Linux adapter from PR #38 isn't shipped or offered
# in the app, but shares capture.c and controller.c, so it's still built here.
# These builds add the GPL x264 encoder for headless tests only: they're never
# uploaded or shipped.
on:
pull_request:
paths: ["desktop/**", "ui/**", "tests/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
workflow_dispatch:
jobs:
linux:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Native libraries
run: |
sudo apt-get update -qq
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
python3 desktop/build.py --with-x264
- name: Tests (real x264, fake capture consent/input)
env:
FRAME_PC_REQUIRE_NATIVE: "1"
FRAME_PC_REQUIRE_X264: "1"
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
# flaky mirror can't break a release build.
- name: MSYS2 packages for the PC host
shell: msys2 {0}
run: |
for attempt in 1 2 3; do
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
sleep $((attempt * 30))
done
exit 1
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
shell: msys2 {0}
run: |
python desktop/build.py --with-x264
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
+155 -3
View File
@@ -2,19 +2,67 @@ name: release
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
# the installers to that tag's GitHub release (created as a draft if missing).
# Pull requests that touch the app build the same installers as artifacts.
# Pull requests that change how the installers are built (the Electron app, the
# Windows PC helper libraries, the macOS helper, or this workflow) build the same installers as
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
# are tested by the checks workflow; run this one by hand to package them early.
on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: write
jobs:
# One job makes the draft, before the builds: three matrix jobs each running
# "view || create" could race and make duplicate drafts. The draft is tied to
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
# by tag. Editing a draft can make GitHub show an untagged-* name again; the
# upload step below recovers that exact draft if tag lookup fails. It is
# created with the REST API so the id comes straight back: the
# release list can lag a fresh draft (v0.4.2's first run found nothing and
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
# this step against tests/fakegh.
draft:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- name: Create the draft release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
# A rerun finds the draft even if GitHub temporarily shows untagged-*.
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"$tag\" or (.draft and (.tag_name | startswith(\"untagged-\")) and .name == \"Frame Control ${tag#v}\")) | .id")
if [ -n "$id" ] && ! [[ "$id" =~ ^[0-9]+$ ]]; then
echo "expected one release for $tag; found: $id" >&2
exit 1
fi
if [ -z "$id" ]; then
# Given a missing tag, GitHub would create it on the default branch.
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
-F draft=true -f body="" --jq .id)
fi
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
build:
needs: draft
# Still runs for pull requests and manual runs, where the draft job is skipped.
if: ${{ !failure() && !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -22,6 +70,9 @@ jobs:
- os: macos-latest
script: dist
files: app/dist/*.dmg app/dist/*.zip
- os: macos-15-intel
script: dist:mac:x64
files: app/dist/*.dmg app/dist/*.zip
- os: windows-latest
script: dist:win
files: app/dist/*.exe app/dist/*.zip
@@ -34,22 +85,102 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "24"
# PC in the headset (Experimental, Windows only): our capture adapter and
# the GStreamer libraries it uses, bundled into resources/desktop/bundle.
- uses: msys2/setup-msys2@v2
if: runner.os == 'Windows'
with:
msystem: UCRT64
update: true
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
# flaky mirror can't break a release build.
- name: MSYS2 packages for the PC host
if: runner.os == 'Windows'
shell: msys2 {0}
run: |
for attempt in 1 2 3; do
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
sleep $((attempt * 30))
done
exit 1
- name: Windows PC streaming libraries
if: runner.os == 'Windows'
shell: msys2 {0}
run: python desktop/build.py --out app/build/desktop/win-x64
- name: Build
working-directory: app
shell: bash
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Check the Mac bundle matches its runner
if: runner.os == 'macOS'
shell: bash
run: |
bundle=$(find app/dist -maxdepth 2 -name 'Frame Control.app' -type d -print -quit)
test -n "$bundle"
resources="$bundle/Contents/Resources"
for binary in "$bundle/Contents/MacOS/Frame Control" "$resources/python/bin/python3" "$resources/tools/adb" "$resources/mac/bin/frame-mac-view"; do
lipo "$binary" -verify_arch "$(uname -m)"
done
"$resources/python/bin/python3" -I -B -c 'import http.server, ssl, sqlite3; print(ssl.OPENSSL_VERSION)'
- name: Launch the built Linux AppImage
if: matrix.os == 'ubuntu-latest'
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
# The installer, not just the unpacked build: its 7-Zip payload once dropped
# the OpenXR layer's arm64 library without any error. Install it silently
# and compare every installed file with the unpacked build.
- name: Check the Windows installer installs every file
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
$setup = Get-ChildItem app/dist/Frame-Control-Setup-*.exe | Select-Object -First 1
$p = Start-Process -FilePath $setup.FullName -ArgumentList '/S' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "installer exited $($p.ExitCode)" }
$installed = Join-Path $env:LOCALAPPDATA 'Programs\Frame Control'
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# From the installed app, so this also proves the installer carries the
# libraries. After the check above, and without writing __pycache__,
# so it can't change what that check compares.
- name: Test the installed app's Python with the Windows PC libraries
if: runner.os == 'Windows'
shell: bash
env:
PYTHONDONTWRITEBYTECODE: "1"
run: |
resources="$(cygpath -u "$LOCALAPPDATA")/Programs/Frame Control/resources"
python="$resources/python/python.exe"
native="$(cygpath -w "$resources/desktop/bundle")"
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_INSTALLER_BUNDLE=1 "$python" -B -m unittest discover -s tests -p 'test_pc*.py' -v
- name: Upload to the release
if: startsWith(github.ref, 'refs/tags/')
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
# A release-notes edit can make GitHub expose a draft as untagged-*.
# Keep the normal upload path; recover only that lookup failure.
if result=$(gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber 2>&1); then
printf '%s\n' "$result"
exit 0
fi
printf '%s\n' "$result" >&2
case "$result" in *"release not found"*) ;; *) exit 1 ;; esac
tag="${GITHUB_REF_NAME}"
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
ids=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
--jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\" and (.tag_name == \"$tag\" or (.tag_name | startswith(\"untagged-\")))) | .id")
if ! [[ "$ids" =~ ^[0-9]+$ ]]; then
echo "expected one matching draft for $tag; found: ${ids:-none}" >&2
exit 1
fi
gh api -X PATCH "repos/$GH_REPO/releases/$ids" -f tag_name="$tag" >/dev/null
gh release upload "$tag" ${{ matrix.files }} --clobber
# Tagged builds are attached to the release above; only other builds need artifacts.
- uses: actions/upload-artifact@v4
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
with:
name: frame-control-${{ matrix.os }}
path: |
@@ -59,3 +190,24 @@ jobs:
app/dist/*.AppImage
app/dist/*.deb
if-no-files-found: ignore
checksums:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Attach SHA256SUMS after all installers upload
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[]' > releases.jsonl
python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" < releases.jsonl > SHA256SUMS
# Find the same draft using its exact tag, or its version title if a
# draft edit made GitHub temporarily show an untagged-* tag name.
id=$(python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" --id < releases.jsonl)
old=$(gh api "repos/$GH_REPO/releases/$id" --jq '.assets[] | select(.name == "SHA256SUMS") | .id')
for asset in $old; do gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset" >/dev/null; done
gh api -X POST "https://uploads.github.com/repos/$GH_REPO/releases/$id/assets?name=SHA256SUMS" \
-H "Content-Type: text/plain" --input SHA256SUMS >/dev/null
+10
View File
@@ -7,5 +7,15 @@ compat-db/.lakebed/
tests/smoke/results/
mac/bin/
# Shared desktop streaming controller build products
desktop/controller.dll
desktop/controller.dylib
desktop/controller.so
desktop/*.obj
desktop/*.lib
desktop/*.exp
desktop/bundle/
app/build/desktop/
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
frame/kdeconnect/packages/
+237
View File
@@ -0,0 +1,237 @@
# Changelog
## Unreleased
- Fix smoke cleanup skipping orphan metadata and Steam shortcuts after a test
title directory was already removed. Sweep and verify all six exact reserved
smoke IDs; preserve similarly named titles. Make `scripts/frame-smoke.sh`
work with POSIX sh and Python 3 without zsh, including from another working
directory. Local regressions cover both fixes; no new headset run.
- Skip backup saves and settings reached through a symbolic link anywhere below
the home folder, and recheck paths before packing each file. Local synthetic
tests cover redirected Proton prefixes and links changed after discovery;
concurrent filesystem changes can still race the checks.
[Backup layout and limits](docs/backup-and-recovery.md).
- Import a base APK with supported native-library or asset splits from a local
Android backup or XAPK/APKS/APKM bundle. Check package and version before
install; refuse resource, code and feature splits and device-variant sets.
This route has local fixture coverage, not a real split-game headset test.
- Save Steam screenshots under a separate folder for each headset, Steam
account and game. Equal screenshot filenames no longer overwrite one another;
existing files in the old flat folder are left in place.
[Copying guide](docs/recording.md).
- Keep Steam clip exports separate for each headset, Steam account and nested
recording session. Manual export and automatic copying use the same folders;
existing flat exports stay untouched and do not mark unrelated clips saved.
[Recording guide](docs/recording.md#steam-clips).
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
Highlight exact PnP instances and known state changes after a dropout;
incomplete inventories stay unknown. A Forget button clears the comparison.
No adapter setting changes or report uploads are made.
- Add recovery steps to the Windows Wireless Adapter doctor for streams that
drop as they start and dongles that will not reconnect. Compare exact PnP IDs
and test one connection condition at a time. In-app disable/re-enable remains
deferred until an active Steam adapter instance can be identified safely.
[Guide and limits](docs/windows-wireless-doctor.md).
- Add an explicit host window-switch button to whole-display panels: Alt-Tab
on Windows and Command-Tab on macOS. Single-window panels keep their own
source, so the button is hidden there.
- Enable the in-app OBS source Start/Stop control on Windows. A control pipe
lets the Python relay close its SSH child on Stop, app quit or parent exit;
add a short [Discord guide](docs/streaming-to-discord.md).
- Add a read-only wired connection doctor for a PC using the Frame's observed
USB-C network: show the local route, SSH port and pinned-key login result
without changing IP, host keys or headset settings.
[Usage and limits](docs/wired-connection.md).
- Start the Android app catalogue without waiting for the headset's installed-app
list. Browsing remains available if that status request is slow or fails;
installation still waits for a confirmed list to avoid duplicate installs.
- Verify the packaged OBS Start and Stop control with a real Frame, and record
37.600 seconds of headset mirror video and non-silent output audio in OBS.
Listening, sync and active passthrough remain unverified.
[Test record](docs/evidence/obs-media-source-real-2026-10-25.md).
- Bundle the OBS source helper in Linux and macOS installers and add a
**Capture & Live** Start/Stop control. The app selects the active Frame and
closes its SSH stream on a headset switch or app exit. A real Frame stream
reached OBS with video and non-silent audio; listening and sync remain
unverified. [Setup and limits](docs/obs-headset-source.md).
- Add a source-checkout helper that sends the Frame's SteamVR mirror and
default-output audio to a local OBS Media Source. A real Frame supplied a
4.021-second H.264/AAC stream to a Linux receiver; OBS playback and sync
remain unverified. [Setup and limits](docs/obs-headset-source.md).
- Link emulator setup guidance from Store → Emulators. Existing Steam library
shortcuts and artwork cover Android apps and sideloaded titles; Android ROM
transfer still needs a verified per-instance workflow.
[Guide](docs/emulators.md).
- Record the current Frame OS mirror and charge-limit checks. Active
passthrough in the mirror is still unknown; this build has no supported
user-writable 80% charge-limit control.
[Headset evidence](docs/evidence/round3-headset-2026-10-25.md).
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
visible; no adapter disable action is offered because an unused instance
cannot be identified reliably. No headset access or settings changes.
[Sources, verification and limits](docs/windows-wireless-doctor.md).
- Add a Linux PC Wireless Adapter doctor in Settings and Devices, with a source
CLI and local JSON report. Select observed hardware without assuming Valve
IDs; inspect USB/interface presence, driver/module binding, udev/permissions,
rfkill, channel/band and network manager evidence. Missing evidence stays
unknown; all checks are read-only and never contact the Frame.
[Usage and verification limits](docs/linux-wireless-adapter-doctor.md).
- Add Linux Tools → Steam desktop sharing: request `steam -pipewire` only after
checking Steam is not running, or open KDE Application Permissions with
instructions to revoke a verified Steam screen-sharing session manually. No
automatic permission reset, Steam restart, or headset access. Frame Control
Linux capture remains disabled; live portal behavior is unverified.
[Usage and limits](docs/steam-pipewire.md).
- Use gamescope's input for the keyboard and trackpad, following the focused
headset panel without installing an input service. Start KDE Connect only for
accents and emoji, keeping queued text ordered and cancelling it on headset
switches. Retain intended-headset checks before starting either control and
prevent Unicode helper startup from deadlocking with a headset switch.
- Keep accepted keyboard text when the Unicode queue is full: reject new batches
before sending their clicks or keys, and count in-flight text toward the limit.
Retain unsent text after helper failures/timeouts, report the error and resume
it with Try again. Clean up failed workers without touching a new headset's queue.
[Current verification and device limits](docs/evidence/pad-gamescope-2026-10-24.md).
- Keep media playback running when SteamVR refuses the optional theatre dark
surround, including a retry after standby. Log the refusal and play without
the surround. Overlay teardown attempts every owned handle and always calls
OpenVR shutdown even if a handle fails to close.
- Check the desktop server's single-instance lock before starting background
downloads, so a second server exits cleanly with the existing "already
running" message.
- Recheck saved spatial layouts on SteamOS 0.5.6 / SteamVR 2.18.2 (issue #31).
Read-only probes expose some panel geometry, but safe restoration of
Steam/gamescope docking and positions remains unverified. Record exact API
results and a reproducible probe; saved layouts remain unavailable.
- Record packaged v0.4.17 Desktop Capture succeeding on a mapped nested KDE
compositor on a real Frame with SteamOS 0.5.6. Headset overlay visibility was
not established; this is evidence, not a new fix or resolution of issue #73.
- Correct the Return to Game Mode help text: Frame Control selects the switch
supported by the headset's SteamOS build, including `steamosctl` on current
Frames. The button's behavior is unchanged.
- Distinguish an unreadable gamescope panel list from a confirmed empty list in
Desktop Capture and Control (issue #73). Failed discovery now asks users to
check SteamVR and report the exact message and SteamOS version, rather than
claiming no panel is open. This does not establish a new capture fix.
- Add an opt-in desktop low-battery alert without starting a family session.
Existing status reads confirm low charge and drain; recovery hysteresis and a
ten-minute cooldown limit repeat alerts. Off on page open/headset switch;
pauses with hidden-page status refreshes. No extra polling or headset changes.
- Clarify first-time desktop downloads, installation and opening on the website
and README, including CPU choices, unsigned-app warnings, pairing fallback
and the source-only iOS build. Keep section navigation available on mobile.
- Treat headset aliases and other CSV metadata as plain text in power and heat
exports, so spreadsheet formula prefixes cannot run when a report is opened.
- Add desktop Tools → Power & heat report: bounded read-only one/five-minute
observations with progress, stop and HTML/JSON/CSV downloads. Received samples
survive stop, disconnect and headset switch, labeled incomplete and tied to the
original headset. Shared sampler/report modules ship with the app; no power or
thermal controls are added. Download before replacing a report or quitting.
- Fix Return to Game Mode misidentifying full Plasma Desktop Mode when its VR
overlay also runs gamescope (issue #142). Session targets now determine the
mode; conflicting or incomplete evidence stays unknown. Use SteamOS's
`steamosctl` switch when available, with older selector commands retained.
- Export read-only power and heat observations to an offline accessible HTML
report with JSON/CSV downloads. `--html FILE` limits sessions to ten minutes;
Ctrl+C saves received samples with an explicit incomplete state. Units,
unknown counts and sources remain visible; no headset controls are added.
- Add a guarded `frame-doctor.sh --plan` with read-only, evidence-specific next
steps and explicit local baseline reports for before/after diagnostics (issue
#34). Restart risk blocks plans/comparisons; no automatic repairs, password
changes or headset security changes are performed.
- Recover a tagged release upload when GitHub temporarily displays its draft
as `untagged-…` after an edit, without creating a duplicate draft or
silently retrying unrelated upload failures.
- Import standalone APK/OBB backups from XAPK/APKS/APKM archives through the
existing preview and game-data setup flow. Unsafe archives, split sets and
Android/data restores are refused with guidance. This does not establish RE4
compatibility or diagnose its reported failure (issue #102).
- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for
battery, CPU, named temperatures, fan and available power rails (issue #52).
JSON samples/summaries and CSV exports retain explicit unavailable states;
active smoke-test locks stop measurement. No power settings are changed.
- Show saved Android compatibility reports on store search cards and app details.
Reports refresh in the background, so a stalled database or credential lookup
cannot delay search. Warnings allow installation; build compatibility blockers
still apply. Public copies use this computer's reports, not private shared ratings.
- Fix two cases where optional diagnostic logs could retain part of a headset
display name or an SSH host name, while preserving existing privacy redaction
(issue #81).
- Keep the full host-key recovery explanation readable in Devices, including
long addresses, and remove doubled punctuation from the connection tooltip
(issue #87). Refresh stays within the header and switching headsets selects
the new headset's details.
- Fix experimental PC screen capture on Windows builds whose streaming libraries
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
windows are hidden with an explanation instead of failing when shown. A Windows
VM streamed a real desktop to the Frame; physical input remains unverified.
- Add read-only battery health in Tools: charging state, temperature, cycle count,
and available capacity with explicit unknown states. The parser was checked
on a real Frame; the connected desktop UI was tested with synthetic readings.
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
library/game install-size breakdown, with unknown sizes, partial inventories
and permission/missing-drive states. Includes instructions for Steam's own
Move UI; Frame Control does not move or delete game files. The read-only
parser was checked on a real Frame; the connected UI and game moves were not.
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
with verified SSH identity, private summaries and actionable next steps
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
manual; restart-loop evidence stops deeper diagnostics. The real Frame
verified the SSH connection and safety stop; deeper checks were skipped.
- Fixed headset smoke cleanup reporting success while a smoke-title process
could remain running after its files were removed. Cleanup now stops and
rechecks processes tied to exact reserved test-title paths, uses PID handles
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
shared Wine services are left alone. Verified with local Linux process
regressions and a 13/13 real Frame smoke run that stopped five test-owned
Proton process PIDs before removing the Windows test title.
- Keep failed automatic screenshot and clip copies visible after three attempts.
**Resume** and **Copy everything now** allow another try without restarting;
headset switches cannot use another headset's list or saved-file history.
[Copying guide](docs/recording.md).
+3
View File
@@ -58,6 +58,9 @@ node --test site/test/*.test.mjs # website feedback function
Say what you tested, and whether you tried it on a real Steam Frame.
To add a local feature without changing the core app, see the
[extension guide](docs/extensions.md) and its working example.
## Blocking
If you ignore this document twice, or spam the tracker with agent-generated
+154 -41
View File
@@ -7,9 +7,9 @@
**Manage your Valve Steam Frame from your computer.**<br>
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
[![Latest release](https://img.shields.io/github/v/release/saphid/steam-frame?label=release&color=1a9fff)](https://github.com/saphid/steam-frame/releases/latest)
[![Latest release](https://img.shields.io/github/v/release/saphid/frame-control?label=release&color=1a9fff)](https://github.com/saphid/frame-control/releases/latest)
[![Platforms](https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-2a475e?label=runs%20on)](#install)
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/frame-control/checks.yml?branch=main&label=checks)](https://github.com/saphid/frame-control/actions/workflows/checks.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE)
[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
@@ -19,12 +19,14 @@ See what the headset sees, install games and Android apps, move files and text a
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
<a id="trailer"></a>
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
<a href="https://github.com/saphid/frame-control/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
[See what was tested on a real Frame, in a VM, or only in automated checks](docs/current-support-matrix.md).
</div>
---
@@ -44,13 +46,19 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
**🔋 Battery and status**<br>
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
For PC-side USB, driver, radio and network manager evidence, use the
[Linux Wireless Adapter doctor](docs/linux-wireless-adapter-doctor.md).
For repeatable read-only battery, power-rail, temperature and fan measurements,
use the [power and heat sampler](docs/power-and-heat.md).
</td>
</tr>
<tr>
<td valign="top">
**🎮 Steam games**<br>
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
</td>
<td valign="top">
@@ -63,17 +71,17 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
<tr>
<td valign="top">
**📁 Files, games and clipboard**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
**📁 Files and games**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you.
</td>
<td valign="top">
**📸 Screenshots**<br>
Browse the shots you take in the headset and save them to your Pictures folder.
**📸 Screenshots and recordings**<br>
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record SteamVR's mirrored headset view, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder. [Recording details and passthrough limits](docs/recording.md#does-record-include-passthrough).
**⌨️ Keyboard and trackpad**<br>
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one. Copy text to the headset's clipboard and back again. Press T to open it.
</td>
</tr>
@@ -89,6 +97,14 @@ Install desktop apps like Moonlight or VLC, and set each Android app's resolutio
**⚡ One-click tools**<br>
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
</td>
</tr>
<tr>
<td colspan="2" valign="top">
**💾 Backup and recovery**<br>
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
</td>
</tr>
</table>
@@ -105,17 +121,49 @@ already ships (sideloading a game copies Valve's own devkit scripts to
## Install
**Install Frame Control on your computer, not on the headset.** The website is
for downloads and instructions; it does not connect to your Frame in a browser.
Choose the operating system and processor of the computer you will use.
| | Download | Needs |
|---|---|---|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra |
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
| **macOS** (Intel, v0.4.39+) | [Frame-Control-mac-x64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-x64.dmg) | Nothing extra |
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) |
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
**iPhone and iPad:** the same features from your phone, with nothing to install on
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
### Is it safe? Check your download
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
Starting with **v0.4.26**, get the installer and [SHA256SUMS](https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS) from the **same release**. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Use the command for your downloaded file, then compare all 64 characters with its line in `SHA256SUMS`:
```powershell
# Windows PowerShell
Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
```
```sh
# macOS: shasum -a 256 Frame-Control-mac-arm64.dmg
# Linux: sha256sum Frame-Control-linux-x86_64.AppImage
```
The [source](https://github.com/saphid/frame-control) and [release build workflow](.github/workflows/release.yml) are public. The Windows installer is **not code-signed**, so SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.
日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
**Which build?** On a Mac, check Apple menu → About This Mac: use `mac-arm64`
for Apple Silicon (M-series), or `mac-x64` for Intel (available from v0.4.39). On Windows,
check Settings → System → About → System type: the release is x64, not a native
Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64`
(arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions.
**iPhone and iPad:** no App Store, TestFlight or `.ipa` download is provided in
the current release. It runs from your phone without a computer after setup.
Build it from [`ios/`](ios) in Xcode on a Mac with your own signing team; see
[docs/iphone.md](docs/iphone.md).
The app brings its own Python and `adb`. macOS includes SSH; Windows needs the
OpenSSH Client optional feature (normally installed).
From 0.4 it updates itself: when a new version is published, a banner offers
**Update and restart**. It sends anonymous usage statistics, which you can turn
off. Sharing compatibility results and error details is opt-in. See
@@ -126,16 +174,44 @@ distribution's. If you already have `adb`, the app uses yours.
<details>
<summary><b>macOS: the app isn't notarized</b></summary>
There's no paid Apple developer account behind it, so macOS says the app is
damaged or can't be checked. Drag it to Applications, then clear the download
quarantine once:
The app has an **ad hoc signature**, no Apple Developer ID signature, and no
notarization. The DMG is unsigned. First verify the download against the
`SHA256SUMS` from the same release, then open the DMG and drag **Frame Control**
to **Applications**.
Check your processor before opening: v0.4.38 and earlier publish only
`mac-arm64` for Apple Silicon. On an Intel Mac, this build cannot run;
removing quarantine or installing Rosetta does not make an ARM app run on Intel.
On a real Intel MacBook Air with macOS 15.7.7, opening v0.4.35 and v0.4.36
reported: **“The application cannot be opened because it has an incorrect
executable format.”** This is an architecture failure, not an observed
Gatekeeper warning. The official **v0.4.39 Intel DMG** was then verified,
installed and launched on that Air. Both CLI and T3 browser downloads lacked
quarantine metadata; no Gatekeeper dialog appeared, while `spctl` rejected the
ad hoc signed app. This does not verify Safari/Chrome warnings or Open Anyway.
See the [released Intel test](docs/evidence/mac-v0439-2026-10-26.md) and
[earlier architecture failure](docs/evidence/mac-desktop-2026-10-26.md).
On a compatible Mac, if macOS blocks an unverified developer, try opening the
app once, then go to **System Settings → Privacy & Security → Open Anyway**
and confirm **Open**. Only do this for a download you verified and trust.
[Apple's opening instructions](https://support.apple.com/102445) describe the
current security controls. The exact warning and this override flow have not
yet been verified here on either Mac architecture.
If a trusted, verified copy still reports “damaged” and offers no Open Anyway
option, the Terminal fallback removes only this app's download quarantine
(it does not sign or notarize the app):
```sh
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
```
The first time, macOS also asks to allow local network access (for SSH) and
control of Terminal (for the password prompts).
Then open it from Applications. Do not disable Gatekeeper globally. Depending
on the features you use, macOS may also request Local Network access, permission
to control Terminal, or Screen Recording; approve only the feature you intend
to use. Those permission prompts were not verified in the release test above.
</details>
<details>
@@ -157,26 +233,54 @@ If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
or run it with `--appimage-extract-and-run`.
</details>
## Open the app
**Optional: open at login.** In the installed desktop app, choose **Frame → Open at Login**. It is off by default. When on, a login launch starts in the tray or menu bar if that control is available; click its icon to open the window, or use its **Quit Frame Control** menu item. Closing the window keeps the app in the tray while this option is on. Turn the same menu option off to remove its OS login item. On Linux this creates or removes only Frame Control's own file in `~/.config/autostart/` (or `$XDG_CONFIG_HOME/autostart/`). If the desktop has no working tray, the window opens at login instead. A source checkout does not offer this setting.
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
open it from Applications. For the Gatekeeper warning, use the macOS steps
above. Allow local network access and control of Terminal when prompted.
- **Windows:** run the installer, then open **Frame Control** from Start. For
the portable download, extract the entire `.zip` first and run
`Frame Control.exe` inside it. See the SmartScreen note above.
- **Linux:** install the `.deb` with your package installer and launch
**Frame Control** from your applications menu; or make the AppImage executable
and run it as described above. Install the OpenSSH client if `ssh` is missing.
## Set up the headset
You type one password on the headset, once. Everything else happens on your
computer.
Keep the Frame awake and connect it and your computer to the same trusted local
network. Guest Wi-Fi/client isolation can prevent discovery and connections.
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
in the Developer section, **Set User Password**. Pick something short:
you'll type it once more on your computer and then never again.
2. **On your computer:** open Frame Control. It offers to **Set Up
Connection**, which finds the headset, creates an SSH key, and asks for that
password once in a terminal window. If it can't find the Frame, type the
IP address from the Frame's Quick Settings.
Developer → **Set User Password**. Choose a password that isn't easy to guess:
it also protects `sudo` and remote desktop, even after SSH keys are set up.
2. **On your computer:** open Frame Control and choose **Set Up Connection…**.
If you dismissed the first-run prompt, use **Frame → Set Up Connection…**.
Setup opens a terminal and tries to find the headset. If it can't, use its IP
address from Quick Settings or Steam Settings → Internet. Windows/Linux setup
prompts for it; macOS exits instead. On macOS, return to the app →
**Devices & connection → + Add a headset…**, set SSH alias to `frame` for
the default headset, fill in **Address**, then choose **Set Up…**.
3. **Pair or enter the password:** setup first tries Valve's devkit pairing.
Open Steam Settings → Developer → **Pair new host** in the headset and approve
only the request you just started. The service and pairing-mode step are
verified on a Frame; approval and key installation through that flow remain
unverified. If pairing fails or times out, setup falls back to asking for the
Developer Mode password in the computer's terminal. Password typing may show
no characters; press Enter when done. See [SSH details](docs/ssh.md#password-free-pairing-steamos-devkit-service).
4. After setup succeeds, return to Frame Control. It uses SSH keys for later
connections whenever the Frame is awake and reachable. If it stays offline,
check Developer Mode, the address and network isolation, then run setup again.
For connections away from home, see [Tailscale](docs/tailscale.md).
Before asking for the password it tries Valve's SteamOS devkit pairing: in
the headset, open Steam Settings → Developer → **Pair new host** and approve
the request, and no password is needed. (The service and the pairing-mode
step are verified on a Frame; the approval itself isn't yet. See
[SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).)
3. That's it. The app now reaches the headset whenever it's awake and on the
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
**Does Frame Control lower streaming lag?** No. Frame Control manages the Frame
over SSH. SteamVR or Steam Link carries the game stream. Frame Control does not
carry that stream, lower ping, or need the Wireless Adapter. It can connect over
Wi-Fi, the Wireless Adapter, or USB when your computer has an SSH route to the
Frame. The wired doctor checks only that SSH route. A data-capable USB-A-to-C
cable has not been tested for the Frame's USB network. See the
[connection FAQ and Japanese quick start](docs/connection-faq.md).
**What it changes:** only what you click. Installs go to your user account on
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
@@ -189,7 +293,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
This is a first public test, so reports are really useful, especially from
Windows and Linux. The quickest way is **Report a problem** in the app (the
warning-sign button at the top, or **Help → Report a Problem…**). It adds
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
diagnostics with personal details removed, shows you exactly what's included,
and sends it privately to the maintainer; nothing is published. Without the app,
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
@@ -212,17 +316,24 @@ Frame's software fits together, all checked against a real headset and labelled
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
| [SteamOS October 2026 compatibility](docs/steamos-2026-10-compat.md) | Frame OS 0.5.6 connection changes and SteamOS 3.9.5 PC host notes |
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [Steam desktop sharing (Linux)](docs/steam-pipewire.md) | External Steam helper: launch with `-pipewire` after exiting Steam, or open KDE permissions to re-pick a screen manually; no automatic permission reset |
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame plugin author guide](docs/frame-plugin-author.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
| [Power and heat measurements](docs/power-and-heat.md) | Read-only sampling and offline HTML reports with JSON/CSV downloads |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
| [Open questions](docs/open-questions.md) | What's still unchecked |
<details>
@@ -237,8 +348,8 @@ Frame's software fits together, all checked against a real headset and labelled
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
database key (maintainer-only) is never written to the repo.
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
you've switched to key auth, a short password still protects `sudo` and
RDP, so pick one that isn't trivially guessable.
you've switched to key auth, that password still protects `sudo` and RDP,
so pick one that isn't trivially guessable.
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
**every** Frame port is reachable from your tailnet, including Steam's DevTools
@@ -264,3 +375,5 @@ into a draft release, which reaches users once published. See
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
Corporation. This project isn't affiliated with or endorsed by Valve.
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
+3 -1
View File
@@ -10,6 +10,8 @@ ship other people's software, unchanged, each under its own licence:
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
| GStreamer 1.x, a few of its plugins and the libraries they need, as [MSYS2](https://packages.msys2.org/) builds them | Windows app only, for the experimental [PC in the headset](docs/pc-in-headset.md) | LGPL-2.1-or-later and others (per package); the GPL x264 encoder is left out | `libraries.json` (exact package versions) and the `licenses` folder in the app's `desktop/bundle` folder |
Frame Control starts KDE Connect and talks to it over its network protocol;
it doesn't link to it or include its code.
it doesn't link to it or include its code. On Windows, Frame Control's own
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
+14
View File
@@ -6,11 +6,25 @@ A report: package, version, result (runs | crashes | install_failed |
instance_failed, from an automated test), rating (works | issues | broken, from
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
Newest wins, and a person's rating beats an automated result.
An install_failed report saying the file had no arm64-v8a build (or needed a
newer Android) is about that one APK file, often the wrong per-ABI download of
an app that has an arm64 build, so it says nothing about the app and is left out.
"""
import re
FILE_FAULT = re.compile(r'no arm64-v8a build|needs Android API')
def about_app(r):
"""False for reports about one wrong APK file rather than the app itself."""
return not (r.get('result') == 'install_failed' and not r.get('rating')
and FILE_FAULT.search(r.get('notes') or ''))
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
reports = [r for r in reports or () if about_app(r)]
if not reports:
return None
rs = sorted(reports, key=lambda r: r.get('date') or '')
+84
View File
@@ -0,0 +1,84 @@
// Checks that the app's resources hold what Frame Control can't work without.
// The OpenXR compatibility layer is why this exists: VR APK installs need its
// arm64-v8a library (ui/frame_android.py XR_COMPAT_FILES).
//
// As electron-builder's afterPack hook it checks the unpacked app. From the
// command line it checks an installed copy, which is what caught the Windows
// installer silently dropping the library (see .github/workflows/release.yml):
// node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]
// With a reference (the unpacked build's resources), every file in it must also
// be in the installed copy, at the same size.
const fs = require("fs");
const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/frame_power.py",
"ui/frame_status.py",
"scripts/frame_power_sample.py",
"scripts/frame_power_report.py",
"scripts/obs-headset-source.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
"frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.gz",
];
function resourcesDir(context) {
if (context.electronPlatformName === "darwin" || context.electronPlatformName === "mas") {
const app = `${context.packager.appInfo.productFilename}.app`;
return path.join(context.appOutDir, app, "Contents", "Resources");
}
return path.join(context.appOutDir, "resources");
}
function size(file) {
try {
return fs.statSync(file).size;
} catch {
return -1;
}
}
// Required files that are missing or empty.
function missing(dir) {
return REQUIRED.filter((rel) => size(path.join(dir, rel)) <= 0);
}
// Files under reference that aren't in dir at the same size.
function differences(dir, reference) {
const out = [];
(function walk(rel) {
for (const e of fs.readdirSync(path.join(reference, rel), { withFileTypes: true })) {
const r = path.join(rel, e.name);
if (e.isDirectory()) walk(r);
else if (e.isFile() && size(path.join(dir, r)) !== size(path.join(reference, r))) out.push(r.split(path.sep).join("/"));
}
})("");
return out;
}
exports.default = async function afterPack(context) {
const dir = resourcesDir(context);
const gone = missing(dir);
if (gone.length) {
throw new Error(`packaged app is missing required resources in ${dir}: ${gone.join(", ")}`);
}
};
exports.missing = missing;
exports.differences = differences;
exports.REQUIRED = REQUIRED;
if (require.main === module) {
const [dir, reference] = process.argv.slice(2);
if (!dir) {
console.error("usage: node build/check-resources.js INSTALLED_RESOURCES [REFERENCE_RESOURCES]");
process.exit(2);
}
const problems = [...missing(dir), ...(reference ? differences(dir, reference) : [])];
if (problems.length) {
console.error(`${dir} is missing or has the wrong size for:\n ${[...new Set(problems)].join("\n ")}`);
process.exit(1);
}
console.log(`${dir}: all required resources present${reference ? ", and every file of " + reference : ""}`);
}
+2 -1
View File
@@ -4,7 +4,7 @@
// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
// packages for every build), into ../frame/kdeconnect/packages as listed in
// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
const https = require("https");
@@ -15,6 +15,7 @@ const PY = "3.12.14+20260924";
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
+ `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
const PYTHON = {
"mac-x64": ["x86_64-apple-darwin", "7ea9761b9069c10b9a20531d568645849d604c59e9c7f11f6659f1e1790c968e"],
"mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
"win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
"linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
+53
View File
@@ -0,0 +1,53 @@
// Opt-in OS login item. Linux owns only its marked autostart file; an existing
// custom entry is never overwritten or removed.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const MARKER = "# Managed by Frame Control; use Frame > Open at Login to remove.\n";
const FILE = "com.saphid.frame-control.desktop";
function quoteExec(value) {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`").replace(/%/g, "%%")}"`;
}
function createLoginItem({ platform = process.platform, electronApp, env = process.env,
executable = process.execPath, filesystem = fs,
home = os.homedir() }) {
const config = env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)
? env.XDG_CONFIG_HOME : path.join(home, ".config");
const file = path.join(config, "autostart", FILE);
const command = platform === "linux" && env.APPIMAGE && path.isAbsolute(env.APPIMAGE)
? env.APPIMAGE : executable;
function owned() {
try { return filesystem.readFileSync(file, "utf8").startsWith(MARKER); }
catch (err) { if (err.code === "ENOENT") return false; throw err; }
}
function enabled() {
if (platform === "linux") return owned();
return !!electronApp.getLoginItemSettings().openAtLogin;
}
function set(enabledNow) {
if (platform !== "linux") {
electronApp.setLoginItemSettings({ openAtLogin: !!enabledNow, openAsHidden: !!enabledNow,
args: enabledNow ? ["--hidden"] : [] });
if (enabled() !== !!enabledNow) throw new Error("The operating system did not save the login item.");
return enabled();
}
if (enabledNow) {
if (filesystem.existsSync(file) && !owned()) throw new Error(`A different autostart entry already exists: ${file}`);
filesystem.mkdirSync(path.dirname(file), { recursive: true });
const body = MARKER + "[Desktop Entry]\nType=Application\nName=Frame Control\n" +
`Exec=${quoteExec(command)} --hidden\nTerminal=false\nX-GNOME-Autostart-enabled=true\n`;
const tmp = file + `.tmp-${process.pid}`;
try { filesystem.writeFileSync(tmp, body, { mode: 0o600 }); filesystem.renameSync(tmp, file); }
finally { try { filesystem.unlinkSync(tmp); } catch (err) { if (err.code !== "ENOENT") throw err; } }
} else if (owned()) {
filesystem.unlinkSync(file);
}
return enabled();
}
return { enabled, set, file };
}
module.exports = { createLoginItem, quoteExec };
+207 -14
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
const { app, BrowserWindow, Menu, Notification, Tray, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -11,6 +11,9 @@ const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const updater = require("./updater");
const { preferenceStore } = require("./preferences");
const { createLoginItem } = require("./login-item");
const { createMetaSignIn } = require("./meta-signin");
const run = promisify(execFile);
@@ -27,10 +30,13 @@ const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Contr
const LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame";
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
const loginItem = createLoginItem({ electronApp: app });
let server = null;
let url = null;
let win = null;
let tray = null;
let quitting = false;
let python = null;
@@ -115,10 +121,11 @@ function ping(target) {
}
async function startServer() {
// The version and whether this is a built app go to ui/frame_telemetry.py, which
// sends nothing from a source checkout.
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
// which sends nothing from a source checkout.
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
@@ -149,7 +156,7 @@ async function startServer() {
const target = `http://127.0.0.1:${port}/`;
for (let i = 0; i < 100; i++) {
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
if (await ping(target)) { url = target; return; }
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
@@ -175,18 +182,27 @@ function stopServer() {
if (server) endServer(server);
}
function errorPage(message) {
function errorPage(message, title = "Frame Control couldn't start") {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
<p>${esc(message)}</p>
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
// A server that had been running starts again by itself (something stopped it: a
// signal, a crash). One that stops again within a minute shows the error instead,
// so a server that can't stay up doesn't restart forever.
let serverStarted = 0;
function serverDied(why) {
url = null;
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
if (!win) return;
if (Date.now() - serverStarted > 60000) restartServer();
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
}
// Restarts that overlap share one: two could each start a server, and the one
@@ -263,13 +279,61 @@ function fromUi(e) {
} catch { return false; }
}
// The error page's Try Again button. The error page is the only data: page the window
// shows (`url` can still be set then: the server answered but the page failed to load).
ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// Text from the headset's clipboard onto this computer's.
ipcMain.handle("clipboard:write", (e, text) => {
if (!fromUi(e) || typeof text !== "string") return false;
clipboard.writeText(text);
return true;
});
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
if (img.isEmpty()) throw new Error("not an image");
clipboard.writeImage(img);
return true;
});
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// The OBS source is local to this computer. Closing the Python relay's control
// pipe lets it stop SSH on every platform, including Windows where SIGTERM
// terminates Python without running its cleanup block.
const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py");
const { createObsSource } = require("./obs-source");
const obsSource = createObsSource({
spawn,
// Keep the button hidden until native Windows/Frame streaming is verified.
supported: !IS_WIN,
prepare: async alias => {
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
const env = { ...process.env, PATH: await loginPath() };
const executable = python || await findPython(env);
if (!executable) throw new Error(PYTHON_HELP);
if (quitting || alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
return { executable, args: [...PY_FLAGS, OBS_SCRIPT], env };
},
onState: state => {
if (win && !win.isDestroyed()) win.webContents.send("obs:state", state);
},
});
const publicObs = () => obsSource.status();
const stopObs = () => obsSource.stop();
ipcMain.handle("obs:status", e => fromUi(e) ? publicObs() : null);
ipcMain.handle("obs:start", (e, port) => fromUi(e) ? obsSource.start(activeAlias(), port) : null);
ipcMain.handle("obs:stop", e => fromUi(e) ? stopObs() : null);
// The page reports the headsets it knows (the server's Devices tab), so the Frame
// menu can switch between them. Only plain names and ids go into the menu.
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -280,6 +344,7 @@ ipcMain.on("devices:changed", (e, list) => {
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
if (JSON.stringify(next) === JSON.stringify(devices)) return;
devices = next;
if (obsSource.status().alias && obsSource.status().alias !== activeAlias()) stopObs();
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
@@ -325,6 +390,47 @@ ipcMain.handle("comfort:notify", (e, message) => {
});
});
// ---- Sign in with Meta (app/meta-signin.js, docs/meta-library.md) ----
// The token goes from the sign-in window's own session straight to the server's
// connect path; the page asks for it and only hears the server's answer.
function postApi(apiPath, body) {
return new Promise((resolve, reject) => {
if (!url) return reject(new Error("Frame Control's server isn't running."));
const data = JSON.stringify(body);
const req = http.request(new URL(apiPath, url), {
method: "POST", timeout: 60000,
headers: { "Content-Type": "application/json", "X-Frame-UI": process.env.FRAME_UI_KEY || "1",
"Content-Length": Buffer.byteLength(data) },
}, (res) => {
let text = "";
res.setEncoding("utf8");
res.on("data", (c) => { text += c; });
res.on("end", () => {
let parsed = {};
try { parsed = JSON.parse(text); } catch {}
resolve({ status: res.statusCode, data: parsed });
});
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
req.end(data);
});
}
let metaSignIn = null;
const meta = () => (metaSignIn ||= createMetaSignIn({
BrowserWindow, session, shell, parent: () => win,
// The page only offers sign-in after its "I understand" box is ticked (checked again below).
connect: (token, attempt) => postApi("/api/meta", { action: "connect", token, acknowledged: true, attempt }),
cancel: (attempt) => postApi("/api/meta", { action: "cancel-signin", attempt }),
}));
ipcMain.handle("meta:signin", (e, acknowledged) => {
if (!fromUi(e)) return null;
if (acknowledged !== true) return { ok: false, error: "My Meta library is experimental: tick that you understand the warning first." };
return meta().signIn();
});
ipcMain.handle("meta:signout", (e) => fromUi(e) ? meta().signOut() : false);
// frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch),
// so they wait here until the page asks for them. The page checks the link with
@@ -441,7 +547,60 @@ function registerScheme() {
}
}
function createWindow() {
function showWindow() {
if (!win) return createWindow(true);
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
function ensureTray() {
if (tray) return true;
try {
const icon = nativeImage.createFromPath(path.join(__dirname, "build", "icon.png"));
if (icon.isEmpty()) return false;
tray = new Tray(icon);
// Some Linux desktops accept the Tray object but expose no status icon.
// Keep a visible login window rather than hiding the only way back in.
if (process.platform === "linux") {
const bounds = tray.getBounds();
if (!bounds.width || !bounds.height) { tray.destroy(); tray = null; return false; }
}
tray.setToolTip("Frame Control");
tray.on("click", showWindow);
updateTrayMenu();
return true;
} catch {
if (tray) { try { tray.destroy(); } catch {} tray = null; }
return false;
}
}
function updateTrayMenu() {
if (!tray) return;
tray.setContextMenu(Menu.buildFromTemplate([
{ label: "Open Frame Control", click: showWindow },
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(), click: toggleLoginItem },
{ type: "separator" },
{ label: "Quit Frame Control", click: () => app.quit() },
]));
}
function toggleLoginItem() {
try {
const on = loginItem.set(!loginItem.enabled());
if (on) ensureTray();
else if (tray) { tray.destroy(); tray = null; }
buildMenu();
updateTrayMenu();
} catch (err) {
dialog.showErrorBox("Open at Login", String(err.message || err));
buildMenu();
updateTrayMenu();
}
}
function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray) {
win = new BrowserWindow({
width: 1400, height: 950, minWidth: 760, minHeight: 560,
title: "Frame Control", backgroundColor: BG, show: false,
@@ -450,7 +609,10 @@ function createWindow() {
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
preload: path.join(__dirname, "preload.js") },
});
win.once("ready-to-show", () => win.show());
win.once("ready-to-show", () => { if (showOnReady) win.show(); });
win.on("close", (event) => {
if (!quitting && tray && loginItem.enabled()) { event.preventDefault(); win.hide(); }
});
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
// External links open in the default browser; the app never navigates away.
win.webContents.setWindowOpenHandler(({ url: target }) => {
@@ -504,6 +666,8 @@ function buildMenu() {
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: () => setUpConnection() },
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(),
enabled: app.isPackaged, click: toggleLoginItem },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
{ type: "separator" },
...(devices.length > 1 ? [{
@@ -552,7 +716,7 @@ if (!app.requestSingleInstanceLock()) {
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
// Windows and Linux start a second instance with the link as an argument.
app.on("second-instance", (_e, argv) => {
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
if (!argv.includes("--hidden")) showWindow();
const link = linkFromArgv(argv);
if (link) openInstallLink(link);
});
@@ -560,12 +724,41 @@ if (!app.requestSingleInstanceLock()) {
if (firstLink) openInstallLink(firstLink);
app.whenReady().then(() => {
registerScheme();
if (loginItem.enabled()) ensureTray();
buildMenu();
createWindow();
scheduleUpdateChecks();
});
app.on("activate", () => { if (!win) createWindow(); });
app.on("activate", showWindow);
app.on("window-all-closed", () => app.quit());
app.on("before-quit", () => { quitting = true; stopServer(); });
process.on("exit", stopServer);
let obsQuitWaiting = false;
let obsQuitReady = false;
app.on("before-quit", (event) => {
quitting = true;
stopServer();
if (!obsSource.child || obsQuitReady) { stopObs(); return; }
event.preventDefault();
if (obsQuitWaiting) return;
obsQuitWaiting = true;
const child = obsSource.child;
let timer;
child.once("close", () => {
clearTimeout(timer);
obsQuitReady = true;
app.quit();
});
stopObs();
timer = setTimeout(() => {
if (child.exitCode === null) {
if (IS_WIN) {
// Only the helper and its SSH child: never enumerate unrelated SSH jobs.
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"],
{ stdio: "ignore", windowsHide: true, detached: true }).unref();
} else child.kill("SIGKILL");
}
obsQuitReady = true;
app.quit();
}, 8000);
});
process.on("exit", () => { stopObs(); stopServer(); });
}
+252
View File
@@ -0,0 +1,252 @@
// "Sign in with Meta" for My Meta library (docs/meta-library.md), the way BSManager's
// "Log in with Meta" works: Meta's own login page in a Frame Control window, then the
// access token cookie (oc_ac_at) that page sets is handed to the local server's
// existing connect path (format check, "who am I" with Meta, 0600 file).
//
// The window has its own persistent session (PARTITION), so the cookie is read only
// from there: never from Frame Control's main window, never from any browser. It has
// no preload and no Node, and it only navigates within Meta's login sites; anything
// else opens in the system browser. Nothing here logs the token, and the page only
// ever gets back the server's answer (connected, display name), never the token.
// Electron is passed in, so app/test/meta-signin.test.js can run it with fakes.
const crypto = require("crypto");
const PARTITION = "persist:meta-signin";
// BSManager's loginUrl (src/main/services/bs-version-download/bs-oculus-downloader.service.ts).
const LOGIN_URL = "https://secure.oculus.com";
const COOKIE = "oc_ac_at";
// Sites the login can go through: the Oculus store web, Meta accounts, Facebook login,
// and Instagram (Meta's login passes through www.instagram.com: seen live, 2026-10-14).
const ALLOWED = ["oculus.com", "meta.com", "facebook.com", "instagram.com"];
const TIMEOUT = 10 * 60 * 1000; // as BSManager
const underDomain = (host, domains) => {
host = String(host || "").replace(/^\./, "").toLowerCase();
return domains.some((d) => host === d || host.endsWith("." + d));
};
// Plain HTTPS on the default port, to one of the login sites.
function allowedUrl(raw) {
let u;
try { u = new URL(String(raw)); } catch { return false; }
if (u.protocol !== "https:" || u.username || u.password || (u.port && u.port !== "443")) return false;
return underDomain(u.hostname, ALLOWED);
}
// Frames inside the page: any HTTPS address (Meta's login can embed captcha and CDN
// frames from other sites), or an empty frame; never data:, blob:, javascript:, http:,
// file: or any other scheme. Frames can't reach the cookie read or IPC (main process only).
function allowedSubframeUrl(raw) {
if (/^about:(?:blank|srcdoc)$/.test(String(raw))) return true;
let u;
try { u = new URL(String(raw)); } catch { return false; }
return u.protocol === "https:" && !u.username && !u.password;
}
const onOculus = (raw) => { try { return underDomain(new URL(raw).hostname, ["oculus.com"]); } catch { return false; } };
// A cheap first look, so a signed-out or app value is never sent anywhere; the server's
// check_token (ui/frame_meta.py) has the final word.
function looksLikeUserToken(v) {
return typeof v === "string" && /^(?:OC|FRL)[A-Za-z0-9+/=_-]{18,4093}$/.test(v)
&& !/^OC\d{15}$/.test(v) && /[a-z]/.test(v);
}
const fingerprint = (v) => crypto.createHash("sha256").update(v).digest("hex");
// Anything token-shaped out of a message, in case a server error ever carried one.
function scrub(text, token) {
let s = String(text == null ? "" : text);
if (token) s = s.split(token).join("<redacted>");
return s.replace(/(?:OC|FRL)[A-Za-z0-9+/=_%|:-]{8,}/g, "<redacted>").slice(0, 600);
}
// deps: { BrowserWindow, session, shell, parent() -> BrowserWindow|null, timeout,
// connect(token, attempt) -> Promise<{status, data}> (the server's connect action),
// cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) }
function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) {
let active = null; // { win, promise, finish }
const pending = new Set(); // cancels still to reach the server, from flows already finished
let signingOut = null; // the sign-out under way; no sign-in starts while it runs
const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment.";
const openOutside = (target) => {
if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target));
};
function signIn() {
if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT });
if (active) { active.win.focus(); return active.promise; }
const ses = session.fromPartition(PARTITION);
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
ses.setPermissionCheckHandler(() => false);
// Electron adds its own name and the app's to the user agent; send a plain Chrome one.
if (ses.getUserAgent && ses.setUserAgent) {
ses.setUserAgent(ses.getUserAgent().replace(/\s(?:Electron|frame-control|Frame Control)\/\S+/g, ""));
}
const p = parent();
const win = new BrowserWindow({
width: 520, height: 760, title: "Sign in with Meta – Frame Control", autoHideMenuBar: true,
...(p ? { parent: p } : {}),
webPreferences: { partition: PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true,
nodeIntegrationInSubFrames: false, webviewTag: false, safeDialogs: true, devTools: false },
});
const wc = win.webContents;
let resolve;
const promise = new Promise((r) => { resolve = r; });
let done = false, busy = false, again = false, lastError = null, blocked = null;
const tried = new Set(); // fingerprints of values already sent, so each goes once
// The server refuses to save a connect from an attempt this window gave up on.
const attemptId = crypto.randomUUID();
const onCookie = (_e, cookie, _cause, removed) => {
if (!removed && cookie && cookie.name === COOKIE && underDomain(cookie.domain, ["oculus.com"])) attempt();
};
const timer = setTimeout(() => finish({ ok: false, error: "Sign-in timed out after 10 minutes. Try again." }), timeout);
if (timer.unref) timer.unref();
// Stops cookie intake first, then the window (destroy: a page's beforeunload can't
// keep it open), and answers the page. Unless it connected, and if it sent a token, it
// then has the server drop this attempt: only once the connect under way has settled,
// so the server always sees the cancel after the connect (and deletes the token if
// that connect saved it). The returned promise settles when that is done.
let finished = null;
let inflight = null; // the last connect sent
function finish(result) {
if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed"
done = true;
ses.cookies.removeListener("changed", onCookie);
clearTimeout(timer);
if (active && active.win === win) active = null;
if (!win.isDestroyed()) win.destroy();
resolve(result);
if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel
finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {});
pending.add(finished);
finished.then(() => pending.delete(finished));
return finished;
}
async function attempt() {
if (done) return;
if (busy) { again = true; return; }
busy = true;
try {
do {
again = false;
// Only this window's own session, and only Meta's Oculus cookie.
const cookies = await ses.cookies.get({ name: COOKIE });
const c = cookies.find((k) => underDomain(k.domain, ["oculus.com"]) && looksLikeUserToken(k.value));
if (!c || done) continue;
const key = fingerprint(c.value);
if (tried.has(key)) continue;
tried.add(key);
let r;
try {
inflight = connect(c.value, attemptId);
r = await inflight;
} catch {
lastError = "Frame Control's local server didn't answer.";
continue;
}
if (done) return;
const d = (r && r.data) || {};
if (r && r.status === 200 && d.connected) {
finish({ ok: true, connected: true, user: d.user && d.user.name ? { name: scrub(d.user.name, c.value).slice(0, 80) } : null,
message: scrub(d.message || "Connected your Meta account (Experimental)", c.value) });
return;
}
lastError = scrub(d.error || `HTTP ${r && r.status}`, c.value);
} while (again && !done);
} catch {
// The session went away (the window closed); nothing to do.
} finally {
busy = false;
}
}
// Navigation stays on Meta's login sites. A link elsewhere opens in the system
// browser; a redirect elsewhere is just stopped (it may carry sign-in data).
// Every frame: the window itself stays on the list; frames inside it may load any
// HTTPS page (captchas), and one going anywhere else is just stopped.
wc.on("will-navigate", (e, target) => { if (!allowedUrl(target)) { e.preventDefault(); openOutside(target); } });
// A stopped page change in the window itself says where it went (the host only), so
// closing the stuck window explains why instead of "closed before you signed in".
const stopped = (to) => {
let host = "";
try { host = new URL(String(to)).hostname; } catch { /* not an address */ }
blocked = `Meta's sign-in went to ${scrub(host || "an address", null).slice(0, 80)}, which Frame Control doesn't allow.`;
};
wc.on("will-frame-navigate", (e) => {
if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return;
e.preventDefault();
if (e.isMainFrame) stopped(e.url);
});
// Redirects fire for frames too: the same rule per frame (details on the event in
// current Electron, positional arguments in older ones; unknown counts as the window).
wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => {
const to = e && e.url !== undefined ? e.url : target;
const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false;
if (main ? allowedUrl(to) : allowedSubframeUrl(to)) return;
e.preventDefault();
if (main) stopped(to);
});
// A page's beforeunload never keeps the window open.
wc.on("will-prevent-unload", (e) => e.preventDefault());
wc.setWindowOpenHandler(({ url: target }) => {
if (allowedUrl(target)) wc.loadURL(target).catch(() => {});
else openOutside(target);
return { action: "deny" };
});
// Signed in: the cookie changes, or the window lands back on Meta's store site.
ses.cookies.on("changed", onCookie);
wc.on("did-navigate", (_e, target) => { blocked = null; if (onOculus(target)) attempt(); }); // it recovered
wc.on("did-navigate-in-page", (_e, target) => { if (onOculus(target)) attempt(); });
win.on("closed", () => finish({ ok: false, cancelled: true,
error: lastError ? `The sign-in window closed before Frame Control could connect. ${lastError}`
: blocked ? `The sign-in window was closed before you signed in. ${blocked}`
: "The sign-in window was closed before you signed in." }));
active = { win, promise, finish };
wc.loadURL(LOGIN_URL).catch(() => {});
return promise;
}
// Signs out of Meta in Frame Control: stops any sign-in under way (and has the server
// refuse to save it), destroys the window, then clears everything
// the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh.
// One at a time (a second call joins the first), and no sign-in can start meanwhile.
// It goes round until no window is open and no connect or cancel is left, so nothing
// started before it can set a token after it (or after the Disconnect that follows).
function signOut() {
if (!signingOut) {
signingOut = (async () => {
for (;;) {
if (active) {
const { win, finish } = active;
await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." });
// Clear only once nothing can write to the session again.
if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again.");
} else if (pending.size) {
// A connect under way settles and is cancelled before the caller (Disconnect)
// deletes the account, so it can't be saved afterwards.
await Promise.all([...pending]);
} else {
break;
}
}
const ses = session.fromPartition(PARTITION);
await ses.clearStorageData();
await ses.clearCache();
if (ses.clearAuthCache) await ses.clearAuthCache();
return true;
})().finally(() => { signingOut = null; });
}
return signingOut;
}
return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut };
}
module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED };
+59
View File
@@ -0,0 +1,59 @@
// Own one Python relay. Async preparation can be cancelled before a process exists.
const ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
function createObsSource({ prepare, spawn, onState = () => {}, supported = true }) {
let child = null, attempt = 0;
let state = { state: 'idle', alias: '', port: 15099, error: '', supported };
const status = () => ({ ...state });
function set(next, alias = state.alias, port = state.port, error = '') {
state = { state: next, alias, port, error, supported };
onState(status()); return status();
}
function stop() {
++attempt;
if (!child) return set('idle');
set('stopping');
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
return status();
}
async function start(alias, port = 15099) {
if (!supported) return status();
if (!ALIAS.test(alias || '')) throw new Error('Select a valid headset first.');
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('OBS port must be a whole number from 1024 to 65535.');
if (child || state.state === 'starting') return status();
const id = ++attempt;
set('starting', alias, port);
try {
const { executable, args = [], env } = await prepare(alias);
if (id !== attempt) return status();
const proc = spawn(executable, [...args, '--frame', alias, '--port', String(port), '--control-stdin'],
{ env, stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
child = proc;
// The helper may exit between a click and closing its control pipe.
proc.stdin.on('error', () => {});
let output = '';
proc.stderr.on('data', chunk => {
output = (output + chunk.toString('utf8')).slice(-2048);
if (child === proc && state.state === 'starting' && output.includes('Frame Control OBS stream is ready'))
set('running', alias, port);
});
proc.once('error', error => {
if (child !== proc) return;
child = null; set('failed', alias, port, `Could not start OBS source: ${error.message}`);
});
// close follows stderr EOF, so the final failure message is available.
proc.once('close', (code, signal) => {
if (child !== proc) return;
const stopping = state.state === 'stopping';
child = null;
set(stopping ? 'idle' : 'failed', alias, port,
stopping ? '' : `OBS source stopped (${signal || code}). ${output.trim().split('\n').pop() || 'Check SteamVR and SSH.'}`);
});
return status();
} catch (error) {
if (id !== attempt) return status();
return set('failed', alias, port, error.message);
}
}
return { start, stop, status, get child() { return child; } };
}
module.exports = { createObsSource };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.0",
"version": "0.4.43",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.0",
"version": "0.4.43",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+28 -8
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.0",
"version": "0.4.43",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -9,10 +9,11 @@
"scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
"dist:mac:x64": "FRAME_MAC_ARCH=x86_64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac x64 && electron-builder --mac --x64 --publish never"
},
"devDependencies": {
"electron": "^44.4.5",
@@ -29,15 +30,20 @@
]
}
],
"afterPack": "build/check-resources.js",
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"main.js",
"obs-source.js",
"preload.js",
"install-link.js",
"updater.js",
"preferences.js",
"login-item.js",
"meta-signin.js",
"package.json",
"build/icon.png"
],
@@ -49,7 +55,9 @@
"*.py",
"*.html",
"*.js",
"telemetry.json"
"*.css",
"telemetry.json",
"framey-compatibility.json"
]
},
{
@@ -64,7 +72,10 @@
"from": "../scripts",
"to": "scripts",
"filter": [
"*.sh"
"*.sh",
"obs-headset-source.py",
"frame_power_sample.py",
"frame_power_report.py"
]
},
{
@@ -81,7 +92,7 @@
"to": "frame/openxr-compat",
"filter": [
"XrApiLayer_FRAME_compat.json",
"prebuilt/**/*.so"
"prebuilt/**/*.so.gz"
]
},
{
@@ -194,7 +205,16 @@
"zip"
],
"icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}"
"artifactName": "Frame-Control-win-${arch}.${ext}",
"extraResources": [
{
"from": "build/desktop/${os}-${arch}",
"to": "desktop/bundle",
"filter": [
"**/*"
]
}
]
},
"nsis": {
"oneClick": false,
+27
View File
@@ -0,0 +1,27 @@
// Client choices live outside the local server's changing URL/origin.
const fs = require("node:fs");
const path = require("node:path");
const KEYS = ["mvSeparate", "padUsed", "pcView"];
const valid = value => value === "0" || value === "1";
function preferenceStore(file) {
function read() {
try {
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
} catch { return {}; }
}
function write(key, value) {
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
const saved = { ...read(), [key]: value };
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
fs.renameSync(tmp, file);
return true;
}
return { read, write };
}
module.exports = { preferenceStore };
+28 -5
View File
@@ -1,18 +1,35 @@
// Lets the page read this computer's clipboard through Electron, so sending it
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// Lets the page read and write this computer's clipboard through Electron, so sending
// it to the Frame, and copying the Frame's back, needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
// It can open Set Up Connection when the headset can't be reached, and keeps the
// Frame menu's list of headsets up to date.
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
// It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first.
// And it passes update state both ways: see app/updater.js.
// And it passes update state both ways: see app/updater.js. It opens the Sign in
// with Meta window and signs out of it (app/meta-signin.js).
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
preferences: {
read: () => ipcRenderer.invoke("preferences:read"),
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
},
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
obs: {
status: () => ipcRenderer.invoke("obs:status"),
start: (port) => ipcRenderer.invoke("obs:start", port),
stop: () => ipcRenderer.invoke("obs:stop"),
onState: (cb) => {
ipcRenderer.removeAllListeners("obs:state");
ipcRenderer.on("obs:state", (_e, state) => cb(state));
},
},
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
@@ -37,6 +54,12 @@ contextBridge.exposeInMainWorld("frameApp", {
ipcRenderer.removeAllListeners("report:open");
ipcRenderer.on("report:open", () => cb());
},
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
// answer is the server's (connected, display name), never the token.
meta: {
signIn: (acknowledged) => ipcRenderer.invoke("meta:signin", acknowledged === true),
signOut: () => ipcRenderer.invoke("meta:signout"),
},
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
+57
View File
@@ -0,0 +1,57 @@
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { createLoginItem } = require("../login-item");
test("Linux login toggle creates and removes only its own desktop entry", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-login-test-"));
try {
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root },
executable: "/opt/Frame Control/frame-control" });
assert.equal(login.enabled(), false);
assert.equal(login.set(true), true);
const body = fs.readFileSync(login.file, "utf8");
assert.match(body, /^# Managed by Frame Control;/);
assert.match(body, /Exec="\/opt\/Frame Control\/frame-control" --hidden/);
assert.equal(fs.statSync(login.file).mode & 0o777, 0o600);
assert.equal(login.set(false), false);
assert.equal(fs.existsSync(login.file), false);
fs.mkdirSync(path.dirname(login.file), { recursive: true });
fs.writeFileSync(login.file, "[Desktop Entry]\nName=My custom launcher\n");
assert.throws(() => login.set(true), /different autostart entry/);
assert.equal(login.set(false), false);
assert.match(fs.readFileSync(login.file, "utf8"), /My custom launcher/);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
test("AppImage autostart launches the AppImage, with safe desktop escaping", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-appimage-test-"));
try {
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root,
APPIMAGE: '/home/user/Frame Control 100%.AppImage' },
executable: "/tmp/.mount_extracted/AppRun" });
login.set(true);
assert.match(fs.readFileSync(login.file, "utf8"), /Exec="\/home\/user\/Frame Control 100%%\.AppImage" --hidden/);
login.set(false);
} finally { fs.rmSync(root, { recursive: true, force: true }); }
});
for (const platform of ["win32", "darwin"]) {
test(`${platform} login toggle uses the OS API and starts hidden`, () => {
let openAtLogin = false;
const calls = [];
const electronApp = {
getLoginItemSettings: () => ({ openAtLogin }),
setLoginItemSettings: opts => { calls.push(opts); openAtLogin = opts.openAtLogin; },
};
const login = createLoginItem({ platform, electronApp });
assert.equal(login.enabled(), false);
assert.equal(login.set(true), true);
assert.deepEqual(calls.at(-1).args, ["--hidden"]);
assert.equal(calls.at(-1).openAsHidden, true);
assert.equal(login.set(false), false);
assert.deepEqual(calls.at(-1).args, []);
});
}
+578
View File
@@ -0,0 +1,578 @@
// Run: node --test app/test/
// "Sign in with Meta" (app/meta-signin.js) with fake Electron windows, sessions and cookies.
const nodeTest = require("node:test");
// A broken flow can leave a sign-in waiting forever; fail it instead of hanging.
const test = (name, fn) => nodeTest(name, { timeout: 5000 }, fn);
const assert = require("node:assert");
const { EventEmitter } = require("node:events");
const { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE } = require("../meta-signin");
const TOKEN = "OCAbcdefghijklmnopQRSTUVwxyz0123456789abcdEFGH";
const OTHER = "OCzyxwvutsrqponmlkJIHGFEDcba9876543210zyxwVUTS";
class FakeCookies extends EventEmitter {
constructor() { super(); this.store = []; this.reads = 0; }
async get(filter) {
this.reads++;
return this.store.filter((c) => !filter.name || c.name === filter.name);
}
set(cookie) { // as the page would: store it, then tell listeners
this.store = this.store.filter((c) => !(c.name === cookie.name && c.domain === cookie.domain)).concat(cookie);
this.emit("changed", {}, cookie, "explicit", false);
}
}
class FakeSession {
constructor(name, log) { this.name = name; this.log = log; this.cookies = new FakeCookies(); this.cleared = []; this.permission = null; }
setPermissionRequestHandler(fn) { this.permission = fn; }
setPermissionCheckHandler(fn) { this.permissionCheck = fn; }
getUserAgent() { return "Mozilla/5.0 Chrome/140.0 frame-control/0.4.4 Electron/44.4.5 Safari/537.36"; }
setUserAgent(ua) { this.ua = ua; }
async clearStorageData(opts) { this.log.push("clear"); this.cleared.push(["storage", opts]); }
async clearCache() { this.cleared.push(["cache"]); }
async clearAuthCache() { this.cleared.push(["auth"]); }
}
function fakes({ timeout } = {}) {
const log = []; // the order things happened in
const sessions = new Map();
const defaultSession = new FakeSession("default", log);
const session = {
defaultSession,
fromPartition(name) {
if (!sessions.has(name)) sessions.set(name, new FakeSession(name, log));
return sessions.get(name);
},
};
const windows = [];
class BrowserWindow extends EventEmitter {
constructor(opts) {
super();
this.opts = opts;
this.destroyed = false;
this.veto = false; // a page whose beforeunload keeps the window open on close()
this.focused = 0;
const wc = new EventEmitter();
wc.loaded = [];
wc.loadURL = async (u) => { wc.loaded.push(u); };
wc.setWindowOpenHandler = (fn) => { wc.openHandler = fn; };
// Deliberately a different session: the code must read the partition's, not guess.
wc.session = defaultSession;
this.webContents = wc;
windows.push(this);
}
isDestroyed() { return this.destroyed; }
focus() { this.focused++; }
close() { if (this.destroyed || this.veto) return; this.destroy(); }
destroy() { if (this.destroyed) return; this.destroyed = true; log.push("destroyed"); this.emit("closed"); }
}
const opened = [];
const shell = { openExternal: (u) => { opened.push(u); } };
const calls = [], attempts = [], cancels = [];
let answer = () => ({ status: 200, data: { connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" } });
const connect = async (token, attempt) => { calls.push(token); attempts.push(attempt); log.push("connect"); return answer(token); };
const cancel = async (attempt) => {
cancels.push(attempt);
log.push(`cancel (cookie listeners: ${session.fromPartition(PARTITION).cookies.listenerCount("changed")})`);
};
const flow = createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent: () => null, ...(timeout ? { timeout } : {}) });
return { flow, session, sessions, windows, opened, calls, attempts, cancels, log, setAnswer: (fn) => { answer = fn; },
meta: () => session.fromPartition(PARTITION) };
}
const tick = () => new Promise((r) => setImmediate(r));
async function settle() { for (let i = 0; i < 10; i++) await tick(); }
function navigate(wc, event, target) {
let prevented = false;
wc.emit(event, { preventDefault: () => { prevented = true; } }, target);
return prevented;
}
test("only plain HTTPS addresses on Meta's login sites are allowed", () => {
for (const ok of ["https://secure.oculus.com", "https://secure.oculus.com/my/profile", "https://auth.meta.com/login/",
"https://www.meta.com/", "https://www.facebook.com/login.php", "https://oculus.com:443/",
"https://www.instagram.com/accounts/login/"]) {
assert.ok(allowedUrl(ok), ok);
}
for (const bad of ["http://secure.oculus.com/", "https://oculus.com.evil.example/", "https://evil-oculus.com/",
"https://notmeta.com/", "https://example.com/", "javascript:alert(1)", "file:///etc/passwd",
"https://secure.oculus.com:8443/", "https://user:pw@secure.oculus.com/", "https://evil.example/?u=https://www.meta.com",
"data:text/html,hi", "not a url"]) {
assert.ok(!allowedUrl(bad), bad);
}
});
test("the window is isolated: its own partition, no preload, no Node, sandboxed, on Meta's login page", async () => {
const f = fakes();
f.flow.signIn();
const [w] = f.windows;
const wp = w.opts.webPreferences;
assert.strictEqual(wp.partition, PARTITION);
assert.strictEqual(PARTITION, "persist:meta-signin");
assert.strictEqual(wp.contextIsolation, true);
assert.strictEqual(wp.nodeIntegration, false);
assert.strictEqual(wp.sandbox, true);
assert.strictEqual(wp.preload, undefined);
assert.strictEqual(wp.devTools, false);
assert.deepStrictEqual(w.webContents.loaded, [LOGIN_URL]);
assert.strictEqual(LOGIN_URL, "https://secure.oculus.com");
// Permission prompts (camera, notifications...) are refused.
let granted = null;
f.meta().permission(null, "media", (v) => { granted = v; });
assert.strictEqual(granted, false);
assert.strictEqual(f.meta().permissionCheck(null, "notifications", "https://secure.oculus.com"), false);
assert.ok(!/Electron|frame-control/.test(f.meta().ua));
w.close();
});
test("navigation stays on Meta's sites; other links open in the system browser", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(navigate(wc, "will-navigate", "https://auth.meta.com/login/"), false);
assert.strictEqual(navigate(wc, "will-navigate", "https://www.facebook.com/login.php"), false);
assert.strictEqual(navigate(wc, "will-navigate", "https://example.com/help"), true);
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
// A redirect elsewhere is stopped and not opened anywhere (it may carry sign-in data).
assert.strictEqual(navigate(wc, "will-redirect", "https://evil.example/cb?x=1"), true);
assert.strictEqual(navigate(wc, "will-redirect", "https://auth.meta.com/next"), false);
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
// New windows: never created. Meta's own load in this window; others go outside; non-web schemes nowhere.
assert.deepStrictEqual(wc.openHandler({ url: "https://www.meta.com/legal/terms/" }), { action: "deny" });
assert.deepStrictEqual(wc.openHandler({ url: "https://example.org/" }), { action: "deny" });
assert.deepStrictEqual(wc.openHandler({ url: "file:///etc/passwd" }), { action: "deny" });
await settle();
assert.deepStrictEqual(wc.loaded, [LOGIN_URL, "https://www.meta.com/legal/terms/"]);
assert.deepStrictEqual(f.opened, ["https://example.com/help", "https://example.org/"]);
f.windows[0].close();
});
test("the cookie is read only from the sign-in partition, only for Meta's Oculus domain", async () => {
const f = fakes();
// The same cookie elsewhere: the default session and another partition. Never read.
f.session.defaultSession.cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
f.session.fromPartition("persist:other").cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
const done = f.flow.signIn();
const w = f.windows[0];
// A cookie with the right name on another site, and a signed-out value: ignored.
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: OTHER });
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: "OC123456789012345" });
w.webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
await settle();
assert.deepStrictEqual(f.calls, []);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.deepStrictEqual(f.calls, [TOKEN]);
assert.strictEqual(r.ok, true);
assert.strictEqual(f.session.defaultSession.cookies.reads, 0);
assert.strictEqual(f.session.fromPartition("persist:other").cookies.reads, 0);
assert.ok(f.sessions.get(PARTITION).cookies.reads > 0);
});
test("landing on the logged-in page picks up a cookie set earlier", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.store.push({ name: COOKIE, domain: "secure.oculus.com", value: TOKEN }); // no change event
f.windows[0].webContents.emit("did-navigate", {}, "https://auth.meta.com/login/"); // not Meta's store site yet
await settle();
assert.deepStrictEqual(f.calls, []);
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
assert.strictEqual((await done).ok, true);
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("after a connect the window closes, and the page gets the answer, never the token", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.ok(f.windows[0].isDestroyed());
assert.ok(!f.flow.isOpen());
assert.deepStrictEqual(r, { ok: true, connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" });
assert.ok(!JSON.stringify(r).includes(TOKEN));
// The cookie listener is gone: a later change sends nothing.
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("a token the server refuses keeps the window open, is sent once, and the next one connects", async () => {
const f = fakes();
f.setAnswer((t) => t === TOKEN ? { status: 400, data: { error: "Meta didn't accept this token." } }
: { status: 200, data: { connected: true, user: null } });
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile"); // same value again
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
assert.ok(!f.windows[0].isDestroyed());
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
const r = await done;
assert.deepStrictEqual(f.calls, [TOKEN, OTHER]);
assert.strictEqual(r.ok, true);
assert.strictEqual(r.user, null);
});
test("the token is never logged, and an error that echoed it is scrubbed", async () => {
const seen = [];
const names = ["log", "info", "warn", "error", "debug", "trace"];
const saved = Object.fromEntries(names.map((n) => [n, console[n]]));
const writes = [process.stdout.write, process.stderr.write];
for (const n of names) console[n] = (...a) => { seen.push(a.map(String).join(" ")); };
process.stdout.write = (c, ...rest) => { seen.push(String(c)); return writes[0].call(process.stdout, c, ...rest); };
process.stderr.write = (c, ...rest) => { seen.push(String(c)); return writes[1].call(process.stderr, c, ...rest); };
let r;
try {
const f = fakes();
f.setAnswer((t) => ({ status: 500, data: { error: `RuntimeError: bad ${t} and ${t.slice(0, 12)}` } }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close(); // the user gives up
r = await done;
} finally {
for (const n of names) console[n] = saved[n];
[process.stdout.write, process.stderr.write] = writes;
}
assert.strictEqual(r.ok, false);
assert.strictEqual(r.cancelled, true);
assert.ok(!r.error.includes(TOKEN) && !r.error.includes(TOKEN.slice(0, 12)), r.error);
assert.match(r.error, /<redacted>/);
assert.ok(!seen.some((s) => s.includes(TOKEN.slice(0, 12))), "token in output");
assert.strictEqual(scrub(`x FRL${"a".repeat(30)} y`), "x <redacted> y");
});
test("closing the window before signing in reports it, and a second click reuses the open window", async () => {
const f = fakes();
const a = f.flow.signIn();
const b = f.flow.signIn();
assert.strictEqual(a, b);
assert.strictEqual(f.windows.length, 1);
assert.strictEqual(f.windows[0].focused, 1);
f.windows[0].close();
const r = await a;
assert.deepStrictEqual(r, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
assert.deepStrictEqual(f.calls, []);
});
test("disconnect's sign-out closes the window and clears only the sign-in partition", async () => {
const f = fakes();
f.session.fromPartition("persist:other");
const pending = f.flow.signIn();
await f.flow.signOut();
const r = await pending;
assert.strictEqual(r.ok, false);
assert.ok(f.windows[0].isDestroyed());
assert.deepStrictEqual(f.meta().cleared, [["storage", undefined], ["cache"], ["auth"]]);
assert.deepStrictEqual(f.session.defaultSession.cleared, []);
assert.deepStrictEqual(f.session.fromPartition("persist:other").cleared, []);
// The next sign-in starts with a new window.
f.flow.signIn();
assert.strictEqual(f.windows.length, 2);
f.windows[1].close();
});
test("the first look at a cookie value matches the server's token rules", () => {
assert.ok(looksLikeUserToken(TOKEN));
assert.ok(looksLikeUserToken("FRL" + "aB3".repeat(10)));
for (const bad of [undefined, "", "OC123456789012345", "OC|123|abc", "OCabc%2Fdef" + "x".repeat(20), "OCshort",
"XY" + "a".repeat(30), "OC" + "A".repeat(30)]) {
assert.ok(!looksLikeUserToken(bad), String(bad));
}
});
function frameNav(wc, url, isMainFrame) {
let prevented = false;
wc.emit("will-frame-navigate", { url, isMainFrame, preventDefault: () => { prevented = true; } });
return prevented;
}
test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
// Frames: captcha and CDN frames from other sites load; other schemes are stopped.
for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x",
"https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x",
"https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) {
assert.strictEqual(frameNav(wc, ok, false), false, ok);
}
for (const bad of ["http://www.facebook.com/plugin", "data:text/html,<script>x</script>", "blob:https://evil.example/1",
"javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/",
"chrome://settings", "not a url"]) {
assert.strictEqual(frameNav(wc, bad, false), true, bad);
}
// The window itself: Meta's sites only.
assert.strictEqual(frameNav(wc, "about:blank", true), true);
assert.strictEqual(frameNav(wc, "https://evil.example/", true), true);
assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true);
assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false);
assert.deepStrictEqual(f.opened, []); // stopped, never opened outside
f.windows[0].destroy();
});
function redirect(wc, url, isMainFrame, style) {
let prevented = false;
const e = { preventDefault: () => { prevented = true; } };
if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame }));
else wc.emit("will-redirect", e, url, false, isMainFrame);
return prevented;
}
test("redirects follow the same rule per frame", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
for (const style of ["details", "positional"]) {
// A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style);
assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style);
assert.strictEqual(redirect(wc, "data:text/html,<b>x</b>", false, style), true, style);
assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style);
assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style);
// The window: Meta's sites only.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style);
assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style);
}
// Without frame information it is treated as the window.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true);
assert.deepStrictEqual(f.opened, []);
f.windows[0].destroy();
});
test("a redirect the window stops is named when the window is closed", async () => {
const f = fakes();
const p = f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(redirect(wc, "https://www.instagram.com/x?next=1", true, "details"), false); // Meta's login goes here
assert.strictEqual(redirect(wc, "https://login.evil.example/cb?code=SECRET", true, "details"), true);
f.windows[0].close();
const r = await p;
assert.strictEqual(r.ok, false);
assert.match(r.error, /went to login\.evil\.example, which Frame Control doesn't allow/);
assert.doesNotMatch(r.error, /SECRET|code=/);
});
test("a stopped redirect is forgotten once the window loads a page again", async () => {
const f = fakes();
const p = f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(redirect(wc, "https://login.evil.example/cb", true, "details"), true);
wc.emit("did-navigate", {}, "https://auth.meta.com/login/"); // the user carried on
f.windows[0].close();
assert.deepStrictEqual(await p, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
});
test("a page's beforeunload can't keep the window open", async () => {
const f = fakes();
const done = f.flow.signIn();
const w = f.windows[0];
let prevented = false;
w.webContents.emit("will-prevent-unload", { preventDefault: () => { prevented = true; } });
assert.ok(prevented);
// Even if close() would be vetoed, finishing destroys the window.
w.veto = true;
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
assert.strictEqual((await done).ok, true);
assert.ok(w.isDestroyed());
});
test("closing during a connect: the cancel goes to the server only after that connect settles", async () => {
for (const outcome of ["saved", "refused", "failed"]) {
const f = fakes();
let release, fail;
f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
f.windows[0].close(); // the user gives up while the server and Meta are answering
const r = await done; // the page hears at once
assert.strictEqual(r.ok, false);
await settle();
assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled
if (outcome === "saved") release({ status: 200, data: { connected: true } });
else if (outcome === "refused") release({ status: 400, data: { error: "no" } });
else fail(new Error("socket hang up"));
await settle();
// The same attempt id the connect carried, after it settled and after cookie intake stopped.
assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome);
assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/);
assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome);
assert.deepStrictEqual(f.calls, [TOKEN]);
}
});
test("closing before any connect needs no cancel", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable
await settle();
f.windows[0].close();
assert.strictEqual((await done).ok, false);
await f.flow.signOut();
await settle();
assert.deepStrictEqual(f.calls, []);
assert.deepStrictEqual(f.cancels, []);
});
test("a timeout during a connect cancels after it settles", async () => {
const f = fakes({ timeout: 50 });
let release;
f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.match(r.error, /timed out/);
assert.ok(f.windows[0].isDestroyed());
assert.deepStrictEqual(f.cancels, []);
release({ status: 400, data: { error: "no" } });
await settle();
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.log.length = 0;
let signedOut = false;
const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one
assert.strictEqual((await done).ok, false);
await settle();
assert.strictEqual(signedOut, false); // still waiting for the connect
assert.deepStrictEqual(f.cancels, []);
release({ status: 200, data: { connected: true } }); // it saved, just now
await out;
// Only now would the page post its disconnect: the cancel (which deletes what that
// connect saved) has reached the server, and the session is cleared.
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("a later disconnect still waits for a closed window's connect", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close();
await done;
let signedOut = false;
const out = f.flow.signOut().then(() => { signedOut = true; });
await settle();
assert.strictEqual(signedOut, false);
release({ status: 400, data: { error: "no" } });
await out;
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("nothing sent means nothing to cancel; sign-out destroys the window, cancels, then clears", async () => {
const f = fakes();
// Nothing sent: closing cancels nothing.
const a = f.flow.signIn();
f.windows[0].close();
await a;
assert.deepStrictEqual(f.cancels, []);
// Something sent, then signed out: the attempt is cancelled before the session is cleared.
f.setAnswer(() => ({ status: 400, data: { error: "Meta didn't accept this token." } }));
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[1].veto = true; // and its page tries to stay open
f.log.length = 0;
await f.flow.signOut();
assert.strictEqual((await b).ok, false);
assert.ok(f.windows[1].isDestroyed());
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
// After sign-out a new cookie in the partition sends nothing.
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("each sign-in has its own attempt id", async () => {
const f = fakes();
f.setAnswer(() => ({ status: 400, data: { error: "no" } }));
const a = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close();
await a;
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
f.windows[1].close();
await b;
await settle();
assert.strictEqual(f.attempts.length, 2);
assert.notStrictEqual(f.attempts[0], f.attempts[1]);
assert.deepStrictEqual(f.cancels, f.attempts);
});
test("no sign-in can start while a sign-out waits on an earlier connect", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const first = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
const out = f.flow.signOut(); // Disconnect pressed while that connect is under way
assert.strictEqual((await first).ok, false);
assert.ok(f.flow.isSigningOut());
// The page answered, so the user tries again at once: refused, no window, nothing sent.
const second = await f.flow.signIn();
assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." });
assert.strictEqual(f.windows.length, 1);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
// A second sign-out joins the first.
assert.strictEqual(f.flow.signOut(), out);
release({ status: 200, data: { connected: true } });
await out;
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1);
assert.ok(!f.flow.isSigningOut());
// Once it is done, signing in works again.
f.setAnswer(() => ({ status: 200, data: { connected: true } }));
const third = f.flow.signIn();
assert.strictEqual(f.windows.length, 2);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
assert.strictEqual((await third).ok, true);
});
test("sign-out goes round until no window and no connect or cancel is left", async () => {
const f = fakes();
let releaseA;
f.setAnswer(() => new Promise((r) => { releaseA = r; }));
const a = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close(); // window A closed; its cancel waits on its connect
await a;
// Window B is open, with its own connect under way, when sign-out starts.
let releaseB;
f.setAnswer(() => new Promise((r) => { releaseB = r; }));
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
let finished = false;
const out = f.flow.signOut().then(() => { finished = true; });
await b;
releaseB({ status: 400, data: { error: "no" } });
await settle();
assert.strictEqual(finished, false); // A's connect is still under way
releaseA({ status: 200, data: { connected: true } });
await out;
assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort());
assert.strictEqual(f.attempts.length, 2);
assert.strictEqual(f.log[f.log.length - 1], "clear");
});
+86
View File
@@ -0,0 +1,86 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { createObsSource } = require('../obs-source');
function fixture(prepare) {
const states = [], children = [];
const source = createObsSource({
prepare: prepare || (async () => ({ executable: 'python', args: ['helper.py'], env: {} })),
spawn(executable, args, options) {
const child = new EventEmitter();
child.stderr = new EventEmitter(); child.stdin = new EventEmitter();
child.stdin.end = () => { child.stdin.writableEnded = true; };
children.push({ child, executable, args, options }); return child;
},
onState: s => states.push(s),
});
return { source, states, children };
}
test('Stop during Python discovery cancels the pending start', async () => {
let resolve;
const f = fixture(() => new Promise(r => { resolve = r; }));
const pending = f.source.start('frame', 15100);
assert.equal(f.source.status().state, 'starting');
f.source.stop();
resolve({ executable: 'python', args: ['helper.py'], env: {} });
await pending;
assert.equal(f.children.length, 0);
assert.equal(f.source.status().state, 'idle');
});
test('late stderr cannot undo Stop; closing allows a fresh start on another port', async () => {
const f = fixture(); await f.source.start('frame', 15100);
const { child, args } = f.children[0];
assert.deepEqual(args.slice(-5), ['--frame', 'frame', '--port', '15100', '--control-stdin']);
child.stderr.emit('data', Buffer.from('Frame Control OBS stream is '));
child.stderr.emit('data', Buffer.from('ready\n'));
assert.equal(f.source.status().state, 'running');
f.source.stop();
child.stderr.emit('data', Buffer.from('last ffmpeg warning\n'));
assert.equal(f.source.status().state, 'stopping');
child.emit('close', 0, null);
assert.equal(f.source.status().state, 'idle');
await f.source.start('other-frame', 15101);
assert.equal(f.source.status().port, 15101);
// An old child cannot overwrite the replacement's state.
child.emit('close', 255, null);
assert.equal(f.source.status().state, 'starting');
});
test('concurrent starts spawn once and preparation failures are retryable', async () => {
let attempts = 0;
const f = fixture(async () => {
if (++attempts === 1) throw new Error('Python missing');
return { executable: 'python', args: [], env: {} };
});
assert.equal((await f.source.start('frame')).state, 'failed');
assert.match(f.source.status().error, /Python missing/);
await Promise.all([f.source.start('frame'), f.source.start('frame')]);
assert.equal(f.children.length, 1);
});
test('invalid ports and aliases cannot spawn; errors and closed pipes remain visible', async () => {
const f = fixture();
for (const port of [0, 1023, 65536, 15099.5, '15100']) {
await assert.rejects(f.source.start('frame', port), /port/i);
}
await assert.rejects(f.source.start('-bad'), /headset/i);
assert.equal(f.children.length, 0);
await f.source.start('frame');
const { child } = f.children[0];
child.stdin.emit('error', Object.assign(new Error('closed pipe'), { code: 'EPIPE' }));
child.stderr.emit('data', Buffer.from('network lost\n'));
child.emit('close', 255, null);
assert.equal(f.source.status().state, 'failed');
assert.match(f.source.status().error, /network lost/);
});
test('an unverified host hides the controls and cannot spawn a relay', async () => {
let spawned = false;
const source = createObsSource({ supported: false, prepare: async () => ({}), spawn: () => { spawned = true; } });
assert.equal(source.status().supported, false);
await source.start('frame');
assert.equal(spawned, false);
});
+68
View File
@@ -0,0 +1,68 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { preferenceStore } = require("../preferences");
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return path.join(dir, "client-preferences.json");
}
test("desktop packaging includes every local application module dependency", () => {
const root = path.resolve(__dirname, "..");
const packaged = new Set(require("../package.json").build.files);
for (const file of ["main.js", "preload.js"]) {
const source = fs.readFileSync(path.join(root, file), "utf8");
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
const target = match[1].slice(2) + ".js";
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
}
}
});
test("client choices survive a new native session and local server origin", t => {
const file = fixture(t);
const first = preferenceStore(file);
assert.deepEqual(first.read(), {});
first.write("mvSeparate", "0");
first.write("padUsed", "1");
// A new process/session reads the same client store, regardless of its URL.
const second = preferenceStore(file);
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
second.write("mvSeparate", "1");
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
});
test("only the non-sensitive binary choices can enter the native store", t => {
const file = fixture(t), store = preferenceStore(file);
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
assert.throws(() => store.write(key, value), /Invalid preference/);
}
assert.equal(fs.existsSync(file), false);
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
assert.deepEqual(store.read(), { mvSeparate: "0" });
store.write("padUsed", "1");
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
});
test("a missing or damaged preference file retains usable defaults", t => {
const file = fixture(t), store = preferenceStore(file);
assert.deepEqual(store.read(), {});
for (const bad of ["{broken", "null", "[]", '"text"']) {
fs.writeFileSync(file, bad);
assert.deepEqual(store.read(), {});
}
store.write("padUsed", "1");
assert.deepEqual(store.read(), { padUsed: "1" });
});
test("the PC in the headset opt-in is remembered and off unless saved", t => {
const file = fixture(t), store = preferenceStore(file);
assert.equal(store.read().pcView, undefined);
store.write("pcView", "1");
assert.deepEqual(preferenceStore(file).read(), { pcView: "1" });
assert.throws(() => store.write("pcView", "on"), /Invalid preference/);
});
+21
View File
@@ -51,6 +51,27 @@ test("update.json assets always download from this repository's release", () =>
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
});
test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
const { fromManifest, fromApi } = require("../updater");
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
assert.strictEqual(page(tagPage), tagPage);
// 0.4.0's real update.json: the draft's address, a 404 once published.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
assert.strictEqual(page(undefined), tagPage);
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
// Paths that normalize to another repository.
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
tagPage);
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
});
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
const { prepare } = require("../updater");
const release = { version: "0.3.1", assets: [] };
+18 -5
View File
@@ -106,12 +106,24 @@ async function getJson(url, headers) {
return JSON.parse(body);
}
// The release page the banner links to. update.json for 0.4.0 carried the draft's
// address (releases/tag/untagged-...), which is dead once the release is published,
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
// normalizes to another repository) before shell.openExternal. So the given page is
// ignored: the link is always this repository's tag page, built from a valid version.
function releasePage(version) {
const v = parseVersion(version);
if (!v) return RELEASES;
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
}
// update.json and the API's release both become { version, notes, page, assets }.
function fromManifest(m) {
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
page: m.page || RELEASES,
const version = String(m.version).replace(/^v/i, "");
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
return { version, notes: String(m.notes || "").slice(0, 4000),
page: releasePage(version),
// Assets always come from this repository's release, whatever the manifest says.
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
size: a.size, digest: a.digest || null })) };
@@ -119,8 +131,9 @@ function fromManifest(m) {
function fromApi(r) {
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
page: r.html_url || RELEASES,
const version = String(r.tag_name || "").replace(/^v/i, "");
return { version, notes: String(r.body || "").slice(0, 4000),
page: releasePage(version),
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
digest: a.digest || null })) };
}
@@ -0,0 +1,311 @@
{
"label": "pc-agent-frame-arm64-hairpin",
"date": "2026-09-28T22:36:19",
"commit": "c5fc552+dirty",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 351,
"frames_drawn": 351,
"frames_shown": 324,
"duration_s": 10.1,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.5,
"n": 351
},
"queue": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 351
},
"encode": {
"p50": 2.0,
"p95": 2.9,
"p99": 3.5,
"n": 351
},
"socket": {
"p50": 0.1,
"p95": 0.4,
"p99": 0.6,
"n": 351
},
"network": {
"p50": 11.6,
"p95": 75.7,
"p99": 130.1,
"n": 351
},
"decode": {
"p50": 0.7,
"p95": 2.1,
"p99": 4.1,
"n": 351
},
"draw": {
"p50": 0.4,
"p95": 0.8,
"p99": 1.1,
"n": 351
},
"present": {
"p50": 0.8,
"p95": 18.1,
"p99": 24.9,
"n": 324
},
"content": {
"p50": 15.1,
"p95": 80.3,
"p99": 134.2,
"n": 351
},
"content_shown": {
"p50": 21.0,
"p95": 71.8,
"p99": 146.3,
"n": 324
}
},
"fps": 34.8,
"fps_shown": 32.0,
"late_pct": 53.41,
"stall_max": 226.8,
"stalls_over_100ms": 7,
"mbps": 0.26,
"keyframes": 6,
"size": "1280x720",
"captured": 607,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 43.3,
"p95": 98.4,
"p99": 110.1,
"n": 18
},
"input_shown_ms": {
"p50": 54.7,
"p95": 98.9,
"p99": 114.0,
"n": 18
},
"input_parts_ms": {
"uplink": {
"p50": 17.1,
"p95": 48.3,
"p99": 53.1,
"n": 18
},
"mac": {
"p50": 12.5,
"p95": 32.9,
"p99": 35.7,
"n": 18
},
"back": {
"p50": 16.8,
"p95": 50.0,
"p99": 55.9,
"n": 18
}
},
"inputs": {
"asked": 18,
"sent": 18,
"seen": 18
},
"timeline": [
{
"t": 0,
"fps": 38,
"mbps": 0.3,
"content_p50": 14.8,
"content_p95": 25.3,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 33,
"mbps": 0.24,
"content_p50": 15.6,
"content_p95": 103.5,
"bitrate": 3845448,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 39,
"mbps": 0.28,
"content_p50": 15.6,
"content_p95": 29.9,
"bitrate": 5283790,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 25,
"mbps": 0.21,
"content_p50": 17.8,
"content_p95": 175.9,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 34,
"mbps": 0.22,
"content_p50": 15.4,
"content_p95": 69.8,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 38,
"mbps": 0.29,
"content_p50": 17.7,
"content_p95": 68.7,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 38,
"mbps": 0.26,
"content_p50": 15.0,
"content_p95": 18.2,
"bitrate": 3091280,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 30,
"mbps": 0.22,
"content_p50": 19.2,
"content_p95": 111.0,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 35,
"mbps": 0.26,
"content_p50": 13.4,
"content_p95": 102.6,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.28,
"content_p50": 12.3,
"content_p95": 20.9,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 3,
"mbps": 0.02,
"content_p50": 20.9,
"content_p95": 24.7,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 80.3,
"input_p50": 43.3,
"input_p95": 98.4,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "acceptable",
"input_p50": "local",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "acceptable"
},
"controller": {
"target": 3442081,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 13.334,
"inFlight": 1,
"slack": 71.738,
"adapt": true
},
"events": [
{
"t": 3.77,
"e": "target 2764800 bit/s; tier 0"
},
{
"t": 7.42,
"e": "target 1725204 bit/s; tier 0"
}
],
"source_fps": 60.1,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
"show_s": 1.88,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
@@ -0,0 +1,302 @@
{
"label": "pc-agent-frame-arm64-final",
"date": "2026-09-28T22:55:27",
"commit": "cd20243",
"config": {
"quality": "balanced",
"mode": "native",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 400,
"frames_drawn": 400,
"frames_shown": 339,
"duration_s": 10.2,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.1,
"p99": 0.2,
"n": 400
},
"queue": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.4,
"n": 400
},
"encode": {
"p50": 2.0,
"p95": 2.7,
"p99": 3.1,
"n": 400
},
"socket": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 400
},
"network": {
"p50": 11.8,
"p95": 25.2,
"p99": 37.8,
"n": 400
},
"decode": {
"p50": 0.7,
"p95": 1.5,
"p99": 2.0,
"n": 400
},
"draw": {
"p50": 0.3,
"p95": 0.7,
"p99": 1.0,
"n": 400
},
"present": {
"p50": 0.8,
"p95": 19.3,
"p99": 26.8,
"n": 339
},
"content": {
"p50": 15.1,
"p95": 28.6,
"p99": 41.3,
"n": 400
},
"content_shown": {
"p50": 17.6,
"p95": 39.0,
"p99": 47.2,
"n": 339
}
},
"fps": 39.3,
"fps_shown": 33.4,
"late_pct": 50.25,
"stall_max": 69.3,
"stalls_over_100ms": 0,
"mbps": 0.35,
"keyframes": 6,
"size": "1280x720",
"captured": 612,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 76.6,
"p95": 98.2,
"p99": 127.8,
"n": 19
},
"input_shown_ms": {
"p50": 80.5,
"p95": 107.8,
"p99": 128.1,
"n": 16
},
"input_parts_ms": {
"uplink": {
"p50": 36.5,
"p95": 57.6,
"p99": 83.2,
"n": 19
},
"mac": {
"p50": 14.6,
"p95": 31.3,
"p99": 31.7,
"n": 19
},
"back": {
"p50": 15.1,
"p95": 32.8,
"p99": 36.2,
"n": 19
}
},
"inputs": {
"asked": 19,
"sent": 19,
"seen": 19
},
"timeline": [
{
"t": 0,
"fps": 40,
"mbps": 0.34,
"content_p50": 11.7,
"content_p95": 27.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 39,
"mbps": 0.37,
"content_p50": 11.9,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 41,
"mbps": 0.35,
"content_p50": 11.4,
"content_p95": 16.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 39,
"mbps": 0.37,
"content_p50": 12.7,
"content_p95": 28.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 38,
"mbps": 0.33,
"content_p50": 15.3,
"content_p95": 30.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 39,
"mbps": 0.37,
"content_p50": 16.7,
"content_p95": 28.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 41,
"mbps": 0.38,
"content_p50": 15.9,
"content_p95": 29.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 41,
"mbps": 0.31,
"content_p50": 15.6,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 38,
"mbps": 0.38,
"content_p50": 15.5,
"content_p95": 25.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.29,
"content_p50": 16.7,
"content_p95": 28.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 6,
"mbps": 0.03,
"content_p50": 17.6,
"content_p95": 23.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 28.6,
"input_p50": 76.6,
"input_p95": 98.2,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "acceptable",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "local"
},
"controller": {
"target": 5529600,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 12.332,
"inFlight": 0,
"slack": 74.12,
"adapt": true
},
"events": [],
"source_fps": 60.0,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
"show_s": 1.75,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
import os
from pathlib import Path
import subprocess
import sys
root = Path(__file__).resolve().parent
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
out = root / ('controller' + suffix)
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
else:
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
subprocess.run(cmd, cwd=root, check=True)
print(out)
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Build our native PC adapter and bundle its ordinary shared libraries.
Run on the target architecture after installing GStreamer development packages.
No GStreamer executable or desktop streaming application is shipped or invoked.
Linux libc, display/GPU drivers and the portal service remain platform pieces.
x264 is GPL, so the bundle the Windows installer ships leaves it out: Windows
encodes with Media Foundation. --with-x264 adds it for CI's headless protocol
tests, which have no GPU or desktop to encode with.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parent
def output(*args):
return subprocess.check_output(args, text=True).strip()
def build(destination, with_x264=False):
win = sys.platform == 'win32'
destination.mkdir(parents=True, exist_ok=True)
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
if not win:
modules += ['gio-unix-2.0']
import shlex
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
if not win:
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
plugins_out.mkdir(parents=True, exist_ok=True)
libs_out.mkdir(parents=True, exist_ok=True)
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad']
if with_x264:
names.append('x264')
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
pending = [lib]
for name in names:
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
if not matches:
raise SystemExit('Missing GStreamer library: ' + name)
for source in matches:
target = plugins_out / source.name
shutil.copy2(source, target)
pending.append(source)
if not win:
# PipeWire dynamically loads support and protocol modules. ldd cannot
# discover those; bundle them with a private client-only config instead
# of accidentally loading a different distro's client modules/config.
libdir = plugin_dir.parent
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
'pipewire-0.3/libpipewire-module-client-node.so',
'pipewire-0.3/libpipewire-module-adapter.so',
'spa-0.2/support/libspa-support.so',
'spa-0.2/support/libspa-dbus.so',
'spa-0.2/videoconvert/libspa-videoconvert.so'):
source = libdir / relative
if not source.is_file():
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
target = destination / 'lib' / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
pending.append(source)
config = destination / 'share' / 'pipewire'
config.mkdir(parents=True, exist_ok=True)
(config / 'client.conf').write_text(
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
'context.modules = [\n'
' { name = libpipewire-module-protocol-native }\n'
' { name = libpipewire-module-client-node }\n'
' { name = libpipewire-module-adapter }\n'
']\n')
copied = set()
while pending:
binary = pending.pop()
if win:
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
dependencies = [prefix / 'bin' / name for name in imported]
else:
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
for dep in dependencies:
if not dep.is_file() or dep.name in copied:
continue
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
continue
shutil.copy2(dep, libs_out / dep.name)
copied.add(dep.name)
pending.append(dep)
# License texts and package provenance accompany the dynamically linked
# libraries. Distribution builders keep the upstream package/source URLs.
licenses = destination / 'licenses'
licenses.mkdir(exist_ok=True)
if win:
source = prefix / 'share' / 'licenses'
if source.exists():
shutil.copytree(source, licenses, dirs_exist_ok=True)
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
else:
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
# Debian copyright files contain licenses and source homepage details.
for path in Path('/usr/share/doc').glob('*/copyright'):
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
plugins=names, libraries=sorted(copied), packages=packages), indent=2))
print(lib)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
parser.add_argument('--with-x264', action='store_true', help='add the GPL x264 encoder (CI tests only, never shipped)')
args = parser.parse_args()
build(args.out.resolve(), args.with_x264)
+134
View File
@@ -0,0 +1,134 @@
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
#include "controller.h"
#include <gst/gst.h>
#include <gst/app/gstappsink.h>
#include <gst/video/video-event.h>
#include <string.h>
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
typedef struct {
GstElement *pipeline, *encoder, *sink, *raw_queue;
Gate gate;
GstSample *sample;
GstMapInfo map;
int mapped;
char error[512];
} Capture;
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
FC_API int fc_has_element(const char *name) {
GstElementFactory *f=gst_element_factory_find(name);
if(!f)return 0;gst_object_unref(f);return 1;
}
FC_API int fc_has_property(const char *element, const char *property) {
/* Probe the same initialized GStreamer instance that capture uses. The
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
GstElement *source=gst_element_factory_make(element,NULL);
if(!source)return 0;
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
gst_object_unref(source);
return found;
}
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
if(!b)return GST_PAD_PROBE_OK;
int64_t now=fc_now(), cap=now;
GstClock *clock=gst_element_get_clock(c->pipeline);
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
}
if(clock)gst_object_unref(clock);
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
int phase=0;
for(;;) {
/* While congested, prefer a newer raw picture queued upstream. If
* nothing changed, retain this last picture until the gate opens;
* an idle window must not stay stale after a dropped final update. */
guint queued=0;
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
if(queued)return GST_PAD_PROBE_DROP;
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
phase=2;g_usleep(2000);
}
}
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
c->pipeline=gst_parse_launch(pipeline,&e);
if(e || !c->pipeline) {
g_strlcpy(error,e?e->message:"No pipeline",capacity);
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
if(!c->encoder || !c->sink || !raw) {
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
GstPad *p=gst_element_get_static_pad(raw,"src");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
p=gst_element_get_static_pad(c->encoder,"sink");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
return c;
}
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
GstBus *bus=gst_element_get_bus(c->pipeline);
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
if(m) {
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
gst_message_unref(m);return -1;
}
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
if(!c->sample) {
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
}
return 0;
}
GstBuffer *b=gst_sample_get_buffer(c->sample);
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
}
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
FC_API void fc_capture_bitrate(Capture *c,int bps) {
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
}
FC_API void fc_capture_test(Capture *c,uint32_t input) {
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
if(source) {
/* Each benchmark click visibly changes the ball, before injection is
* timestamped. This is a response, not merely a protocol input echo. */
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
}
}
FC_API void fc_capture_key(Capture *c) {
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
}
FC_API void fc_capture_close(Capture *c) {
gst_element_set_state(c->pipeline,GST_STATE_NULL);
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
if(c->sample)gst_sample_unref(c->sample);
if(c->raw_queue)gst_object_unref(c->raw_queue);
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
}
+130
View File
@@ -0,0 +1,130 @@
/* A C port of the Mac agent's RateController (mac/frame-mac-view/Sources/
* Controller.swift) for the Windows host. Same gate, demand protection, bitrate
* recovery and tier hysteresis; bounded sample buffers. The Mac agent keeps its
* Swift original, so a change to either must be made to both:
* tests/test_pc_controller.py checks this one against the Swift trace. */
#include "controller.h"
#include <stdlib.h>
#include <string.h>
#define CAP 1024
#define MIN(a,b) ((a)<(b)?(a):(b))
#define MAX(a,b) ((a)>(b)?(a):(b))
typedef struct { int64_t t, v; uint32_t seq; } Sample;
typedef struct { Sample a[CAP]; int n; } Samples;
struct FCController {
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
int64_t decrease, increase, below, above, slack;
Samples flight, rtts, acked, sent, captures;
};
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
static const double floors[] = {.45,.28,.16,.08,0};
static void remove_first(Samples *s, int n) {
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
}
static void add(Samples *s, Sample v, int cap) {
if (s->n >= cap) remove_first(s, 1);
s->a[s->n++] = v;
}
static void expire(Samples *s, int64_t oldest) {
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
remove_first(s,n);
}
static int64_t base(FCController *c) {
int64_t v=0;
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
return v;
}
static int compare(const void *a, const void *b) {
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
return (x>y)-(x<y);
}
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
int64_t a[CAP]; int n=0;
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
if(!n) return 0;
qsort(a,(size_t)n,sizeof(int64_t),compare);
return a[n*numerator/denominator];
}
static int rate(Samples *s) {
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
return (int)MIN(total*16,2147483647);
}
FCController *fc_new(int max_fps,int enabled) {
FCController *c=calloc(1,sizeof(*c));
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
return c;
}
void fc_free(FCController *c) {free(c);}
void fc_ceiling(FCController *c,int bps) {
if(!c->target || c->target>bps) c->target=bps;
c->ceiling=bps;
}
int fc_gate(FCController *c,int64_t now,int counts) {
if(!c->enabled || !c->saw_ack) return 1;
expire(&c->flight,now-2000000);
if(!c->flight.n) return 1;
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
if(counts) c->held++;
return 0;
}
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
}
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
c->saw_ack=1;
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
}
return 0;
}
int fc_update(FCController *c,int64_t now) {
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
expire(&c->captures,now-1000000);
if(!c->enabled || c->ceiling<=0) return 0;
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
int64_t jitter=spread ? spread-baseline : 0;
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
int64_t recent=quantile(&c->rtts,now-300000,1,2);
int64_t queue=recent ? recent-baseline : 0;
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
int delivered=rate(&c->acked),sending=rate(&c->sent);
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
int congested=signal && c->signal;c->signal=signal;c->held=0;
if(congested && now-c->decrease>300000) {
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
c->target=next;c->decrease=now;
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
}
double share=(double)c->target/MAX(c->ceiling,1);
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
if(!c->below)c->below=now;
if(now-c->below>500000) {
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
c->below=0;
}
} else c->below=0;
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
if(!c->above)c->above=now;
if(now-c->above>2000000) {c->tier--;c->above=0;}
} else c->above=0;
return c->target;
}
int64_t fc_value(FCController *c,int field) {
switch(field) {
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
default:return 0;
}
}
+29
View File
@@ -0,0 +1,29 @@
/* Shared latency controller. Times are monotonic host microseconds.
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
* encoded inter frame would break the decoder's reference chain. */
#ifndef FRAME_CONTROLLER_H
#define FRAME_CONTROLLER_H
#include <stdint.h>
#ifdef _WIN32
#define FC_API __declspec(dllexport)
#else
#define FC_API
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct FCController FCController;
FC_API FCController *fc_new(int fps, int enabled);
FC_API void fc_free(FCController *c);
FC_API void fc_ceiling(FCController *c, int bps);
FC_API int fc_gate(FCController *c, int64_t now, int counts);
FC_API void fc_capture(FCController *c, int64_t now);
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
FC_API int fc_update(FCController *c, int64_t now);
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
FC_API int64_t fc_value(FCController *c, int field);
#ifdef __cplusplus
}
#endif
#endif
+147
View File
@@ -0,0 +1,147 @@
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
* Input uses only the devices granted in Start's response. */
#ifndef _WIN32
#include "controller.h"
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <unistd.h>
#include <string.h>
#define BUS "org.freedesktop.portal.Desktop"
#define PATH "/org/freedesktop/portal/desktop"
#define RD "org.freedesktop.portal.RemoteDesktop"
#define SC "org.freedesktop.portal.ScreenCast"
typedef struct {
GDBusConnection *bus;
GMainContext *context;
char *session;
int fd;
unsigned node, devices;
int width,height;
} Portal;
typedef struct {GVariant *result;int done;unsigned code;} Response;
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
const char *signal,GVariant *params,gpointer data) {
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
}
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
const char *token,char *error,int capacity) {
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
Response r={0};GError *e=NULL;
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
if(reply)g_variant_unref(reply);
int64_t deadline=g_get_monotonic_time()+120000000;
while(!e && !r.done && g_get_monotonic_time()<deadline) {
while(g_main_context_iteration(p->context,FALSE)) {}
g_usleep(10000);
}
if(!r.done) {
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(closed)g_variant_unref(closed);
}
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
if(e || !r.done || r.code) {
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
}
return r.result;
}
FC_API void fc_portal_close(Portal *p) {
if(!p)return;
if(p->session && p->bus) {
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(r)g_variant_unref(r);
}
if(p->fd>=0){close(p->fd);p->fd=-1;}
g_free(p->session);if(p->bus)g_object_unref(p->bus);
if(p->context)g_main_context_unref(p->context);g_free(p);
}
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
* previously consumed protocol socket is not a new connection.
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
* pipewiresrc never takes the fd it is given: its core connects with
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
* close; not closing it would leak one socket per pipeline reopen. The
* caller closes the previous pipeline before asking for a new fd. */
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
GUnixFDList *fds=NULL;GError *e=NULL;
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
if(p->fd>=0)close(p->fd);
p->fd=fd;return fd;
}
FC_API Portal *fc_portal_select(char *error,int capacity) {
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
g_main_context_push_thread_default(p->context);
GError *e=NULL;GVariant *r=NULL;
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
GVariantBuilder b;char token[64],session[64];
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"devices","u",&p->devices);
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
if(streams && g_variant_n_children(streams)>0) {
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
g_variant_unref(props);g_variant_unref(entry);
}
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
g_main_context_pop_thread_default(p->context);return p;
fail:
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
}
FC_API int fc_portal_value(Portal *p,int field) {
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
}
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
if(type==0 && (p->devices&2)) {
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
} else if(type==1 && (p->devices&2)) {
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else if(type==2 && (p->devices&2)) {
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
} else if(type==3 && (p->devices&1)) {
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else return 0;
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
}
#endif
+1 -1
View File
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
## Assistant panel
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
To put the same page in the headset, with the HTTP server still running:
```sh
+63
View File
@@ -0,0 +1,63 @@
# Import an Android game backup
A SideQuest backup can contain a small APK and much larger `main.…obb` and
`patch.…obb` files. All of them belong to the same game. Installing only the APK
can leave the game unable to start because its assets are missing.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the base APK, any supported splits, and its OBB files together.
You can also drop them anywhere in the window. In the desktop app, dropping the
APK alone finds matching OBB files beside it; dropping a folder containing the
APK and OBBs also works. Browsers can read only the files you explicitly choose.
The import screen shows the game app and its game-data files together. **Import
game & data** installs the APK, starts its own Steam library instance, waits for
Android and the installed package, copies the OBBs, and closes the game. Open it
again from the Steam library to play. The screen explains this before import;
choosing files alone never starts a game.
Files are matched using the package inside the APK and the standard
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
patch files are preserved. OBB version numbers need not equal the APK's current
version code: updates can reuse older expansion files. The copy uses the existing
SHA-256 transfer check inside Android and writes to that game's own
`/sdcard/Android/obb/<package>` folder.
If no data files are selected, the screen reminds you to include any OBBs in the
backup before offering **Install game app**. An APK cannot reliably tell Frame
Control whether separate expansion files are required. Game-data files are never
silently sent to Downloads by this flow.
If setup fails, the import screen keeps the error and retry instructions visible.
The APK may already be installed, but the game is not reported ready. Import the
same backup again, or choose just its OBB files to add data to an existing install.
The importer checks the installed package and version before writing data and
closes the instance after a failed copy. Unrelated or duplicate files, running
games that would be replaced, and downgrades are rejected. Uploaded staging files
are discarded after use or cancellation; abandoned imports expire after an hour
when the importer is next used, and dead-server staging folders are swept at
startup.
This flow handles one base APK, supported native-library or asset splits, and
standard OBB files for one game at a time. It checks the package and version in
each split manifest before touching the headset. The existing Quest merger
rejects resource, code and feature splits and sets with multiple device
variants. A merged APK is re-signed; that can affect a game's licence or login.
XAPK/APKS/APKM archives use the same checks. Nested Android/data folders and
private save archives remain unsupported. PC ZIP/folder/EXE imports keep their
existing route. Quest USB and Meta library imports keep their existing workflow.
Validation covers actual APK parsing, the three-file backup layout, package
matching, sibling discovery, browser upload staging and cleanup, instance/version
guards and failure recovery with mocked headset operations. Browser verification
uses the real page and upload/inspection endpoints with simulated installation.
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
install/start/copy/close sequence. Reopening its persistent instance confirmed
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
shortcut and instance were removed afterward, the shared device lock was
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
The headset was unworn; this checks transfer and persistence, not rendering,
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
flow does not change APK compatibility or remove licence checks.
The supported-split backup route was checked with generated local fixtures and
existing merger tests, not a real split game on the Frame.
+54 -1
View File
@@ -58,8 +58,61 @@ form shows the pinned one ("Trusted on first use: …") so you can compare it.
Re-adding an existing URL preserves its pin; changing it requires deliberately
removing and re-adding it.
### Repositories behind a login
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
client, the login can be part of the address
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
store's **Add a source** form under "This repository needs a login", in the
server API (`username` and `password` beside `url`), or on the command line,
where the password is read from standard input so it stays out of shell history
and `ps`:
```sh
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
```
How the login is kept and used:
- **Kept out of the address.** The `user:pass@` part is removed before anything
is saved, so the saved repository URL, its id, the source list, job results
and the page never contain it. The store only says "Uses a login".
- **Stored separately.** Logins live in
`frame_host.data_dir('source-credentials.json')`
(`~/Library/Application Support/Frame Control/source-credentials.json` on
macOS), written with mode 0600 and keyed by source id. They are stored in
plain text there, protected by file permissions only (on Windows,
the folder's normal ACLs). Removing the repository deletes its login, and
the file goes when the last login is removed. Each change holds a lock
file (`source-credentials.lock`), so the app and the command line can't undo
each other's saves or removals.
- **HTTPS only.** An `http://` address with a login is refused before any request.
- **Inside the repository only.** The `Authorization` header is decided
separately for every request, redirects included: index, `entry.jar`,
APKs and the store's artwork proxy. It is sent only when the request's
scheme, host and port match the repository's and its path is inside the
repository's directory by whole path segments. A redirect to another host
or port, or to another path on the same server, gets no login, and that
request's own `Authorization` header is removed. Paths with `.` or `..`
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
or `;` get no login, since a server could normalise them to a path
outside the repository. Index file names like that are refused outright.
- **Never echoed.** Logins are not written to logs, error messages, API
responses or the cache. The CLI prints only the saved source.
- **Checked before saving.** As with any repository, the whole index must
download and verify before anything is saved. HTTP 401 (or 403 when a login
was sent) stops the add with "check the username and password" and saves
nothing: no repository, no login, no rollback state. A 403 with a login is
not treated as rate limiting.
- **Same verification.** The login changes only who may download. Signature,
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
any other repository.
Re-adding the same address with a new login replaces the saved one. Re-adding
it without a login keeps the saved one.
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
`sources`, `search`, `details`, and `download` from the shared source contract.
This change supplies the CLI and API; the integrated source-management UI is
+17 -4
View File
@@ -45,10 +45,23 @@ search. Results outside the curated list stay page-only, even if a repository
claims an open-source license. This prevents an arbitrary tagged mirror from
becoming a trusted downloader. Extend the curated JSON after provenance review.
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
quota. Tokens are sent only to `api.github.com`, never written to the cache,
never sent to asset hosts, and removed on redirects. The adapter does not
read `gh` credentials automatically. Metadata is cached for one hour under
For a higher API quota, add a GitHub access token in the store's source
settings (the GitHub row's token field) or through the server API
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
works and wins over a saved token. A saved token is kept with repository
logins in `source-credentials.json` (mode 0600, see
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
reports only whether a token is set, where it came from and whether GitHub
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
`api.github.com`, never written to the cache, never sent to asset hosts (public
release downloads don't need them), and removed on redirects. If GitHub answers
401 to the token, the request is retried once without it, as Obtainium does.
The rejection is logged without the token and shown in source settings. The
token is not tried again until it is saved again or the app restarts. Rate-limit
backoff is kept per login (anonymous, or each token), so saving a token after
anonymous requests were limited lets the next request go out with it. The
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
fetches at most ten releases. Rate-limit errors are surfaced without retry
loops. Asset IDs and release tags are not Android version codes: metadata
+140
View File
@@ -39,6 +39,12 @@ section below), instead of going into Lepton Development:
run at once alongside Lepton Development, each in its own container
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
Split APKs (a base plus `split_*.apk` files) install as one app:
`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
APK, so splits that add native libraries or assets are merged into the base
and re-signed; density and language splits are refused. See
[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
from the shortcut, stopped, and relaunched with their data intact. ADB and
Lepton Development aren't involved.
@@ -177,6 +183,92 @@ See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
## Store and Library in Frame Control
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
Store search cards and app details also show **Reported not working** or
**Reported issues** when saved compatibility reports say so. Details include
the report date, version and notes. These are advisories: Install remains
available unless the selected build has an existing compatibility blocker
(for example, no arm64-v8a build or Android API above 30). Reports never clear
those blockers. Apps reported not working are excluded from Home's featured
app and recommendation shelves; search and category results still show them.
An unrated install failure caused by the wrong APK architecture or Android API
is ignored, using the same rules as the catalogue. A person's rating takes
priority over automated results, with the latest rating winning.
Reports warm in a background worker at server start. Search and details read
only the last complete in-memory snapshot and schedule a single background
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
the report database. During initial loading they show the usual build verdict;
after a refresh, the next search or detail request sees the reports. Failed
refreshes keep the previous snapshot and retry after another 60 seconds.
**Availability:** public copies benefit from their own saved reports, including
reports for apps outside the bundled catalogue, across every source with the
same package name. This does not give public users the private shared ratings.
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
report verdicts, so it cannot supply a useful public report snapshot. No private
reports, free-text notes or credentials are exported or published. The existing
maintainer loader still uses its configured database access in the background;
the search feature adds no sharing, endpoint or network destination. Tests leave
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
server subprocesses) and use synthetic reports to exercise refresh behavior.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
*Social Network* is Web & chat).
Apps built around a phone are hidden from browsing unless **Show phone-only
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
a category that only makes sense in a phone's own system (launchers, keyboards,
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
fitness, alarms, system tools) unless a useful category says otherwise
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
part (a widget, a launcher, a battery tool), and apps that need hardware the
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
Without a search, each source offers its best 300 for the chosen category:
apps with a banner first, then with screenshots, then the newest.
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
The **APK Store** panel is the Store above, drawn for the laser: the same
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
its own floating panel:
```sh
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
scripts/store-on-frame.sh close
```
`open` copies the same bundle the iPhone app uses
(`ios/scripts/make_frame_bundle.py`) to
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
all run on the Frame, so the Mac can go away after `open`.
The page has a category rail (Home, the store's categories, Installed), eight
art cards a page, and a featured VR app on Home. It turns pages instead of
scrolling, because a tagged panel gets mouse clicks and a drag would select
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
the real sources): Home, categories, Installed, the detail sheet. **Still to
check in the headset:** that the panel opens and takes laser clicks, that an
install completes when run on the Frame, and that **Open** brings up the app's
panel.
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
@@ -339,3 +431,51 @@ app instance and back up/restore a stopped instance's private app data. See
[SideQuest features and limits](sidequest.md) for commands, archive scope and
verification status. These paths have offline coverage; real Frame storage and
permissions remain unverified. They do not change APK install or launch behavior.
## Local APK/OBB backups and Android bundles
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
base APK, any supported splits, and its matching `main.<version>.<package>.obb` and/or
`patch.<version>.<package>.obb` files together. The desktop app also discovers
matching OBB files beside a lone selected APK. Browser uploads use only the
files you selected. Installed apps can receive OBB-only imports.
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
one base APK, optional supported splits and matching OBBs. These extensions describe
containers, not a guarantee that their contents are supported. OBBs may be
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
one enclosing backup folder is allowed. Known manifest/icon/signature
sidecars are ignored; the APK manifest determines the package. Unknown files,
unsafe paths, links, encrypted entries, duplicate paths (including case
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
100-file/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
extract an Android ZIP yourself and select its APK/OBBs.
The importer previews the app and files before **Import game & data**.
Confirming authorizes installing the app, starting it to prepare Android,
checking the running package/version, copying OBBs through the existing
verified data helper, and closing the game. Open it from Steam afterwards.
If setup fails, read the error and retry the same backup; do not uninstall
first, since that deletes saves. An app that cannot start may also prevent
data setup. Uploaded and extracted staging is removed on cancellation,
inspection failure, or job completion/failure; abandoned imports expire
through the existing one-hour staging lifecycle. Original local files stay intact.
**Limits:** this backup importer requires exactly one base APK. For split sets,
each split must identify the same package and version, and pass the existing
[Quest split merger](quest-import.md#split-apks). It refuses resource, code and
feature splits and does not choose among device variants. Merging re-signs the
APK, which can affect licences. It does not restore `Android/data`, private app
data, saves or entitlements. These split imports have local fixture and merger
tests, but no real split game has been installed with this backup route on the
Frame.
For issue #102, the RE4 report only establishes that an APK attempt failed.
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
and runtime compatibility remain unknown. Record those details, Frame Control
and SteamOS versions, and sanitized server/crash errors before diagnosing it.
`python3 ui/frame_android.py info /path/to/game.apk` inspects the APK locally.
Successful installation or fixture tests do not establish RE4 compatibility;
OBBs cannot fix ABI/API, VrApi/OpenXR, entitlement or Android-service failures.
+228
View File
@@ -0,0 +1,228 @@
# Backup and recovery
**Tools → Backup & recovery** copies what a factory reset would lose from the
Steam Frame to your computer, puts it back afterwards, and gets the headset out
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
app or the Frame itself.
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
with no sign-in files or keys in the archives. Restoring one game's saves on top
of identical files wrote nothing and left nothing behind. The session check
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
headset: a restore after a real factory reset, a restore that replaces files,
Android data restore. Unit tests and the fake Frame cover those (see
[Verification](#verification)).
On 2026-10-24, the real-device reproduction in [issue #142](https://github.com/saphid/frame-control/issues/142)
confirmed full Plasma Desktop Mode on the same build: the Plasma session target
was active and the Game Mode target inactive. Desktop Mode also ran gamescope
for `steamvr-plasma.service`, so the old process check incorrectly reported
Game Mode. SteamOS's `steamosctl switch-to-game-mode` restored Game Mode and
reversed the target states; battery stayed at 99%, and the rollback timer and
shared lock were cleaned up. After #143 passed CI, the corrected code was
tested in another full Plasma session under a three-minute rollback timer.
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
the button. Game Mode became active and a second call made no switch. Battery
remained at 99%; the timer and lock were removed. That test used the production
CLI rather than clicking the desktop button.
Later on 2026-10-24, the published **v0.4.17 Linux x86_64 AppImage** passed the
literal packaged-button check on the same Frame/build. With the shared lock and
a verified three-minute rollback timer, the headset entered full Plasma Desktop
Mode. In the packaged Electron window, **Tools → Backup & recovery → Check**
reported Desktop Mode despite gamescope. A trusted automated pointer click on
the visible **Return to Game Mode** button, accepting its confirmation, returned
`switched: true` and `steamosctl switch-to-game-mode`. The systemd targets then
confirmed Game Mode. A second confirmed button click returned `switched: false`;
the Game Mode service invocation and SteamVR PID stayed unchanged. **Check**
then reported Game Mode and disabled Return. The test app/server and virtual
display were stopped; the timer and shared lock were removed. Steam and SteamVR remained running
at 99% battery. [Commands, responses, cleanup and limits](evidence/return-game-mode-packaged-2026-10-24.json),
[before](evidence/return-game-mode-packaged-2026-10-24-before.png) and
[after](evidence/return-game-mode-packaged-2026-10-24-after.png).
This used the unchanged extracted AppImage payload with its bundled Python on
Xvfb (`--no-sandbox --disable-gpu`), following the packaged Export test in #156.
FUSE mounting, desktop integration, hardware rendering and the view inside a
worn headset were not checked. Older SteamOS builds remain fixture-tested.
## Back up
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
(all three by default), then **Back up now**. **See what's included** lists each
part and its size first. Backing up only reads the headset.
Each backup is a folder named after the time it started:
```
~/Documents/Frame Control Backups/2026-10-23-142530/
manifest.json
saves/saves-1234.tar.gz one per game
saves/saves-linux.tar.gz
settings/settings-steam.tar.gz
settings/settings-system.tar.gz
android/org.example.game.tar.gz one per Android app
```
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
file with its size and SHA-256, and each archive's SHA-256. It also lists what
was skipped and why. A backup is written under a hidden `.…partial` name and
renamed when complete, so a cut-off backup never shows up as one.
### What's saved
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|---|---|---|
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
"Kept for reference" files are in the backup but not put back automatically:
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
point at apps that aren't installed after a reset. Open the backup folder to
copy one by hand if you need it.
### What's never copied
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
`registry.vdf` and `ssfn*` files.
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
(`secrets.env`). These are refused by name wherever they turn up, including
inside a game's folder, and a restore refuses them too.
- Games, Proton and the Steam runtimes: reinstall them from Steam.
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
screenshots (**Tools → Capture & Live** saves those to Pictures).
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
- Symbolic links, including any ancestor below the home folder of a selected
save or settings path, and single files over 2 GiB. Discovery checks each
component, and packing checks it again before opening each file.
Steam backups use the normal `~/.local/share/Steam` directory tree. A separate
`~/.steam/steam` alias does not affect this route. A library, Proton prefix or
Wine save folder redirected by a symlink below the home folder is skipped,
even if its target is inside home; it is not resolved and backed up elsewhere.
The home folder itself is trusted and may use the host's normal path aliases.
These are path checks, not a filesystem snapshot: a concurrent rename or link
replacement between a check and a read can still race it. Close games and
avoid moving or relinking these folders during backup. This fix rejects links
already present when checked, including ones introduced after discovery; it
does not provide atomic protection against concurrent filesystem changes.
Backups aren't encrypted. Game folders can still hold a launcher's login, and
Android app data can hold an app's, so keep backups private.
Android apps that are open are skipped (the helper needs the app closed); close
them and back up again.
## Restore after a factory reset
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
Steam with the same account.
2. Reinstall your games, and **start each once** so Proton creates its folders.
3. Reinstall your Android apps from Frame Control.
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
each part against the headset and says what it would do: how many files it
replaces and how many are new.
5. Tick the parts to restore and **Restore selected**. It asks before replacing
anything.
Parts that can't go back yet are skipped, not failed, with the reason:
| Reason | What to do |
|---|---|
| Install the game first | Install it from Steam, then restore |
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
| Sign in to Steam first | Controller layouts go under your account's folder |
| Install the app first / close the app first | Android app data needs the app installed and closed |
| The backup file doesn't match its checksum | The archive changed since it was made |
A restore writes only the files in the parts you chose, and only inside that
part's folders: it never deletes a file, and a save archive can't write outside
its game. Before replacing a file it copies the current one to
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
already identical are left alone. Android app data goes through the existing
`restore-data` helper, which keeps the previous data folder beside it.
## Stuck in Desktop Mode
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
is what you see, you're already in Game Mode: close it from the Steam dashboard.
Full Desktop Mode is a different session. **Check** reads the main user's
systemd session targets over SSH: `gamescope-session.target` and
`plasma-workspace-wayland.target`. Game Mode requires the former active and the
latter inactive; Desktop Mode requires the reverse. This takes precedence over
process names: full Desktop Mode can run gamescope for its VR overlay, and
Game Mode can run a nested Plasma panel. Check changes nothing.
Conflicting, transitional, failed or unreadable targets produce **unknown**.
On older images where both targets are explicitly absent, the fallback accepts
only gamescope without Plasma/nested desktop as Game Mode, or Plasma without
gamescope/nested desktop as Desktop Mode. Mixed processes remain unknown.
The SDDM autologin file is still reported as a login preference; it cannot
establish which session is running.
After confirmation, **Return to Game Mode** runs exactly
`steamosctl switch-to-game-mode` when available, or the older
`holo-session-select gamescope` / `steamos-session-select gamescope` command.
It closes the desktop and anything open in it. Frame Control never edits the
SDDM files itself, and runs no switch when the headset is in Game Mode, when
its mode is unknown, or when no supported switch command is installed. A
refused command reports an error; a request being sent does not verify that
the new session has started.
### Manual fallback
If the button can't help, or the headset isn't reachable:
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
Desktop Mode session, so a restart brings Game Mode back. That the Frame
behaves the same is **inferred**, not tested.
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
`ssh frame steamosctl switch-to-game-mode` (verified on SteamOS 0.5.6).
Older images may instead provide `steamos-session-select gamescope` or
`holo-session-select gamescope`.
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
button on top of Power. Choose `Current`, or `Previous` to start the other
SteamOS slot; both keep your data. Details and the further options
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
`Erase User Data` is a factory reset.
## Verification
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
for real against a folder laid out like the Frame's home, with only SSH faked:
which paths are picked, that sign-in files and keys never are (also inside game
folders), links in save/settings roots and their ancestors refused, the manifest
and its checksums, restore checks
after a reset (game not installed, no Proton folders yet, not signed in), that a
restore replaces only its own files and keeps the old ones, unsafe archives
(`..`, absolute paths, links, other games' folders) writing nothing, backup
names that try to leave the backups folder, and the exact Game Mode command.
Synthetic symlink fixtures cover every component of a Proton save root below
home, including `compatdata/<appid>` and `steamuser/AppData`, plus directory
and file links introduced between discovery and packing. Ordinary Proton and
non-Steam saves, Linux saves, controller layouts, reference settings and home
aliases still pack with their original bytes. These are local fixture checks;
this path fix has not been tested on a headset.
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
the server, deletes it and restores it. Neither involves a real headset.
Isolated session tests in the same suite reproduce full Plasma plus gamescope,
Game Mode with and without the nested desktop, conflicting/transitional targets,
missing targets on older builds, failed/timed-out systemd reads, selector
compatibility, and the confirmed Return action with SSH mocked. Run them with
`python3 -m unittest discover -s tests -p test_backup.py -v`.
+59
View File
@@ -0,0 +1,59 @@
# Battery and health monitoring
Open **Control Center → Battery & thermal health**, or expand **System
information**, to read the Frame's charge, charging state, signed battery
power, battery temperature and driver-reported health. Optional readings show
charge cycles, full charge capacity, design capacity and full / design capacity.
The existing battery chip also shows charging state and temperature.
These are read-only readings from the Linux host's `/sys/class/power_supply`,
not Android's container battery view. The probe prefers the documented Frame
fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral
(`scope=Device`) and absent batteries, and otherwise uses a deterministic
battery fallback. The UI shows the selected source path.
| Reading | Linux attribute | Interpretation |
|---|---|---|
| Charge | `capacity` | Integer 0–100 percent; zero is a real reading |
| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable |
| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame |
| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings |
| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage |
| Charge cycles | `cycle_count` | Driver-reported count, including zero |
| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh |
| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime |
| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state |
Unavailable means the field is absent, unreadable or invalid. One missing
sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second
to 7 days and battery temperatures outside −273.15 to 200 °C are rejected
as invalid readings; these bounds are not thermal warning thresholds.
The ratio may exceed 100% because full capacity is a gauge estimate, so the app
does not clamp it or invent a wear warning. The app does not estimate health
from charge percentage or assign temperature thresholds. Existing thermal
alerts use the kernel's own hot/critical trip points.
A connected charger does not necessarily mean charging: the Frame can still
drain under load or sit idle near full charge. A 100% capacity reading alone
does not prove the driver reports Full. Missing status is explicitly unknown;
near-zero measured battery power with a charger is shown as not charging.
Failed, stale or disconnected status samples clear Control Center readings.
**No charge controls are provided.** Frame's SteamOS charge-limit backend is
unsupported; see [charge-limit research](charge-limit.md). Monitoring never
writes to power supplies, pauses charging, changes current or sets a limit.
## Verification
`tests/test_battery.py` uses temporary sysfs-like files for conversion,
malformed/missing values, permission failures, source selection and partial
sensor support. `tests/control_center.cjs` exercises charging states, optional
metrics and failed/stale/disconnected sample handling. These tests do not
connect to a headset. On 2026-10-24, the PR's uninstalled parser was also
piped over SSH to a real Frame running SteamOS 0.5.6 (build
`20261015.6141207`). Its read-only sysfs result selected
`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C,
Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity
(108.1% fuel-gauge estimate). The Control Center UI was not run against this
headset; Windows and macOS were not tested. Optional fields are not claimed to
be available on every firmware build.
+9
View File
@@ -0,0 +1,9 @@
# Changelog
## Unreleased
- Battery monitoring now selects the Frame fuel gauge predictably, handles
missing or invalid readings, and shows optional cycle count, full/design
capacity and estimated capacity health in Control Center. Unknown charging
status stays explicit. Monitoring is read-only; charge limits remain
unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not.
+173
View File
@@ -0,0 +1,173 @@
# Battery charge limit (80%): research
**Question:** can Frame Control set an 80% battery charge limit on the Frame
over SSH or adb, safely?
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
charge-limit setting uses on the Deck) is not configured for the Frame. The
only control the Frame has is a vendor charger attribute that Valve's own
charging daemon also manages. Research as of 2026-10-23, confirmed with the
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
Rechecked read-only on build `20261022.6153909` on 2026-10-25: the
`BatteryChargeLimit1` interface still returned `UnknownInterface`, no
`charge_control_*` sysfs attribute existed, and the vendor charging switches
were root-owned mode `644`. See [Round 3 headset evidence](evidence/round3-headset-2026-10-25.md).
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
For safe read-only readings in Control Center, see [battery and health
monitoring](battery-health.md). It does not provide charging controls.
## How Steam does it on other devices
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
SteamOS 3.7.8 (beta client 2025-05-08)
([GamingOnLinux](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)).
- Steam calls SteamOS Manager on the session bus:
`com.steampowered.SteamOSManager1.BatteryChargeLimit1`, with properties
`MaxChargeLevel` (`i`, read/write; `-1` resets to the default) and
`SuggestedMinimumLimit` (`i`, read-only). The CLI is
`steamosctl get-max-charge-level` / `set-max-charge-level` /
`suggested-minimum-charge-limit`. Source: `data/interfaces/com.steampowered.SteamOSManager1.xml`
and `steamos-manager/src/bin/steamosctl.rs` in
[holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager),
read at commit `83981ab` (2026-10-21).
- The root daemon writes the value to sysfs only if the device's TOML in
`data/devices/` has a `[battery_charge_limit]` section. Two methods exist
(`steamos-manager/src/power.rs`, `BatteryChargeLimitMethod`):
- `hwmon_attribute`: the Deck writes `max_battery_charge_level` on
`steamdeck_hwmon` (`data/devices/steam-deck.toml`).
- `acpi_sb`: ROG Ally, MSI Claw and others write the standard
`charge_control_end_threshold` on the ACPI battery
(`/sys/bus/{platform,acpi}/drivers/…battery…/PNP0C0A:00/…/power_supply/*/`).
On ARM there's no ACPI, so this path can't match there either.
- With no `[battery_charge_limit]` section, the interface fails with
`UnknownInterface` (Steam can still show its toggle on unsupported hardware:
[steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044)).
## What the Frame has
- **Upstream `data/devices/steam-frame.toml`** (matched by
`dt.compatible = "qcom,sm8650"`, variant `Deckard`) configures GPU clocks
and performance profiles only. It has **no `[battery_charge_limit]`**, so
SteamOS Manager on the Frame has no charge-limit backend. Its git history
(2026-09-10 to 2026-10-12) never added one.
- **Battery driver:** the Frame's battery is
`/sys/class/power_supply/max1720x_bat_7-36`, and the charger is a
`tcpm-source-psy-…` USB-PD supply ([how-the-frame-works.md](how-the-frame-works.md)).
Both were verified 2026-09-25, and `ui/frame_status.py` `battery()` and
`power_source()` read them. The Maxim MAX1720x is a **fuel gauge** and
can't control charging. Mainline `drivers/power/supply/max1720x_battery.c`
exposes only read-only properties (capacity, voltage, current, temp,
charge_full…), with no `charge_control_end_threshold`. Valve's build is
patched: it reports `time_to_full_now`, which mainline doesn't have. Even
so, a charge stop would have to come from the separate charger IC, not this
node.
- **Not the qcom_battmgr path.** Some other SM8650/SM8750 handhelds get
`charge_control_end_threshold` from Qualcomm's `qcom_battmgr`
(pmic_glink) driver, clamped at 55% or above. A third-party distro enabled
Steam's limit on those by patching steamos-manager
([armada-os/armada#758](https://github.com/armada-os/armada/pull/758)). The
Frame's battery isn't exposed by `qcom_battmgr`, so that doesn't carry over.
(Unverified: whether a `qcom_battmgr` node also exists on the Frame. The
read-only checks below answer this.)
- **What third parties found:**
[y0usaf/frame-charge](https://github.com/y0usaf/frame-charge) reports that
the Frame has no `charge_control_end_threshold` and falls back to a vendor
`charging_pause` attribute. It also reports that `constant_charge_current`
changes on its own, which it attributes to Valve's `deckard_charge_daemon`
(`deckard-charger.service`). By its own README, that tool has only run its
read-only `status` on a Frame and has **not** been tested against real
charging. Two questions are still open: whether pausing stops the current,
and whether Valve's daemon undoes it.
## Why not build it now
- **No supported endpoint.** The only Valve-supported interface,
`BatteryChargeLimit1`, isn't backed on the Frame. Writing sysfs ourselves
would bypass Steam and SteamOS Manager, and it needs root (`sudo -S` with
the user's password).
- **A rival owner.** Valve's charge daemon already adjusts charging
parameters. Toggling `charging_pause` underneath it could be undone without
warning, fight it in a loop, or leave charging paused.
- **Stuck at a low charge.** If the app or the Frame dies while charging is
paused, the Frame could sit at a low charge on the charger. Whether a
reboot clears `charging_pause` is unknown.
- **The UI would mislead.** Steam's setting (if it shows at all) wouldn't
reflect our limit.
- **adb doesn't help.** Wi-Fi adb (`frame:5555`) reaches the Lepton Android
container only. USB-C adb reaches the Linux side as `steamos` (non-root, the
same as SSH, [ssh.md](ssh.md)). Neither adds a charging-control route.
Android's `dumpsys battery` in Lepton is a container view, not the hardware.
**Revisit when** a SteamOS update adds `[battery_charge_limit]` to
`steam-frame.toml` (or `steamosctl get-max-charge-level` stops returning
`UnknownInterface` on the Frame). Then the feature is just a SteamOS Manager
call:
- Read: `steamosctl get-max-charge-level`
- Set: `steamosctl set-max-charge-level 80`
- Undo: `steamosctl set-max-charge-level -- -1`
That runs as `steamos` with no sudo. It's the same value Steam's own
Settings → Power shows, so both stay in sync.
## Headset checks (read-only first)
Run these over SSH as `steamos`. Steps 1–4 only read.
```sh
# 1. Does SteamOS Manager offer the interface on this build?
steamosctl get-max-charge-level; steamosctl suggested-minimum-charge-limit
busctl --user introspect com.steampowered.SteamOSManager1 \
/com/steampowered/SteamOSManager1 | grep -i -A3 ChargeLimit
grep -n battery_charge_limit /usr/share/steamos-manager/devices/*frame* 2>/dev/null
# 2. Which power supplies exist, and which drivers back them?
for d in /sys/class/power_supply/*; do
echo "== $d ($(cat $d/type)) driver=$(basename "$(readlink -f $d/device/driver)" 2>/dev/null)"
ls $d
done
# 3. Any charge-control attributes, with their values and permissions?
ls -l /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
grep -H . /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
# 4. What is Valve's charger daemon, and what does it touch?
systemctl status deckard-charger.service --no-pager
systemctl cat deckard-charger.service
journalctl -b -u deckard-charger.service --no-pager | tail -50
```
If step 1 succeeds on some build, test `steamosctl set-max-charge-level 80`
on a charger at a charge above 80%. Watch `current_now` on the
`max1720x_bat_*` node fall to about 0, then `steamosctl set-max-charge-level -- -1` and
confirm charging resumes. Do this on a supervised Frame only. If only step 3
finds a vendor attribute, **stop**: don't write it, and leave the question
with Valve.
## Sources
- steamos-manager source, commit `83981ab` (2026-10-21):
`data/devices/steam-frame.toml`, `data/devices/steam-deck.toml`,
`data/interfaces/com.steampowered.SteamOSManager1.xml`,
`steamos-manager/src/power.rs`, `steamos-manager/src/manager/user.rs`.
[gitlab.steamos.cloud/holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager)
- Mainline [`max1720x_battery.c`](https://github.com/torvalds/linux/blob/master/drivers/power/supply/max1720x_battery.c)
- [ValveSoftware/steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044):
the Steam toggle shows on unsupported hardware
- [armada-os/armada#758](https://github.com/armada-os/armada/pull/758):
`qcom_battmgr` threshold on other SM8650/SM8750 devices
- [y0usaf/frame-charge](https://github.com/y0usaf/frame-charge): third-party,
untested on real charging
- [GamingOnLinux: SteamOS 3.7.7 beta charge limit](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)
- Repo: [how-the-frame-works.md](how-the-frame-works.md) (battery and
charger nodes), [ssh.md](ssh.md) (adb scope), `ui/frame_status.py`
+37
View File
@@ -0,0 +1,37 @@
# Connection and streaming lag FAQ
## Does Frame Control lower my streaming lag?
No. Frame Control uses SSH to manage the Frame. SteamVR or Steam Link sends the
game stream. Frame Control does not carry the game stream. It cannot lower your
game-stream ping or fix a stream that stops on the PC. A 40–75 ms ping in a VR
game is not a Frame Control measurement.
You do **not** need the Steam Wireless Adapter to use Frame Control. The app can
connect over Wi-Fi, the Wireless Adapter, or USB if the computer has an SSH
route to the Frame. Use the route that lets the app connect. To improve game
streaming, check the SteamVR or Steam Link connection on the PC and the Frame.
The [wired connection doctor](wired-connection.md) checks only Frame Control's
SSH route. A successful doctor check does not prove that the game stream uses
the cable or has low lag.
## Can I use a USB-A-to-C cable?
A Mac reached the Frame over a USB-C connection on the
`10.86.200.232/29` USB network. That test used a USB-C host port. We have
**not tested a USB-A-to-C cable** with the Frame. A data-capable USB-A-to-C
cable might show a USB network interface on your PC, but this is unknown on
the tested Frame OS and PC hosts. Check whether your PC shows that interface
and whether Frame Control's wired doctor can reach the Frame at
`10.86.200.233`. A charge-only cable cannot carry USB data. You can use Wi-Fi
for Frame Control if the USB route does not work.
## 日本語: 5 ステップで接続
1. [公式リリース](https://github.com/saphid/frame-control/releases/latest)から PC の OS 用ファイルをダウンロードします。Windows は `.exe`、macOS は `.dmg`、Linux は `.AppImage` または `.deb` です。同じリリースの `SHA256SUMS` でファイルを確認してください。
2. PC に Frame Control をインストールして起動します。Windows はスタートメニュー、macOS はアプリケーション、Linux はアプリ一覧から開きます。AppImage の場合は実行権限を付けて開きます。
3. Frame の Steam 設定 → システムで開発者モードを有効にし、開発者設定でユーザーパスワードを設定します。Frame と PC を同じ信頼できるネットワークに接続し、Frame を起こしておきます。
4. PC の Frame Control で **Set Up Connection…** を選びます。Frame が見つからない場合は、Frame の設定で IP アドレスを確認して入力します。macOS では **Devices & connection → + Add a headset…** からアドレスを設定できます。
5. Frame に **Pair new host** が出たら、自分が開始した要求だけを承認します。ペアリングに失敗した場合は、PC のターミナルで開発者モードのパスワードを入力します。接続後、Frame Control に戻って状態を確認します。
Frame Control は管理用の SSH 接続を使います。ゲーム映像は SteamVR / Steam Link が送ります。Frame Control を入れてもゲームの ping は下がりません。無線アダプターは必須ではありません。USB-A-to-C ケーブルで Frame の USB ネットワークが使えるかは、まだ確認できていません。
+30
View File
@@ -0,0 +1,30 @@
# What we have tested
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 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) |
| OBS headset source on Linux | Real Frame to OBS 30.0.2 on a Linux host: 187.367 s, 720p30 H.264/AAC stereo, 5,621 frames, no encoder skips; room passthrough appeared in the recording | No | Synthetic tone was measured, not real game audio or audible listening. Default audio led by about 0.43 s in this setup; a measured +480 ms OBS offset removed residual skew at test resolution. Source-image latency was 1.431 s in one trial; other hosts and long sessions are unverified | 25 Oct, v0.4.30 package plus PR #219 runtime: [validation](evidence/obs-validation-2026-10-25.md) |
| OBS headset source on Windows | No physical Windows PC test | Windows 11 VM helper fixture passed, but a native bridge to the real Frame sent no packets | In-app OBS controls remain hidden; native Windows OBS and firewall path are unverified | 25 Oct, PR #219: [validation](evidence/obs-validation-2026-10-25.md), [OBS guide](obs-headset-source.md) |
| OBS headset source on macOS | Physical Intel Air: official v0.4.39 native Start/Stop returned to idle offline | No | Real headset stream, OBS receiver and audio/video remain unverified; the app rejected its stale SSH identity | 26 Oct, v0.4.39: [Mac evidence](evidence/mac-v0439-2026-10-26.md), [OBS guide](obs-headset-source.md) |
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
| 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; 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.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.
+237
View File
@@ -0,0 +1,237 @@
# Decky Loader on Steam Frame
For the modding-path survey, licence review and update limits, see
[plugin and modding research](plugins-research.md).
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
verified. Frame Control therefore labels its user-service manager experimental.
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
VR user interface has not been seen or used.
## Official store plugin audit, 2026-10-25
On a real Frame with Frame OS 0.5.6 build `20261022.6153909`, SteamVR 2.18.2
reported `activity_level` 3 (standby). The battery was 99% before and after.
The test downloaded the official store's current archives, checked each SHA-256
against store metadata, and inspected their manifests and binaries. For seven
non-root candidates, the removable user-service loader started each plugin in
turn. It stopped between plugins. Logs showed `Loaded <name>` for all seven.
The service and test folders were removed after the run; the owned shared lock
was released. **A load message is not proof that a plugin's page, settings, or
action works in VR.** No plugin was tried by a wearer.
| Official store plugin (tested version) | Result | Evidence and limit |
| --- | --- | --- |
| ProtonDB Badges 1.3.4 | Partial | ARM64 backend loaded. Earlier 1.1.0 also registered in the shared Gamepad UI; badges in the VR shell were not seen. |
| CSS Loader 2.1.3 | Partial | Backend loaded; one `SyntaxWarning`. Theme controls and CSS application in VR were not checked. |
| SteamGridDB 1.8.1 | Partial | Backend loaded. Artwork selection and its UI were not checked. |
| Animation Changer 1.3.2 | Partial | Backend loaded. Boot animation replacement was not attempted. |
| Storage Cleaner 1.4.0 | Partial | Backend loaded. No files were cleaned; no UI check. |
| HLTB for Deck 2.0.12 | Partial | Passive plugin loaded. Game page content in VR was not checked. |
| Bluetooth 3.1.1 | Partial | Backend logged `Bluetooth plugin loaded`. Pairing and VR controls were not checked. |
| PowerTools 2.0.3 | Not applicable | Manifest asks for root; archive includes x86_64 Ryzen binaries and targets Steam Deck hardware. The Frame user service blocks it. |
| Audio Loader 1.6.1 | Not applicable | Manifest asks for `_root`; the Frame user service blocks it. |
| Wine Cellar 0.2.1 | Broken on ARM64 | Archive includes an x86_64 backend binary. The store installer blocks it before execution. |
| MagicPods 2.0.21 | Broken on ARM64 | Archive includes an x86_64 `magicpodscore` binary. The store installer blocks it before execution. |
| Decky Recorder | Not in current official store | No matching plugin appeared in the official store list on this date; no archive was installed. Frame Control's own headset panel has a separate recorder. |
The table reflects the tested versions only. Other releases can change their
files or behavior. The official store's digest checks the archive in transit;
it does not certify plugin code. Plugins run with the headset user's access.
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
### Which of these plugins can open a Framey panel?
The separate Framey panel accepts a Frame Control store install with a supported
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
A backend load does not bypass this frontend check. We downloaded the 11 versions above
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
catalogue. All 11 had a frontend bundle, but their manifests differed:
| Manifest in the checked archive | Plugins | Framey panel result |
| --- | --- | --- |
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
has each store ID, exact version, SHA-256, manifest field, and bundle check.
The archive audit itself is static PC inspection. Later local host and fake
Frame tests cover the Storage Cleaner, HLTB, CSS Loader and SteamGridDB cases noted above; none shows
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
remain the only device evidence for these versions. A new store version needs a
new check because its manifest and binaries can change.
Pinned CSS Loader 2.1.3 and SteamGridDB 1.8.1 archives now pass the fake Frame
managed install/remove flow. CSS Loader's `plugin.json` has no `api_version` and
its older IIFE bundle passes the Framey packaging check added in
[#261](https://github.com/saphid/frame-control/pull/261). The fixture does not
render its theme controls. SteamGridDB declares API v1 and its bundle passes Framey's packaging
check, but it looks up Steam library modules while loading and patches game
context menus. Those modules and menus do not exist in Framey's isolated
iframe, so its artwork picker cannot be called a usable VR panel. Framey does
not apply its Steam library changes. These checks did not run CSS themes or
modify game artwork on the real Frame.
The new store transaction was also exercised on the same real Frame:
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
store update → store remove → managed loader remove**. Status reported the
managed store ID and version while the plugin was installed. The service
remained active after each store change. The battery was 99% before and after.
The test removed its plugin folder, loader, and owned lock. This validates the
store file and service path over SSH, not a visible VR plugin page.
Frame Control's desktop app and opt-in headset panel now show official store
entries. Install, update, and remove need an explicit confirmation. They only
change Frame Control owned plugin folders under
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
back an update if the service restart fails. An update is also refused if its
new plugin name is already used by another installed plugin, because Decky
routes backend calls by name. This collision check has local tests only.
For a managed plugin with a valid recorded SHA-256, Update and Remove recheck
that installed receipt under the headset transaction lock. If another store
action changed it after the list was read, refresh the installed list and
review the action again. This race check has local transaction tests; older
receipts without a valid hash still use the existing ownership check.
The current VR shell does not provide Decky's Steam Deck Quick Access Menu.
Frame Control's headset panel
shows installed plugins and store actions. Its separate Framey iframe hosts
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
with settings, local routes, and panel toasts where supported.
Steam route patches and system notifications remain unavailable. The panel
still needs a worn-headset visual and controller check. See the
[current compatibility table](frame-plugin-author.md#framey-decky-api-compatibility).
## What was checked on the real headset
The headset was SteamOS `0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`,
`aarch64` (SteamOS 3.9.x family). SSH reached it through the existing `frame`
alias. `/` was mounted read-write as Btrfs; no root filesystem change was
needed. `systemctl --user` was running. The Steam client launched with
`-cef-enable-debugging`, `-vrgamepadui`, and `-steamdeck`; its web helper served
CEF remote debugging on `127.0.0.1:8080`. The usual
`.cef-enable-remote-debugging` marker was absent, yet debugging was active.
CEF listed a `SharedJSContext` target at `steamloopback.host/routes/library/home`
and a separate `SteamVR` target for the VR overlay. The Decky 3.3.0 ARM64
binary started from `/tmp` as the normal user: port `127.0.0.1:1337` answered
HTTP 200, the log reported `Loading Decky frontend!`, and CEF showed
`window.deckyHasLoaded === true` and one registered Decky tab in
`SharedJSContext`. The `SteamVR` target had neither that global nor a Decky tab;
its current body text did not contain Decky. The Steam web helper had
`--disable-quick-menu`. This is evidence of backend and shared-context
injection, not proof of an accessible Decky plugin menu inside VR. A QAM
interaction in the headset and a plugin UI test remain necessary.
Frame Control's per-user manager was then exercised on the real Frame:
**install → status → update → status → remove → status**. The service became
active, the binary matched Decky's published SHA-256, and removal left no
service, binary or listening port 1337. Decky's already injected JavaScript
remained in Steam's running web helper until that helper was restarted. After a
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
temporary trial files and the test's empty managed data folder were removed.
## ProtonDB Badges compatibility test
The [ProtonDB Badges v1.1.0 release](https://github.com/OMGDuke/protondb-decky/releases/tag/v1.1.0)
was tested on the same Frame OS build. It is a popular, passive Decky plugin
(133 GitHub stars at test time) that adds badges to game pages. The published
archive SHA-256 was
`b4c72ff0ffe60b1a14abafe75a62f644cbb6689f398a753a1905c281151d9173`.
Its six archive entries had no links or path traversal, and its manifest declared
`flags: []` and `api_version: 2`. The release contains a frontend bundle and
no backend `main.py`; its source backend only manages its own settings.
The test copied the archive into Frame Control's managed Decky plugin folder,
then installed the official Decky 3.3.0 ARM64 binary as a user service. Decky's
log said `Plugin ProtonDB Badges is passive` and `Loaded ProtonDB Badges`.
`DeckyPluginLoader.plugins` in Steam's `SharedJSContext` contained
`ProtonDB Badges`; `deckyHasLoaded` was true there. The separate `SteamVR`
CEF target had no Decky globals or plugin text, and SteamVR listed no Decky
overlay. The headset was in standby, so no in-headset plugin page or badge
interaction was verified. The backend's unauthenticated root URL returned
HTTP 403, as expected.
The test removed the managed Decky service and binary, the test plugin folder,
and its shared-device lock. The service was no longer running, the plugin list
was empty, the binary and unit were gone, and port 1337 was closed. Decky's
already injected frontend may remain in the running Steam web helper until
Steam normally restarts; the test did not restart Steam or SteamVR.
## Why the official Decky installer is not used
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
selects `PluginLoader-arm64` on `aarch64`, creates `~/homebrew/plugins`, writes
the CEF marker, installs `/etc/systemd/system/plugin_loader.service`, and uses
`sudo systemctl` to enable a **root** service. Its
[uninstaller](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/uninstall.sh)
also uses sudo. These scripts do not call `steamos-readonly`, but they do write
under `/etc`; their root service has broad control over the headset. The
current [Decky loader](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/backend/decky_loader/injector.py)
targets Steam's shared Gamepad UI CEF page, injects a frontend served on port
1337, and patches the standard Quick Access Menu. On Frame, Steam supplies
the CEF target, while visible VR shell integration is unresolved.
Frame Control downloads only Decky's official latest ARM64 release, requires
its published SHA-256 digest, and checks the ELF architecture. It installs a
**user** service named `frame-control-decky.service` in
`~/.config/systemd/user`, with the binary and plugin folder under
`~/.local/share/frame-control/decky`. It never asks for a sudo password, edits
`/etc`, or changes CEF flags. Decky warns that running without root is not
officially supported. Plugins that require root, assume `~/homebrew`, or rely
on Steam Deck hardware may fail even if the loader runs. Frame Control lists
plugins already present in its managed folder and the upstream `~/homebrew`
folder. The opt-in store manager installs compatible archives only into its
own folder. It cannot make Steam Deck QAM plugin pages visible in Frame VR.
The experimental [Framey panel host](framey-loader.md) can render compatible
plugin settings inside Frame Control's separate SteamVR dashboard panel.
The real headset still needs a worn-user visibility and controller check.
The latest tested release was
[Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0),
`PluginLoader-arm64` SHA-256
`8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6`.
This digest comes from that release's GitHub API asset metadata; it detects a
download mismatch, but is not an independent signature.
## Opt in and remove
In Frame Control, open **Tools → Decky Loader on Frame**, read the experimental
notice, check the acknowledgement box, and choose **Install**. **Update**
downloads and verifies the latest ARM64 release again. **Remove managed
Decky** disables and removes only Frame Control's user service and binary.
It preserves plugin files and settings so unrelated user data is not deleted.
Open **Official plugin store** to review a plugin, then confirm each install,
update, or remove. Root and x86-only plugins are blocked. The manager only
removes plugin folders that it installed; it leaves other Decky folders alone.
The desktop app and headset panel share one lock for managed Decky changes.
If another change is in progress, wait for it to finish and try again.
Restart Steam or the headset to clear the frontend already injected into its
current web helper process. No action wipes the headset.
Manual removal if Frame Control is unavailable:
```sh
ssh frame 'systemctl --user disable --now frame-control-decky.service; rm -f ~/.config/systemd/user/frame-control-decky.service ~/.local/share/frame-control/decky/PluginLoader ~/.local/share/frame-control/decky/.frame-control/receipt.json; systemctl --user daemon-reload; systemctl --user reset-failed frame-control-decky.service'
```
That command intentionally keeps `~/.local/share/frame-control/decky/plugins`
and `settings`. Check those folders before deleting them yourself. An upstream
Decky installation is separate; use its own installer to remove it. Frame
Control refuses to install alongside an upstream system service.
## Next compatibility test
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
Quick Access Menu after an opt-in install. If none appears, inspect whether
the VR shell exposes a different menu component and whether Decky's frontend
can target it without patching Steam globally. Confirm the plugin's visible
UI, controller interaction, and persistence across a normal Steam restart.
Until those checks pass,
Frame Control should not describe Decky plugins as usable in VR yet. A worn
headset check is needed for both the Decky menu and the Frame Control SteamVR
panel; only then can their controller interaction and plugin presentation be
confirmed.
+76 -9
View File
@@ -3,8 +3,9 @@
Frame Control can manage more than one Steam Frame, and each headset can be
reached at more than one address: a LAN IP at home, another at the office, its
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
tab (key 5) lists them, and the connection pill in the header shows what the
app is doing to reach the one in use, step by step, as it happens.
tab (key 5) lists them. The connection pill in the header says whether the one in
use is connected and how (Tailscale, or the network's name); click it to add,
edit or reorder that headset's addresses, or to see each step of connecting.
The code is in three modules, all stdlib-only Python on your computer:
@@ -57,7 +58,7 @@ and ranks them:
5. addresses that only ever worked on other networks;
6. Tailscale addresses while Tailscale is off.
Your order on the Devices tab breaks ties. The best-ranked address that answers
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
still trying. If SSH to the winner fails in a way another address could fix
(a different device answered there, or the link dropped), the next one that
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
services and checks `ALIAS.local` and `frame.local`.
**The pill's dialog** lists the same addresses, with what each one answered, and
can add, edit, reorder and remove them without leaving the page you're on. When
the headset reports a LAN IP on the same network as this computer and that IP
isn't saved, it offers to add it. The offer puts the address first in the list,
so on that network it wins over the Tailscale name; away from home the Tailscale
name still leads. A new or edited address is tested straight away. While
connected, **Reconnect** applies your changes now rather than at the next
connection: it tries the addresses again, ranked as above, and the best-ranked
one that answers promptly wins. It doesn't pick a particular address.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
@@ -86,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
## The connection, stage by stage
When a connection fails, the header pill keeps its compact offline state and
retry countdown; its tooltip and connection dialog contain the full reason.
Devices shows a short reason in the headset row (with the full reason in its
tooltip) and a wrapping recovery explanation in the selected headset's details.
**Use this headset** also selects that headset's details.
The connector runs in the server (`frame_link.Link`) and moves through:
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
@@ -104,7 +121,9 @@ connection is an SSH ControlMaster that every command shares; when it dies (the
headset slept or left the network) the connector notices and starts again. On
Windows, where OpenSSH can't share a connection, the same handshake runs once
and each command then connects on its own; a command that can't reach the
headset makes the connector start again.
headset makes the connector start again. There ssh's stderr goes to a file that
the connector reads as it grows, not a pipe: Windows' OpenSSH can block writing
to a redirected pipe.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
@@ -121,9 +140,57 @@ lease that moved) would look like a new host there. The app keeps one known_host
file per headset instead, so saving or forgetting one headset's key never touches
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
headset has a new key; **Forget identity** on the Devices tab lets the next
connection save the new one.
the key it shows, as Set Up Connection does: while a headset has never had an
identity here, every ssh command gets `StrictHostKeyChecking=accept-new` (the
first contact saves it, whichever command makes it), and from then on
`StrictHostKeyChecking=yes`. `accept-new` never replaces a saved key, so it
can't hide a change. The saved key's fingerprint is also recorded in
`devices.json` (`identity`), along with a flag that never clears
(`identity_established`), as soon as ssh saves the key: even if the login is then
refused. A headset counts as having had an identity if the flag is set, a
fingerprint is recorded, its pin file exists at all, or a connection to it ever
worked (older files get the flag from that when loaded, so editing or removing
the address that worked doesn't undo it); then
a pin file that's deleted, empty, unreadable or not parseable **fails closed**
(*Checking the headset's identity* fails, "missing or can't be read") rather than
trusting whatever answers, and only **Check its key…** saves a key again. It
says when the key it reads matches the recorded fingerprint.
**When the key changes** (SteamOS reinstalled or reset, a new headset at the
same address, or a different device answering there), nothing is accepted by
itself. The connection fails at *Checking the headset's identity*; the banner
and the headset on the Devices tab say so, with **Check the new key…** (also
**Check its key…** on the Devices tab at any time). That reads the key the
headset shows now, and nothing more: ssh writes it to a throwaway known_hosts
file during the key exchange, before any login, and is stopped as soon as it
has. Every other kind of contact is ruled out on the command line, ahead of
anything `~/.ssh/config` says: no authentication of any kind, no agent
(`IdentityAgent=none`, `ForwardAgent=no`), no X11 or port forwarding, no local
or remote command (`-N`), no terminal, and `-F none`. Through a ProxyJump host
it builds that hop itself, as a ProxyCommand whose ssh forwards nothing and runs
no local command (ProxyJump's own ssh would take `ForwardAgent` and
`LocalCommand` from `~/.ssh/config`); it still logs in to the jump host as
configured. Every word of that ProxyCommand is quoted for the shell that runs it
(and `%` doubled for ssh's own tokens), the forward is `[%h]:%p` so an IPv6
headset works, and an IPv6 jump host is passed bare with its port. Through a ProxyCommand from `~/.ssh/config`, or several jump hosts,
it refuses, since those can't be made safe from here: add an address it can
reach directly, or compare the key by hand. It shows the key's SHA256 fingerprint next to
the saved one, with the command that prints it on the headset
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, in Konsole on its desktop).
**This is my headset: trust its new key** then saves exactly that key, replacing
the old one, and reconnects. The server keeps the key it showed for 10 minutes
and saves it only if the page sends back the same fingerprint, and only while
the headset's port, user and that address are unchanged; it never saves whatever
answers next. The API's old `forget-identity`, which deleted the pin so that the
next contact saved any key unseen, is refused. Set Up Connection can't fix a changed key: it checks your
own `known_hosts`, not the headset's file.
**When the headset refuses this computer's key** (`Permission denied
(publickey,…)`), the connection stops at *Logging in*, and the banner and the
Devices tab offer **Set Up Connection…**, which pairs this computer again.
Neither is reported as a fault: the diagnostics report each kind at most once
a session ([privacy.md](privacy.md)).
## One server at a time
@@ -144,7 +211,7 @@ All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
| `GET /api/devices/mdns?id=` | Headsets found on this network |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `recorded`, `matches`, `recorded_match`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `forget-identity` (refused: use the two before), `name-network`, `setup` (alias, optional host), `retry` |
Every host, alias and user is checked against strict patterns before it's
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
@@ -161,7 +228,7 @@ share the format later (it still connects to one host; see
```json
{"version": 1, "active": "f67f8b7e",
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
"identity_files": ["~/.ssh/id_ed25519_frame"],
"identity_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"], "identity_established": true,
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
+54
View File
@@ -0,0 +1,54 @@
# Emulators and your own games
Frame Control's **Store → Emulators** lists Android emulators from its open
app sources. Pick an app, read its compatibility result, and install it. An
installed Android app gets its own Steam library shortcut and artwork; **Add
artwork** in its library menu can fill missing images later. The artwork
workflow also covers native Linux and Windows titles added through **Send to
Frame**. An optional SteamGridDB key under **Settings → Library artwork**
can supply exact-title community images; source images and generated art are
fallbacks. See [VR APKs → In the Steam library](vr-apks.md#in-the-steam-library)
for the limits and tested behavior.
## Set up an emulator
1. In **Store → Emulators**, choose an emulator that supports the console and
file format you want. Check its compatibility result before installing;
an app listed in the store is not automatically verified on the Frame.
2. Launch the new shortcut from Steam. Open the emulator's own settings to
choose a gamepad layout, core or system, and game folder. These controls
differ by emulator; Frame Control does not change them.
3. Use game files you own and any firmware that emulator's instructions
require. Frame Control does not provide ROMs or firmware.
**Android apps run in separate Lepton instances.** Copying a ROM to the
Frame's Linux `~/Downloads` through **Send to Frame** does not automatically
put it in an Android app's `/sdcard/Download`. A one-click ROM transfer is
deferred until an emulator and its file picker can be tested end to end on a
real Frame. An app's own import or sync feature may be enough. Advanced users
can use [Lepton's per-instance ADB port](apks.md)
to copy into that instance, after confirming the correct container and port.
Do not assume that port 5555 is the emulator: it is Lepton Development, while
installed apps receive their own ports.
For a native Linux emulator that can read the Frame's home directory, use
**Send to Frame → Copy to ~/Downloads instead** for your files, then open them
from the emulator. Whether a particular emulator has a Frame-compatible ARM64
build, correct controller mapping and readable storage must be checked for
that app. **Linux apps** in Frame Control lists Flatpak options; a Windows or
native game archive can instead be added through
[Send to Frame](sideloading.md#using-it), which registers a Steam title and
artwork. Each ROM is usually opened *inside* its emulator; Frame Control does
not create one Steam shortcut per ROM.
## If something does not appear
- Check that the emulator is running and is the same app instance to which
you sent the file. Android instances do not share `/sdcard` storage.
- Check the emulator's supported extension and whether it expects an archive
or an extracted file. Some systems need firmware before games appear.
- For an empty Steam tile, use **Add artwork** in the installed app or
sideloaded title menu. This changes artwork, not emulator data or ROMs.
The Frame's microSD/USB path into Android apps remains unverified without an
inserted medium and an app readback test; see [issue #169](https://github.com/saphid/frame-control/issues/169).
+34
View File
@@ -0,0 +1,34 @@
{
"date": "2026-10-20T03:53:25Z",
"build_id": "20260925.6191901",
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
"sibling_discovery": true,
"automatic_import": "passed",
"closed_after_import": true,
"independent_readback": [
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
],
"cleanup": {
"test_install_removed": true,
"running_instances_before": {},
"running_instances_after": {},
"battery_before": [
"100",
"Full",
"Charging"
],
"battery_after": [
"99",
"Discharging",
"Charging"
],
"shared_lock_released": true
},
"limits": [
"No headset rendering or gameplay assertion; headset unworn.",
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
"A game that exits before its Lepton instance can accept data was not tested."
]
}
@@ -0,0 +1,79 @@
# Auto-sync retry and headset-selection reliability
Date: 25 October 2026. Base: `d79efb4`, Frame Control v0.4.30.
Issue: [#214](https://github.com/saphid/frame-control/issues/214).
Environment: Linux x86_64, Python 3.12.3, Node 24.15.0.
## Reproduced problems
The existing 19 auto-sync tests passed on the base revision. Added regressions
failed before the implementation: an exhausted clip lost its error, explicit
retry did not recover failed screenshots or clips, and switches around listing
or copying continued the old pass. The original callback interface also had no
way to carry the tick's captured device into the server list reservation.
The final fake-source tests exercise these boundaries:
- Fail screenshots or clips three times, keep checking without further copy
attempts, and retain the source error plus an explicit retry instruction.
- Recover the source and use either Resume or Copy everything now without
restarting. Successful copies stay deduplicated; Resume retains the ledger
even after a local file is deleted. Copy everything now retains its documented
ability to restore deleted files and include older media.
- Reset an exhausted budget while the source still fails: another three
attempts are allowed, rather than unlimited background attempts.
- Save an exhausted item by hand and clear its stale failure on the next list.
- Switch after tick captures A, during a list, after screenshot bookkeeping,
between media kinds, before a transfer, or before transfer bookkeeping.
Interrupted passes do not mark the other headset's IDs or continue copying.
- Switch to B exactly at callback entry through the production `_sync_list`
adapter. `working(A)` refuses before calling the source list, leaving both
ledgers and copy callbacks untouched. The same production reservation blocks
a device switch during a list and rejects stale screenshot/clip transfers.
- Execute the production Settings renderer in Node: Resume is available for
copy errors and disk-full pauses, and hidden for a healthy state.
## Implementation boundary
Each list callback now receives the captured device, like the copy callbacks.
The existing server `working(meant)` mechanism checks that intended headset
under `_work_lock` while reserving work; `devices_post` uses the same lock to
refuse switches during that work. The worker also checks for interruption
around lists and transfers. No new device lock or clip destination logic is
introduced. The server diff is confined to `_sync_list`.
The failed-attempt and error maps are in-memory and reset on explicit retry or
restart. The successful-copy ledger and settings persistence retain their
existing format. Disk-full pauses, offline backoff, clip settling and the
three-attempt limit remain covered by the original tests.
## Checks
- `python3 -m unittest discover -s tests -p test_autosync.py -v`: 33 passed.
- `python3 -m unittest discover -s tests -p test_shot_copy.py -v`: 2 passed.
- `python3 -m unittest discover -s tests -p test_clips.py -v`: 34 passed,
including local ffmpeg coverage.
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
36 passed.
- `python3 -m unittest discover -s tests -p test_page_scripts.py -v`: 5 passed.
- `node --test site/test/*.test.mjs`: 2 test files passed.
- `python3 -m py_compile ui/frame_autosync.py ui/server.py tests/test_autosync.py`
and `git diff --check`: passed.
- `python3 -m unittest discover -s tests -v`: 1,525 tests run in 184.876
seconds, passed with 16 skips. Ran with sandbox escalation for loopback
sockets; test isolation still used throwaway app/SSH data and mocked headset
transport. Final output:
```text
Ran 1525 tests in 184.876s
OK (skipped=16)
```
## Limits and handoff
All new regressions use fake sources and mocked transport; they do not prove
real-headset transfer or UI rendering. No headset was accessed: its shared test
lock was held by another task. The Docker fake-Frame end-to-end suite and
Windows/macOS runs were not performed locally. The coordinator owns review and
merge; this task does not release, change PR #113, or publish announcements.
@@ -0,0 +1,38 @@
{
"date_utc": "2026-10-24T07:54:37Z",
"frame_control_tag": "v0.4.16",
"steamos_version": "0.5.6",
"steamos_build": "20261015.6141207",
"host": "lxso2",
"interface": "source UI served by a temporary private Frame Control server, opened in T3 Code's desktop browser",
"action": "Clicked the visible Export button for Steam's retained real saved clip in Capture & Live",
"clip_id": "10230640/clips/clip_13009719723623972864_20261024_072115",
"api_observed": {
"post_clips_export_http": 200,
"get_job_http": 200,
"get_clips_http": 200
},
"ui_result": "Saved clip_13009719723623972864_20261024_072115.mp4 to ~/Videos/SteamFrame; button changed to Export again",
"output": {
"path": "~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4",
"bytes": 159829,
"sha256": "f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1",
"matches_previously_fully_decoded_export": true,
"duration_seconds": 10.288532,
"video": "H.264 1920x1080",
"audio": "AAC stereo"
},
"cleanup": {
"temporary_server_stopped": true,
"shared_device_lock_released": true,
"battery_percent_after": 99,
"desktop_mode_active_after": false,
"test_title_created": false,
"steam_source_clip_retained": true
},
"limits": [
"The test used the source UI, not a packaged desktop installer.",
"The retained source clip was nearly black; visible gameplay capture remains unverified.",
"Perceptual audio/video sync, long clips, trims and automatic copying were not tested."
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"date_utc": "2026-10-24",
"version": "v0.4.17",
"steam_os_version": "0.5.6",
"steam_os_build": "20261015.6141207",
"result": "PASS: the real Steam-saved clip exported beyond 60 seconds",
"method": "Steam manual recording of a reserved test title, then Frame Control's production frame_clips.export_clip path; no packaged GUI button click",
"source": {
"kind": "saved Steam clip",
"duration_seconds": 100.801,
"chunks": 68,
"gaps": 0
},
"export": {
"format": "MP4",
"bytes": 1377849,
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
"duration_seconds_ffprobe": 100.833335,
"video": "H.264, 1920x1080, 6049 frames",
"audio": "AAC, 48000 Hz, stereo",
"full_ffmpeg_decode": "passed without errors"
},
"cleanup": "Restored Steam recording mode to its prior off setting; stopped and removed only the reserved title and its processes; no recording or running Steam apps remained; Game Mode active, battery 100%, shared test lock released",
"limits": [
"This exercised the production export code path, not a packaged GUI Export click.",
"Visible gameplay quality, perceptual audio/video sync, trims and longer recordings were not checked.",
"The newly saved Steam source clip was retained; no user recording was deleted."
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,94 @@
{
"date_utc": "2026-10-24T09:08:35.523Z",
"result": "PASS: packaged Electron visible Export click saved the retained real Steam clip beyond 60 seconds",
"frame_control_tag": "v0.4.17",
"documentation_base_commit": "4e456480aefbe541e334dbd247328537f535bd94",
"host": "lxso2",
"steamos_version": "0.5.6",
"steamos_build": "20261015.6141207",
"package": {
"asset": "Frame-Control-linux-x86_64.AppImage",
"url": "https://github.com/saphid/frame-control/releases/download/v0.4.17/Frame-Control-linux-x86_64.AppImage",
"sha256": "b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d",
"launch": "Verified release asset; --appimage-extract, then unmodified squashfs-root/AppRun. Electron used the bundled server and Python, not checkout source.",
"display": "Xvfb :137, 1440x1000x24; Electron window 1400x950 including native chrome",
"flags": [
"--no-sandbox",
"--disable-gpu",
"--remote-debugging-port=49371",
"--remote-debugging-address=127.0.0.1",
"--user-data-dir=/tmp/frame-packaged-export-20261024/electron-profile"
],
"isolation": "Throwaway XDG_CONFIG_HOME, FRAME_CONTROL_DATA_DIR and Electron profile; telemetry and update checks disabled; existing SSH identity and normal home Videos destination used."
},
"ui": {
"navigation": "Dismissed optional email prompt with No thanks; clicked Tools, then Capture & Live; scrolled Steam clips into view.",
"row": "Steam recording \u00b7 Clip; Oct 24, 8:53 AM \u00b7 1:40 \u00b7 2 MB",
"action": "Playwright connected to this Electron instance over local CDP; locator.click() clicked its visible button named exactly Export. The test did not directly invoke an export function or API POST.",
"button": "button.small[data-clip-export=\"0\"]",
"click_is_trusted": true,
"button_bounds_css_pixels": {
"x": 1300.390625,
"y": 701.15625,
"width": 61.609375,
"height": 28
},
"post_clips_export_http": 200,
"job": "0ab20b1788adce63",
"job_poll_http": 200,
"saved_message": "Saved clip_15658413054201167872_20261024_085343.mp4 to ~/Videos/SteamFrame",
"final_row": "ON PC; Export again",
"screenshots": [
"clip-packaged-export-2026-10-24-before.png",
"clip-packaged-export-2026-10-24-after.png"
],
"screenshot_scope": "Cropped to Steam clip rows and saved notification; unrelated screenshot thumbnails and host connection identity excluded."
},
"source": {
"clip_id": "10230640/clips/clip_15658413054201167872_20261024_085343",
"duration_seconds": 100.801,
"duration_provenance": "Retained real Steam clip from clip-long-2026-10-24.json; current packaged UI displays 1:40."
},
"output": {
"path": "~/Videos/SteamFrame/clip_15658413054201167872_20261024_085343.mp4",
"bytes": 1377849,
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
"matches_prior_production_export_size_and_sha256": true,
"duration_seconds_ffprobe": 100.833335,
"duration_difference_from_steam_seconds": 0.032335,
"video": "H.264, 1920x1080, 6049 decoded frames",
"audio": "AAC, 48000 Hz, stereo, 4724 decoded audio frames",
"validation": [
"ffprobe -v error -count_frames -show_entries format=duration,size:stream=codec_name,codec_type,width,height,sample_rate,channels,nb_read_frames -of json <output>: exit 0",
"sha256sum <output>: exact match to prior production export",
"ffmpeg -v error -xerror -i <output> -f null -: exit 0, no errors"
]
},
"cleanup": {
"shared_lock": "Acquired with ssh frame mkdir /tmp/frame-test.lock after read-only battery/build/lock checks; released with rmdir after app shutdown; absence read back.",
"battery_percent_before": 99,
"battery_percent_after": 99,
"battery_status": "Not charging; charger Full",
"packaged_app_and_server_exited": true,
"test_xvfb_stopped": true,
"frame_export_cache_directories_remaining": 0,
"ffmpeg_processes_after": 0,
"game_mode_and_steamvr_running_after": true,
"steam_source_clip_retained": true,
"user_recordings_deleted": false,
"headset_install_or_settings_changes": false,
"local_export_retained": true
},
"limits": [
"The unchanged AppImage payload ran after extraction on a virtual X desktop, with Chromium sandbox and GPU disabled. FUSE mounting, desktop integration and hardware rendering were not tested.",
"The click was automated trusted pointer input in a visible Electron window on Xvfb, not a human click on a physical monitor. T3 collaborative preview opened successfully but drives separate browser tabs; Electron-native automation was used for this window.",
"No new game or Steam recording was started. Visible gameplay quality, audible content and perceptual audio/video sync were not assessed.",
"Clips longer than this retained 100-second clip, trims and automatic copying remain unverified.",
"No release, tag or application code was changed."
],
"validation_local": {
"clip_regression_suite": "python3 -m unittest discover -s tests -p test_clips.py -v: 34 tests passed in 20.173s, including real ffmpeg long-recording export",
"json": "python3 -m json.tool: passed",
"diff": "git diff --check: passed"
}
}
@@ -0,0 +1,95 @@
{
"date": "2026-10-24",
"command": "scripts/frame-power-sample.sh --count 2 --interval 1",
"exitCode": 0,
"stopped": null,
"lockPolicy": "Existing sampler checked local and remote /tmp/frame-test.lock before reading; no lock acquired, bypassed or removed. Read-only run only.",
"samples": [
{
"time": 1792828506.4317346,
"osBuild": "20261015.6141207",
"elapsedSeconds": 0.057539,
"battery": {
"battery.percent": {
"value": 99,
"unit": "%",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.status": {
"value": "Not charging",
"unit": "state",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.watts": {
"value": -0.06,
"unit": "W",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.tempC": {
"value": 24.0,
"unit": "C",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
}
}
},
{
"time": 1792828507.4357972,
"osBuild": "20261015.6141207",
"elapsedSeconds": 1.061612,
"battery": {
"battery.percent": {
"value": 99,
"unit": "%",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.status": {
"value": "Not charging",
"unit": "state",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.watts": {
"value": -0.05,
"unit": "W",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.tempC": {
"value": 24.0,
"unit": "C",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
}
}
}
],
"limitations": [
"No real low-charge or thermal condition induced.",
"No native notification delivery verified.",
"No worn-headset or native package tests.",
"Read-only parser/transport check; no background monitor or power-saving claim."
],
"browserCheck": {
"page": "Production ui/index.html served statically; API calls intentionally unavailable",
"viewport": {
"width": 1280,
"height": 800
},
"bridge": "Synthetic desktop notification bridge; no native OS notification sent",
"result": "Initially unchecked and visible only after desktop host/bridge setup. Checkbox enabled through browser. Synthetic 12%, Discharging, -4 W samples at 0/30/60 seconds produced one bridge request, an in-page status and a toast. No horizontal overflow.",
"apiLimit": "Static server returned expected 404/501 API errors; not an end-to-end live desktop connection test."
},
"offlineValidation": [
"test_desktop_battery_comfort.py: 2 tests, production JS with monotonic fake clock and notification bridge",
"test_comfort*.py: 25 tests",
"test_battery.py: 7 tests",
"test_power*.py: 36 tests (loopback HTTP test run outside socket-restricted sandbox)",
"test_ui_workspace.py: 1 Python wrapper / Node workspace suite",
"test_ui_review.py: 1 Python wrapper / 9 Node regression tests, including switch reset and retained cooldown"
]
}
@@ -0,0 +1,206 @@
{
"date_utc": "2026-10-24",
"base_commit": "de931bc",
"issue": 73,
"result": "Packaged Desktop Capture passed for a mapped nested KDE compositor on the real Frame; wearer-visible overlay condition was not established. Do not close #73 from this test.",
"package": {
"tag": "v0.4.17",
"asset": "Frame-Control-linux-x86_64.AppImage",
"sha256": "b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d",
"release_digest_matched": true,
"panel_helper_matches_base": true,
"unmodified_payload": true,
"host": "lxso2",
"display": "Xvfb :139",
"flags": [
"--no-sandbox",
"--disable-gpu"
],
"private_profile_and_data": true,
"telemetry_and_update_checks_disabled": true
},
"preflight": {
"steamos_version": "0.5.6",
"build_id": "20261015.6141207",
"battery_percent": 99,
"battery_status": "Not charging",
"game_mode": true,
"full_plasma_session": false,
"nested_desktop_running": false,
"nested_runtime_present": false,
"lock_present": false,
"steam_pid": 297826,
"vrserver_pid": 297506,
"vrcompositor_pid": 297620,
"gamescope_service_invocation": "e8c1085b3bbd4e79a323b3e4a9bc6355",
"focusable_windows": [],
"focused_windows": []
},
"session": {
"started_utc": "2026-10-24T09:43:48Z",
"lock_acquired": true,
"launch": [
"systemd-run",
"--user",
"--unit=fc-issue73-desktop-20261024",
"--collect",
"--property=RuntimeMaxSec=180s",
"--property=TimeoutStopSec=15s",
"--property=KillMode=control-group",
"--setenv=DISPLAY=:0",
"--setenv=XDG_RUNTIME_DIR=/run/user/1000",
"--setenv=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus",
"--setenv=XDG_SESSION_TYPE=x11",
"--setenv=XDG_CURRENT_DESKTOP=gamescope",
"/usr/bin/steamos-nested-desktop"
],
"response": "Running as unit: fc-issue73-desktop-20261024.service; invocation ID: 92cff1e57c794212997d46c0a68877ec\n"
},
"discovery": {
"focus": 62914560,
"focus_display": ":0",
"panels": [
{
"window": 62914560,
"display": ":0",
"name": "KDE Wayland Compositor - Press right control key to grab input",
"x": 0,
"y": 0,
"width": 1920,
"height": 1080,
"app": 0,
"focused": true
}
]
},
"window_evidence": {
"xwininfo_map_state": "IsViewable",
"width": 1920,
"height": 1080,
"focused_after_show_request": true,
"steamvr_overlay_key": "valve.steam.desktopgame.0",
"steamvr_overlay_visibility_after_show_request": "not_visible"
},
"capture": {
"repeat_utc": "2026-10-24T09:44:47.323Z",
"route": "/api/screenshot?view=panel&window=62914560&display=%3A0",
"http": 200,
"content_type": "image/png",
"content_length": 1209980,
"source_header": "panel",
"decoded": {
"width": 1920,
"height": 1080,
"source": "panel",
"selected": {
"window": 62914560,
"display": ":0",
"name": "KDE Wayland Compositor - Press right control key to grab input",
"x": 0,
"y": 0,
"width": 1920,
"height": 1080,
"app": 0,
"focused": true
},
"stamp": "9:44:48 AM",
"badge": "Desktop \u00b7 KDE Wayland Compositor - Press right control key to grab input"
},
"canvas_png_bytes": 2011278,
"first_capture_pixel_statistics": {
"width": 1920,
"height": 1080,
"min": 2.3333333333333335,
"max": 255,
"hidden": false
},
"first_capture_click_event": {
"isTrusted": true,
"utc": "2026-10-24T09:44:12.738Z"
},
"actions": "Two Playwright locator.click() actions on the visible #shotBtn in the actual packaged Electron window; first click recorded isTrusted=true. No direct screenshot API invocation by harness."
},
"device_cleanup": {
"utc": "2026-10-24T09:45:33Z",
"before_stop": {
"exit": 0,
"out": "MainPID=300739\nControlGroup=/user.slice/user-1000.slice/user@1000.service/app.slice/fc-issue73-desktop-20261024.service\nActiveState=active\n",
"err": ""
},
"hide_dashboard": {
"exit": 0,
"out": "HideDashboard",
"err": ""
},
"stop_owned_unit": {
"exit": 0,
"out": "",
"err": ""
},
"after_stop": {
"exit": 0,
"out": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
"err": ""
},
"desktop_processes": {
"exit": 1,
"out": "",
"err": ""
},
"nested_runtime_exists": false,
"session": {
"exit": 0,
"out": "Id=gamescope-session.target\nActiveState=active\nInvocationID=a2fee8ebf55c42c7b546390d14cd985c\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=\n\nId=gamescope-session.service\nActiveState=active\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n",
"err": ""
},
"steam": {
"exit": 0,
"out": "297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n",
"err": ""
},
"steamvr": {
"exit": 0,
"out": "297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n297620 /opt/steamvr/bin/linuxarm64/vrcompositor\n",
"err": ""
},
"xprops": {
"exit": 0,
"out": "GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \nGAMESCOPE_FOCUSED_WINDOW(CARDINAL) = \n",
"err": ""
},
"battery": {
"capacity": "99",
"status": "Not charging"
},
"owned_lock_released": true
},
"host_cleanup": {
"electron": {
"pid": 1578431,
"exists_after": false
},
"xvfb": {
"pid": 1578428,
"exists_after": false
},
"x_socket_absent": true
},
"delayed_final_readback": "2026-10-24T09:46:39Z\nshared-lock-absent\nLoadState=not-found\nActiveState=inactive\nId=gamescope-session.target\nActiveState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n297620 /opt/steamvr/bin/linuxarm64/vrcompositor\nnested-runtime-absent\n100\nFull\nGAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \nGAMESCOPE_FOCUSED_WINDOW(CARDINAL) = \n",
"validation": {
"focused_panel_suite": "42 tests passed in 14.805s (test_touch.py), including production-page Node behavior tests",
"website": "2 tests passed (node --test site/test/*.test.mjs)",
"node_panels": "Passed independently (node tests/page/desk_panels.mjs)",
"metadata_and_links": "JSON parsed; result/cleanup assertions and relative evidence/streaming/testing links passed",
"diff": "git diff --check passed"
},
"limits": [
"SteamVR continued reporting not_visible after ShowDashboard, despite mapped X window and confirmed focus. No worn-headset visual assessment; does not establish the reporter's visible-overlay condition.",
"SteamOS 0.5.6 only; reporter used 0.4.5 with unknown Frame Control version and no reproduction steps.",
"Nested desktop launched with SteamOS steamos-nested-desktop from a bounded transient user unit using Steam display/session variables; not launched through a wearer's Steam UI.",
"Extracted unchanged AppImage on a virtual X display with Chromium sandbox and GPU disabled; no FUSE, physical monitor or hardware-rendering test.",
"No Live video, Control input or full Desktop Mode test.",
"Nested-desktop journal included gamescope WSI/Vulkan-hook warnings. No warning was dismissed; captured compositor pixels were nonblank.",
"Initial CDP response.body() yielded zero bytes despite successful image decode. Repeat verified Content-Length, fresh decoded image, panel selection and PNG export from canvas; the initial raw-body size is not treated as capture failure or proof.",
"No private desktop pixels, issue screenshot or raw desktop journal committed."
]
}
@@ -0,0 +1,100 @@
# Packaged Desktop Capture, 2026-10-24
Base: `de931bc`. Package: Linux x86_64 **v0.4.17**. Device: real Frame,
SteamOS **0.5.6**, BUILD_ID **20261015.6141207**. Host: lxso2.
[Sanitized state and response evidence](desktop-capture-packaged-2026-10-24.json).
## Result and issue boundary
The packaged app discovered the nested KDE compositor and both automated
Desktop **Capture** button clicks succeeded. The repeat returned HTTP 200,
`Content-Type: image/png`, `X-Capture-Source: panel`, and Content-Length
1,209,980. The app decoded a fresh, nonblank 1920×1080 image and displayed
the Desktop panel badge. No further product failure was reproduced or fixed.
This does **not** establish the full visible-desktop condition in
[#73](https://github.com/saphid/frame-control/issues/73). `xwininfo` reported
the desktop window `IsViewable`, and gamescope confirmed focus after
`vrcmd --showdashboard valve.steam.desktopgame.0`. However, SteamVR still
reported that overlay `not_visible`. There was no worn-headset visual check.
The observation proves capture of a mapped nested compositor, not its visible
presentation to a wearer. Do not close #73 from this evidence.
The issue screenshot shows a desktop panel in the headset view while Desktop
Capture logs “no app panel is open in the headset.” The reporter used SteamOS
0.4.5, gave no Frame Control version or reproduction steps, and has not replied
to the maintainer's 0.4.3 update request. The duplicate page function was already
fixed; [PR #152](https://github.com/saphid/frame-control/pull/152) subsequently
improved unknown-state diagnostics. Its [earlier investigation](panel-discovery-2026-10-24.md)
was blocked from starting a desktop by another task's lock. This run adds a
current-package capture observation, without reproducing the older failure.
## Procedure
1. Read-only preflight found Game Mode active, full Plasma inactive, Steam and
SteamVR running, battery 99%, no nested desktop/runtime and no test lock.
2. Compared the retained AppImage's SHA-256 with the live v0.4.17 release asset
digest: `b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d`.
Extracted an independent copy; `cmp` confirmed its `frame_touch.py` matches
the base checkout. No payload was edited.
3. Started the package's own AppRun, bundled server and Python on Xvfb `:139`
with private config/cache/data/profile, telemetry and update checks disabled,
and `--no-sandbox --disable-gpu`. Used the existing authorized `ssh frame`
identity. T3 preview opened the package server page but does not attach to
the existing Electron window. The literal Electron button test used the
same Playwright-over-loopback-CDP workflow as the earlier packaged tests.
4. Acquired `/tmp/frame-test.lock` with atomic `mkdir` and wrote an owner marker.
Rechecked no nested runtime or Plasma was present. Started SteamOS's
`/usr/bin/steamos-nested-desktop` in the owned transient user unit
`fc-issue73-desktop-20261024.service`, using Steam's display/session variables.
`RuntimeMaxSec=180s`, `TimeoutStopSec=15s` and `KillMode=control-group` bounded
the session. This launches a desktop panel inside Game Mode; no full Desktop
Mode switch was sent.
5. In the actual packaged Electron window, navigated Tools → Capture & Live →
Desktop and clicked Capture with `locator.click()`. The first click event
recorded `isTrusted=true`. The page selected the KDE compositor on `:0`;
the production screenshot path used window `62914560`. Requested the desktop
dashboard, read back focus/visibility, and clicked Capture again. Recorded
response metadata, fresh decoded image dimensions and canvas pixel range.
No direct screenshot API call was substituted for these button clicks.
6. Ran the packaged `frame_touch.py panels` read-only over SSH to retain its
exact discovery result: one focused KDE compositor panel, app ID 0, 1920×1080,
display `:0`, with no diagnostic `why` field.
## Cleanup and limits
The owned desktop session ran **09:43:48–09:45:33 UTC** (105 seconds). Hid the
dashboard to restore its initial hidden state, stopped only the owned unit,
and verified it was unloaded/inactive with MainPID 0. No Plasma/KWin desktop
process or nested runtime remained; gamescope's focusable/focused lists were
empty again. Released only this task's lock, after checking its owner marker.
Delayed readback at **09:46:39 UTC** confirmed the lock absent, Game Mode active,
full Plasma inactive and the original Steam, vrserver and vrcompositor PIDs
still running. The Game Mode service invocation was unchanged. Battery was
99% at cleanup and 100%/Full on the delayed check. Closed both owned preview
tabs, Electron and Xvfb; their processes and X socket were gone. No test server
remained. No reboot, installation, security/global setting change or full
Desktop Mode transition was performed.
The nested desktop journal included gamescope WSI/Vulkan-hook warnings. No
warning was dismissed. Capture produced nonblank compositor pixels, but this
run does not assess their usability to a wearer. Private image files stayed
outside the repository; only state and response metadata are published.
The initial CDP `response.body()` read yielded zero bytes despite successful
decoding in the page. The repeat verified the PNG Content-Length, fresh image
dimensions/source/selection and a 2,011,278-byte PNG exported from the canvas.
The initial raw-body measurement is a harness limitation, not evidence of a
product capture failure. No Live video, Control input, physical monitor, FUSE
mounting or hardware rendering was tested. Older SteamOS and other packages
remain outside this observation.
## Offline verification
`python3 -m unittest discover -s tests -p test_touch.py -v`: **42 tests passed**,
including the production page's Node panel behavior tests, duplicate-function
guard, absent versus empty panel diagnostics, alternate displays, capture/input
target validation and discovery deadlines. JSON parsing and `git diff --check`
were also checked. No new regression test was added because this is an
evidence-only change with no reproduced product defect.
@@ -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,20 @@
# API v1 panel check, 25 October 2026
The unmodified official Storage Cleaner 1.4.0 archive (store ID 50, SHA-256
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`)
declares `api_version: 1`, but its bundled frontend connects to Decky's modern
frontend API. Framey admitted that bundle from a local store receipt and
rendered it in Chromium inside an iframe with `sandbox="allow-scripts
allow-popups"`.
A test parent returned empty lists and `0B` for the plugin's two list and two
size backend calls. Chromium reported `framey:ready`, displayed Shader Cache
and Compatibility Data sections, and showed all four cleanup buttons disabled.
No cleanup action was invoked. The backend was a stub; the test did not run the
plugin's Python code or connect to a headset.
This confirms a panel shell for this exact archive with empty data. Storage
Cleaner may remove compatibility data and local game saves when used with a
real backend. Its cleanup actions, game names, SteamClient app details, VR
visibility, and controller input remain unverified on Frame. Do not infer that
its deletion controls are safe or complete in VR from this browser check.
@@ -0,0 +1,384 @@
{
"schemaVersion": 1,
"timeUtc": "2026-10-26T04:05:15.145624+00:00",
"baseCommit": "56bde24",
"frameOS": "0.5.6",
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"scope": "Local Chromium opaque Framey panels, production SSH/CDP bridge and actual unmodified official backends in ARM64 Decky; real user audio session, silent test stream only; no worn VR",
"plugins": [
{
"id": 38,
"name": "Volume Mixer",
"version": "1.2.1-1",
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3"
},
{
"id": 58,
"name": "Volume Boost",
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc"
},
{
"id": 144,
"name": "Mixer",
"version": "0.1.0",
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106"
}
],
"readOnlySession": {
"timeUtc": "2026-10-26T04:03:54.509107+00:00",
"before": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"volumeMixerLogExists": false,
"defaultSinkVolume": {
"code": 0,
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
"stderr": ""
},
"pactlInfoExit": 0,
"pwDumpExit": 0
},
"after": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"volumeMixerLogExists": false,
"defaultSinkVolume": {
"code": 0,
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
"stderr": ""
},
"pactlInfoExit": 0,
"pwDumpExit": 0
},
"calls": [
{
"id": 38,
"method": "get_sink_inputs",
"args": [
{}
],
"legacy": true,
"version": "1.2.1-1",
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
"result": {
"success": true,
"streamCount": 1,
"volumes": [
1
]
}
},
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"legacy": true,
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
"result": {
"result": {
"result": 0,
"success": true
}
}
},
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "right"
}
],
"legacy": true,
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
"result": {
"result": {
"result": 0,
"success": true
}
}
},
{
"id": 144,
"method": "get_settings",
"args": [],
"legacy": false,
"version": "0.1.0",
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
"result": {
"result": {
"filters": [],
"boostLimit": 0
}
}
},
{
"id": 144,
"method": "get_playbacks",
"args": [],
"legacy": false,
"version": "0.1.0",
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
"result": {
"count": 6,
"internalAudioNodeCount": 5,
"steamStreamCount": 1
}
}
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"fixedLogAbsent": true
},
"lockReleased": true
},
"liveSilentStream": {
"before": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"volumeMixerLogExists": false,
"defaultSinkVolume": {
"code": 0,
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
"stderr": ""
},
"pactlInfoExit": 0,
"pwDumpExit": 0
},
"after": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "failed",
"panel": "inactive",
"volumeMixerLogExists": false,
"defaultSinkVolume": {
"code": 0,
"stdout": "Volume: front-left: 0 / 0% / -inf dB, front-right: 0 / 0% / -inf dB\n balance 0.00\n",
"stderr": ""
},
"pactlInfoExit": 0,
"pwDumpExit": 0
},
"source": "paplay --raw --format=s16le --rate=48000 --channels=2 --client-name=FrameySilentARM64 --stream-name=\"Framey ARM64 silent verification\" /dev/zero",
"writes": [
{
"id": 38,
"method": "set_input_volume",
"args": [
{
"index": 31400,
"volume": 0.5
}
],
"result": {
"result": true,
"success": true
},
"independentReadback": [
{
"index": 31400,
"name": "FrameySilentARM64",
"volume": {
"front-left": {
"value": 32768,
"value_percent": "50%",
"db": "-18.06 dB"
},
"front-right": {
"value": 32768,
"value_percent": "50%",
"db": "-18.06 dB"
}
}
}
]
},
{
"id": 38,
"method": "set_input_volume",
"args": [
{
"index": 31400,
"volume": 0.45
}
],
"result": {
"result": true,
"success": true
},
"independentReadback": [
{
"index": 31400,
"name": "FrameySilentARM64",
"volume": {
"front-left": {
"value": 29491,
"value_percent": "45%",
"db": "-20.81 dB"
},
"front-right": {
"value": 29491,
"value_percent": "45%",
"db": "-20.81 dB"
}
}
}
]
},
{
"id": 144,
"method": "set_volume",
"args": [
199,
37
],
"result": true,
"independentReadback": [
{
"index": 31400,
"name": "FrameySilentARM64",
"volume": {
"front-left": {
"value": 24248,
"value_percent": "37%",
"db": "-25.91 dB"
},
"front-right": {
"value": 24248,
"value_percent": "37%",
"db": "-25.91 dB"
}
}
}
]
},
{
"id": 144,
"method": "set_volume",
"args": [
199,
38
],
"result": true,
"independentReadback": [
{
"index": 31400,
"name": "FrameySilentARM64",
"volume": {
"front-left": {
"value": 24904,
"value_percent": "38%",
"db": "-25.21 dB"
},
"front-right": {
"value": 24904,
"value_percent": "38%",
"db": "-25.21 dB"
}
}
}
]
}
],
"volumeMixerReopen": {
"id": 38,
"method": "get_sink_inputs",
"args": [
{}
],
"result": {
"result": [
{
"name": "FrameySilentARM64",
"index": 31400,
"volume": 0.3800048828125
}
],
"success": true
}
},
"mixerReadAfterChange": {
"id": 144,
"method": "get_playbacks",
"args": [],
"result": [
{
"id": 199,
"name": "FrameySilentARM64",
"mediaName": "Framey ARM64 silent verification",
"volume": 38
}
]
},
"volumeBoostReads": [
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
}
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"fixedLogAbsent": true
},
"lockReleased": true
},
"independentCleanupAudit": {
"testPathsAbsent": true,
"units": {
"framey-audio-kp0z857q58-32363fd7": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
"framey-audio-kp0z857q58-c08f3f5d": "MainPID=0\nLoadState=not-found\nActiveState=inactive",
"framey-audio-kp0z857q58-c08f3f5d-silence": "MainPID=0\nLoadState=not-found\nActiveState=inactive"
},
"silentStreamsAbsent": true
},
"limitations": [
"Physical audible output, default-sink writes, boost above 100%, Bluetooth playback controls and worn VR unverified.",
"Mixer also exposes internal Frame PipeWire nodes and Reset; neither was changed or invoked.",
"Private Decky/installed-plugin outer status fixture; real RPC used production framey_panel.call_method over SSH and live Steam CEF, not a deployed headset HTTP endpoint test.",
"Mixer filter save/restart was verified on amd64 in the earlier evidence, not repeated on ARM64."
],
"catalogRefresh": {
"timeUtc": "2026-10-26T04:11:59.449535+00:00",
"visibleCount": 116,
"idsVersionsDigestsUnchanged": true
}
}
@@ -0,0 +1,111 @@
# Official audio plugins on the ARM64 Frame
Volume Mixer 1.2.1-1 (38) and Mixer 0.1.0 (144) now qualify as **works for
per-app volume control**. Their exact official, unmodified Python backends ran
in published Decky 3.3.0 ARM64 on Frame OS 0.5.6. Local Chromium rendered the
actual Framey panels and changed only a deliberately created silent playback
stream in the Frame's real user audio session. Independent Pulse readbacks
confirmed the changes. This is useful backend behavior, not initializer-only
or fake-backend evidence.
Volume Boost 0.0.7 (58) remains **partial**. Its real ARM64 default-sink reads
matched independent `pactl` reads, and its controls rendered. Default-sink writes
were denied throughout the real-Frame run. Its earlier slider/numeric write
checks used an isolated amd64 virtual sink.
[Digest-pinned observations and cleanup](framey-audio-arm64-2026-10-26.json)
record both runs. No physical audible output or worn-VR result is claimed.
| Plugin | Real Frame result | Remaining limits |
| --- | --- | --- |
| Volume Mixer | `pulsectl`/`ravel` import; real Pulse session discovery; silent-stream slider click 100 → 50%, ArrowDown → 45%; reopen reads back the 38% subsequently set by Mixer | Bluetooth discovery/playback commands, audible output and worn-VR input unverified. The range input rounds the reopened 0.38000488 backend value to its configured step; no extra write occurred. |
| Volume Boost | `pactl` default sink and stereo channel parsing; both backend reads return 0 and agree with independent 0% / 0% sink reads; local panel shows 0 | No real default-sink writes or boosts; no audible output or worn-VR result. Zero alone is not proof: upstream returns 0 when its command fails. |
| Mixer | `pw-dump`/`wpctl` available; real playbacks/settings reads; exact silent-stream numeric input 37%, ArrowUp → 38%, independently read back | Exposes internal Frame PipeWire nodes as well as apps. Internal-node controls and Reset were not exercised. Filter persistence passed on amd64 earlier, not ARM64. Audible output and worn-VR input unverified. |
## Runtime and transport
The normal atomic `/tmp/frame-test.lock` was acquired separately for each run.
An existing owner would abort testing; the lock was never stolen. The headset
was not woken, and no power setting was changed.
The actual Frame UID is 1000. The legacy plugins' hard-coded
`XDG_RUNTIME_DIR=/run/user/1000` therefore reaches this Frame's existing audio
session. Volume Mixer's bundled pure Python wrappers successfully loaded the
system ARM64 Pulse and D-Bus libraries. No native rebuild was needed. It also
sets `HOME=/home/deck` inside its own plugin process; this test does not establish
that arbitrary Deck paths work on the Frame.
The upstream `PluginLoader-arm64` release was verified against GitHub's
published SHA-256 and ELF machine 183 before transfer. Two private, bounded,
removable user-service installs used their own Decky plugins/settings bases.
Neither service was enabled. The normal managed Decky service was already
failed, and the normal headset-panel service inactive; both states remained
unchanged. A pre-existing `/tmp/VolumeMixer-decky.log` would have aborted the
run because the upstream plugin truncates that fixed path during import.
The local relay served actual `ui/headset.html`, vendored React, `framey-host.js`
and digest-checked official frontend bytes in an opaque `allow-scripts` iframe.
Only outer status/install responses were fixtures. Plugin RPC used production
`framey_panel.call_method`, SSH through lxso2, `frame_steam.Page`, the live Steam
CEF `DeckyBackend.call`, and actual Decky plugin subprocesses. Private install
paths were redirected to the temporary base. No second Decky websocket client
was attached. This does not verify the deployed headset HTTP endpoint or a VR
compositor interaction.
## Safe control check
The first run was read-only. Independent `pactl info`, default-sink volume and
`pw-dump` commands exited successfully, so empty/zero fallback values were not
accepted on their own. Volume Mixer saw a live Steam stream; Mixer also saw
Frame spatialization, echo-cancellation and ALSA internal nodes.
For the second run, a separate task-owned transient user service ran:
```sh
paplay --raw --format=s16le --rate=48000 --channels=2 \
--client-name=FrameySilentARM64 \
--stream-name='Framey ARM64 silent verification' /dev/zero
```
This sends zero-valued samples to the existing user server. It is an explicit
silent client, not the earlier private null-server fixture. No server, device,
default-route or physical-sink configuration was changed. The relay resolved
its live stream IDs before every write and allowed only this exact application
name, bounded to 80% volume. The spatialization node with a similar prefix was
excluded. All Volume Boost writes, Mixer Reset/settings writes and Bluetooth
commands were denied. Existing streams and internal nodes were never write
targets. This broader real-session check supersedes the earlier suggested
null-server-only ARM64 follow-up; that older fixture could not prove access to
the Frame's real session.
Every browser-triggered volume RPC was followed by independent
`pactl --format=json list sink-inputs` channel readback. Only the named silent
stream's rows are published. Background streams can appear/disappear naturally;
no whole-session stability claim is made.
## Cleanup and catalog interpretation
Both runs removed their private Decky units/bases and the test-created fixed
plugin log. The live run also stopped its transient silent client. Decky's main
process exited during shutdown but left children waiting; those remaining
processes were killed only within the test service's systemd cgroup before
cleanup completed. Both locks were released after cleanup. An independent
fresh-lock audit confirmed all three test units unloaded/inactive with PID 0,
private paths absent, and no named silent stream. Battery was 100 → 100 and
independent default-sink reads stayed 0% / 0% in both runs.
The canonical store data and generated compatibility table now report
**3 works / 48 partial / 54 broken / 11 N/A**. “Works” describes the tested
per-app control behavior; read each row's remaining limits. Historical
[fake-Frame archive checks](framey-module-lookup-2026-10-26.md) cover transport
with plugin execution stubbed, and the
[earlier local audio checks](framey-audio-backends-2026-10-26.md) cover isolated
amd64 controls/filter persistence. Neither is relabelled as a real ARM64 test.
## Repository checks
The full Python suite passed 1,621 tests (16 skips), and the focused Framey
suite passed all 43 tests. `scripts/framey-compatibility-doc.py --check` and
`git diff --check` passed. A fresh official-store fetch at 04:11:59 UTC found
all 116 IDs, versions and digests unchanged. This PR changes catalog/evidence
and generated documentation; it adds no backend shim or plugin patch.
@@ -0,0 +1,677 @@
{
"schemaVersion": 1,
"timeUtc": "2026-10-26T01:30:56.939937+00:00",
"baseCommit": "442b5ec",
"hostChange": "SliderField editableValue numeric input",
"verification": "local Chromium + real Decky 3.3.0 amd64 container; no real Frame/worn VR",
"decky": {
"tag": "v3.3.0",
"name": "PluginLoader",
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
},
"audioVersions": {
"pipewire": "1.4.2",
"wireplumber": "0.5.8",
"pactl": "17.0"
},
"plugins": [
{
"id": 38,
"name": "Volume Mixer",
"version": "1.2.1-1",
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
"folder": "VolumeMixer-decky"
},
{
"id": 58,
"name": "Volume Boost",
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
"folder": "volume-boost"
},
{
"id": 144,
"name": "Mixer",
"version": "0.1.0",
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
"folder": "decky-volume-mixer"
}
],
"isolation": {
"network": "none",
"devices": "none",
"hostAudioSockets": "none",
"audio": "private PipeWire/Pulse null sink; two silent streams; WirePlumber policy-only"
},
"fixtureBoundaries": [
"Outer headset status/installed-plugin responses were fixture-shaped. Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe and unmodified official bundles ran in Chromium.",
"The relay used Decky 3.3.0 official websocket RPC routes directly inside the isolated container; Steam CEF and the production SSH/CDP method transport were not part of this audio run.",
"Actual published amd64 Decky binary and official unmodified Python backends executed. No ARM64 or real Frame audio claim.",
"Previous full-catalog fake-Frame evidence tests archive/install/remove transport only; its backends remain stubbed."
],
"sharedHeadsetLock": {
"acquired": false,
"owner": "delightvr-r4-a56d43b4c0",
"headsetReadsOrChanges": false
},
"audioBefore": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "45%",
"front-right": "50%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
},
"mutatingCalls": [
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 75,
"channel": "both"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "75%",
"front-right": "75%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 74,
"channel": "both"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "74%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 73,
"channel": "both"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "73%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 75,
"channel": "right"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "75%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 74,
"channel": "right"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
}
]
}
},
{
"id": 38,
"method": "set_input_volume",
"args": [
{
"index": 46,
"volume": 0.5
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "50%",
"front-right": "50%"
}
}
]
}
},
{
"id": 38,
"method": "set_input_volume",
"args": [
{
"index": 46,
"volume": 0.45
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "45%",
"front-right": "45%"
}
}
]
}
},
{
"id": 144,
"method": "set_volume",
"args": [
41,
37
],
"result": true,
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "37%",
"front-right": "37%"
}
}
]
}
},
{
"id": 144,
"method": "set_volume",
"args": [
41,
38
],
"result": true,
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "73%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "38%",
"front-right": "38%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 42,
"channel": "left"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "42%",
"front-right": "74%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "38%",
"front-right": "38%"
}
}
]
}
},
{
"id": 58,
"method": "set_volume_state",
"args": [
{
"value": 42,
"channel": "right"
}
],
"result": {
"result": true,
"success": true
},
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "42%",
"front-right": "42%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "38%",
"front-right": "38%"
}
}
]
}
},
{
"id": 144,
"method": "save_settings",
"args": [
{
"filters": [
"FrameySilent0"
],
"boostLimit": 0
}
],
"result": true
},
{
"id": 144,
"method": "set_volume",
"args": [
41,
39
],
"result": true,
"independentAudioReadback": {
"sinks": [
{
"index": 24,
"name": "framey-silent-test",
"channels": {
"front-left": "42%",
"front-right": "42%"
}
}
],
"streams": [
{
"index": 45,
"name": "FrameySilent0",
"channels": {
"front-left": "100%",
"front-right": "100%"
}
},
{
"index": 46,
"name": "FrameySilent1",
"channels": {
"front-left": "39%",
"front-right": "39%"
}
}
]
}
}
],
"checks": {
"volumeMixer38": "Discovery, slider click and ArrowDown: only FrameySilent1 changed 100\u219250\u219245%; FrameySilent0 stayed100%.",
"volumeBoost58": "Sync both channels 75\u219274\u219273%; unsynced right 75\u219274% preserved left73%; reopen restored unchecked sync and73/74. Numeric left42 preserved right74; re-sync set right42 and disabled both right inputs.",
"mixer144": "Numeric37\u219238\u219239 changed FrameySilent1 only; server readbacks37/38/39%. Invalid101 and off-step37.5 produced no set_volume RPC. Filter FrameySilent0 saved with boostLimit0, survived actual Decky shutdown/restart and reopened showing only FrameySilent1 at38%.",
"invalidNumericBrowserMessages": {
"inputs": [
"101",
"37.5",
"39"
],
"setVolumeMessages": [
{
"method": "set_volume",
"args": [
41,
39
]
}
]
},
"notRun": [
"boost above100%",
"Bluetooth playback commands",
"reset_volumes",
"physical headset audio",
"worn VR",
"VR keyboard/controller input"
]
},
"pipewireConfig": "context.properties = { core.daemon = true core.name = framey-audio }\ncontext.spa-libs = { audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }\ncontext.modules = [\n { name = libpipewire-module-protocol-native }\n { name = libpipewire-module-access }\n { name = libpipewire-module-spa-node-factory }\n { name = libpipewire-module-client-node }\n { name = libpipewire-module-client-device }\n { name = libpipewire-module-adapter }\n { name = libpipewire-module-metadata }\n { name = libpipewire-module-link-factory }\n { name = libpipewire-module-session-manager }\n]\ncontext.objects = [\n\n { factory = spa-node-factory args = { factory.name = support.node.driver node.name = Dummy-Driver node.group = pipewire.dummy priority.driver = 20000 } }\n { factory = adapter args = { factory.name = support.null-audio-sink node.name = framey-silent-test node.description = \"Framey Silent Test\" media.class = Audio/Sink audio.position = [ FL FR ] } }\n]",
"remaining": "All three remain partial until real ARM64 Frame dependencies and audio-session mapping are verified under the shared lock.",
"cleanup": {
"testContainerRemoved": true,
"browserRelayStopped": true,
"headsetUntouched": true,
"testServicesOnHeadset": false
},
"catalogRefresh": {
"checkedAtUtc": "2026-10-26T01:34:07.257386+00:00",
"plugins": 116,
"idsVersionsDigestsUnchanged": true
},
"regression": {
"serverTests": {
"count": 1612,
"skipped": 16,
"result": "OK"
},
"siteTests": {
"passed": 2,
"failed": 0
},
"focusedFrameyTests": 38,
"officialFrontendAudit": {
"attempted": 116,
"ready": 98,
"failed": 18,
"readyStateChanges": [],
"initializerOnly": true,
"backendAndNetworkDenied": true
}
}
}
@@ -0,0 +1,110 @@
# Official audio plugins: local real-backend checks
Historical amd64 run. The [later ARM64 Frame check](framey-audio-arm64-2026-10-26.md)
supersedes the ARM64 gaps and follow-up plan below; it promotes the two per-app
mixers to works for their verified scope. Volume Boost remains partial.
Volume Mixer 1.2.1-1 (38), Volume Boost 0.0.7 (58) and Mixer 0.1.0 (144)
passed useful audio controls in local Chromium with their **unmodified official
Python backends running in actual Decky 3.3.0**. All three remain **partial**:
this run used amd64 Linux, and Frame ARM64 dependencies/audio-session mapping
are unverified. The shared headset lock was occupied; no headset reads or
changes occurred. No worn-VR result is claimed.
The private test container and browser relay were removed after verification.
The [machine-readable observations](framey-audio-backends-2026-10-26.json)
pin all three official archives and the published Decky binary digest, and
record each browser-triggered write with independent `pactl` channel readbacks.
| Plugin | Useful behavior checked | Still unverified on Frame |
| --- | --- | --- |
| Volume Mixer | Discover two playback streams; slider click and ArrowDown change only the selected stream, 100 → 50 → 45%; other stream remains 100% | ARM64 libpulse/libdbus, actual user audio session, Bluetooth playback controls |
| Volume Boost | Both-channel slider; separate right-channel change preserves left; reopen restores sync preference and actual values; exact numeric left entry; syncing disables both right controls | `pactl`, default sink, hard-coded runtime UID, physical output, boost above 100% |
| Mixer | Exact stream volume 37 → 38%, independently read back; invalid 101 and off-step 37.5 produce no write; playback filter saved, survives actual Decky restart, hides the intended stream on reopen | `pw-dump`/`wpctl`, actual Frame session, physical output, worn VR |
## Reproduced host gap
Both Volume Boost and Mixer request `SliderField.editableValue`. Framey
previously ignored that prop and rendered only a range slider. It now supplies
an accessible numeric input with the same minimum, maximum, step and disabled
state, and emits numeric `onChange` values. Empty, non-finite, out-of-range and
browser-invalid drafts do not reach a backend. No official archive was patched.
This also makes the control available to other plugins requesting that prop;
it does not establish their compatibility.
## Runtime and isolation
The disposable Debian 13 container ran PipeWire 1.4.2, WirePlumber 0.5.8 and
PulseAudio client tools 17.0. Docker used `--network none --cap-drop ALL
--security-opt no-new-privileges`, no host audio sockets and no sound devices.
Only the archive cache and test inputs were mounted, read-only. Debian 12 was
insufficient for the published Decky binary's GLIBC requirement; that initial
runtime did not start.
Private environment:
```text
XDG_RUNTIME_DIR=/tmp/framey-audio-runtime
PIPEWIRE_REMOTE=framey-audio
PULSE_SERVER=unix:/tmp/framey-audio-runtime/pulse-native
DBUS_SYSTEM_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
```
XDG config/data/state and both Decky paths were below the private test base.
Decky ran unprivileged with `CHOWN_PLUGIN_PATH=0`, `LIVE_RELOAD=0`,
`USE_CEF_CLOSE_WORKAROUND=0` and `KEEP_SYSTEMD_SERVICE=1`. A private D-Bus
session satisfied Volume Mixer's import; there was no real BlueZ service.
Its fixed `/tmp/VolumeMixer-decky.log` existed only inside the container.
The JSON includes the complete PipeWire server configuration: only a dummy
driver and null stereo sink, no hardware factories. A second PipeWire process
loaded protocol-native, client-node, adapter, metadata and protocol-pulse with
the private absolute socket. `wireplumber --profile policy` managed this server;
that installed profile contains no hardware monitors. Only private default-sink
metadata was selected. Two `paplay --raw --format=s16le --rate=48000 --channels=2
/dev/zero` clients supplied silence named `FrameySilent0` and `FrameySilent1`.
Without policy, successful volume RPCs initially left readbacks unchanged; those
fixture attempts are excluded from the passing observations.
The browser relay served actual `ui/headset.html`, vendored React 18.3.1,
`ui/framey-host.js` and the exact archived frontends in an opaque `allow-scripts`
iframe. Outer headset/installed-plugin responses were fixture-shaped. Backend
RPC used the official Decky websocket protocol inside the isolated container:
`loader/call_legacy_plugin_method` with keyword objects for 38/58 and
`loader/call_plugin_method` with positional arguments for 144. This bypassed
Steam CEF and the production SSH/CDP transport; neither is an audio verification
claim from this run.
The relay allowed discovery/settings reads, bounded volume writes only to
identified `FrameySilent` streams/the private default sink, and Mixer filters
`[]` or `["FrameySilent0"]` with boost limit 0. It denied Bluetooth commands,
`reset_volumes` and boosts. Every volume write was followed by a separate
`pactl -f json list sinks` / `list sink-inputs` read. A clean Decky shutdown and
restart retained Mixer's actual settings file and filter behavior.
Earlier full-catalog fake-Frame evidence for these exact digests covers
install/receipt/frontend/removal transport, with plugin execution stubbed.
The new evidence adds actual local backend execution; it does not turn that
older fake-Frame result into an ARM64 backend test.
## Regression checks
The required server suite passed 1,612 tests (16 skips); all 38 focused Framey
tests and both website tests passed. The 116-plugin browser audit retained
98 initializer-ready / 18 failed, with no change to any ready state. That audit
keeps backend/network actions denied. A fresh official catalog fetch confirmed
all 116 IDs, versions and digests unchanged.
## Next device check
Acquire the normal atomic `/tmp/frame-test.lock`; refuse an existing owner.
First inspect available tools/libraries and audio runtime UID. If supported,
repeat with a private ARM64 Decky user service, isolated audio socket, dummy
sink/streams and hardware discovery disabled. Check Volume Mixer's fixed log
path for collisions before loading it. Never attach the fixture to the normal
headset output/default server. Remove only test-owned services/processes/files,
read back original service/battery state and release only the matching lock.
Keep all three partial until that check passes; physical output, Bluetooth and
worn-VR controls need separate evidence.
@@ -0,0 +1,16 @@
# Bluetooth 3.1.1 compatibility fixture, 25 October 2026
The unmodified official Decky store archive for Bluetooth 3.1.1 (store ID 18,
SHA-256 `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5`)
passes Frame Control's archive rules. The fake Frame e2e harness installed it
through the store API, verified the managed receipt and frontend bytes, then
removed it. An earlier real Frame check recorded the backend's `Bluetooth
plugin loaded` line while SteamVR was in standby.
In a local Node host test, its official frontend starts and builds an initial
panel tree. Framey supplies empty Steam token maps and readable English
fallbacks for the three Bluetooth labels that lack plugin-owned fallbacks.
The test leaves React effects and click
handlers inert and sends no backend calls. It does not inspect paired devices,
change Bluetooth state, exercise a controller, or prove the panel is visible in
VR. No worn-headset check has passed for this plugin.
@@ -0,0 +1,36 @@
# Framey loader evidence, 25 October 2026
## Local browser and fake Frame
The official Decky store returned 116 validated entries. ProtonDB Badges was
entry 14, release 1.3.4, SHA-256
`90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358`.
Its published ZIP passed Frame Control's store manifest and path validator. The current
`dist/index.js` bundle was loaded in an opaque-origin iframe in local Chromium
with the Framey React/DFL shim. The [browser screenshot](framey-browser-2026-10-25.png)
shows the plugin's settings panel. A pointer click on **Enable Badge on
Library** issued `set_setting('settings', …)` through the iframe's request
bridge. The local test parent returned a stub backend reply; it did not use a
real headset or Decky backend. A separate fake Frame CEF fixture returned
`get_setting` and `set_setting` responses through Frame Control's CDP bridge;
that fixture does not model the real Decky process.
The disposable fake Frame end-to-end harness passed the store catalogue,
acknowledgement and HTTP auth checks, hash-verified install, update, and
removal through the real Frame Control HTTP server and SSH transaction. The
full fake Frame suite passed 48 tests; the full unit suite passed 1,559 tests
(16 skipped). Unit tests covered unsafe archives,
unmanaged-folder preservation, asset isolation, backend-call scope,
and safe aborts of the worn-headset check.
## Real headset status
Read-only SSH on Frame OS 0.5.6 found battery at 98–99%. SteamVR activity was
initially 3 (standby), briefly 0, then 3 again. During the active probe the
shared `/tmp/frame-test.lock` belonged to `obs-aacapv3evh-20261025`. It later
became free after SteamVR returned to standby. Framey did not acquire, replace,
or remove it. The active probe found no installed Frame Control VR panel or
Framey plugin; the scripted worn check exited before acquiring a lock or
altering the headset. There is **no** wearer-visible VR
menu screenshot, controller-input proof, or live settings-persistence result
from this round.
Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -0,0 +1,19 @@
# Framey cache check, 25 October 2026
The official Steam Status 1.2.3 ZIP pinned in
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
allow-popups"`, so its native storage remained unavailable. The new Framey
storage facade accepted a set/get probe without a `SecurityError`. Steam
Status rendered and issued four `get_settings` calls to a stub parent backend.
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
snapshot and the plugin wrote the same cache key again. The browser log had
zero `SecurityError` or `localStorage` errors.
The parent was a local test page with in-memory Decky backend replies. One
public status fetch also failed in the test browser, so this check does not
establish offline correctness or the freshness of Steam Status data. The
per-plugin save/reopen protocol, stale panel session rejection, and cache
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
live Decky settings, VR visibility, and controller input remain unverified.
@@ -0,0 +1,56 @@
{
"dateUtc": "2026-10-26",
"plugin": {
"storeId": 135,
"name": "Deck Shelves",
"version": "2.0.0",
"sha256": "b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91"
},
"mode": "opaque allow-scripts iframe in local Chromium, exact archived frontend, backend and network denied",
"baselineError": "relative JavaScript chunk imports cannot resolve from a blob URL",
"result": {
"initializerReady": true,
"error": null,
"moduleResponses200": 9,
"settingsVisible": [
"Enable shelves on Home",
"Create default shelves",
"Behavior",
"Shelves"
],
"attemptedBackendMethods": [
"get_settings"
],
"backendMethodsExecuted": [],
"steamLibraryRead": "denied by audit",
"pluginNetwork": "denied by audit"
},
"limits": [
"No Steam Home shelf injection or settings write was verified",
"No official backend executed",
"No official Deck Shelves interaction occurred on the real Frame or in worn VR",
"Settings visibility alone does not prove the plugin works"
],
"realFrameGate": {
"sourceCommit": "b0c4647b856035c970ae27ea4e2ddbf6e51f6963",
"headsetOsBuild": "20261022.6153909",
"preflightActivity": 3,
"preflightBatteryPercent": 100,
"postBatteryPercent": 99,
"sampleExercisePassed": true,
"sampleSettingPersistedAfterFreshIframe": true,
"syntheticOpenVrPointerTrusted": true,
"stereoPixelsChanged": true,
"mirrorCapturePresent": true,
"nativeScrollMoved": false,
"powerValuesRestored": true,
"recoveryTimerRemoved": true,
"lockReleased": true,
"cleanupErrors": [],
"genericFrameSmokePassed": 13,
"genericFrameSmokeFailed": 0,
"officialDeckShelvesBackendOrVrTested": false,
"physicalControllerVerified": false,
"wornVisualComfortVerified": false
}
}
@@ -0,0 +1,13 @@
# Deck Shelves chunked frontend, 26 October 2026
The exact official Deck Shelves 2.0.0 archive (store ID 135, SHA-256 `b566280ad38fd3ecd584b5b46a855f301539eb9a86063834c64b7045f7063f91`) contains `dist/index.js` and eight JavaScript chunks. Its API v1 `connect(1, "Deck Shelves")` bridge sits in `chunks/decky-api-WgbNuFYS.js`, not in the entry file; the original static scanner therefore reported its frontend API as unknown. Framey's prior blob import failed before initialization because a blob URL has no directory in which to resolve `./chunks/...`. The [numeric observation](framey-deck-shelves-browser-2026-10-26.json) and [local Chromium capture](framey-deck-shelves-browser-2026-10-26.png) record a targeted follow-up using the unchanged official frontend.
Framey now serves JavaScript files from the managed plugin's `dist` directory using hierarchical module URLs. Its opaque `allow-scripts` iframe still has no Frame UI key. Local Chromium received HTTP 200 for the entry and eight chunks, reported the initializer ready, and rendered the settings panel with “Enable shelves on Home,” “Create default shelves,” “Behavior,” and “Shelves.” The browser audit denied the attempted `get_settings` backend call, Steam library read and plugin network access. No backend method executed.
The catalog moves this exact version from broken to **partial** for visible local settings only. The plugin's main feature injects shelves into Steam Home; Framey has no verified Steam Home patch target. Settings writes, official backend behavior, real Frame module serving, wearer-visible VR, and physical controller input remain unverified. The [compatibility table](../framey-compatibility.md) and store views retain these limits.
## Exact source real Frame gates
The exact source commit `b0c4647b856035c970ae27ea4e2ddbf6e51f6963` ran the unattended `scripts/framey-vr-check.py --exercise` on SteamOS build `20261022.6153909` under its unique shared-lock owner. Preflight found SteamVR activity 3 (standby), battery 100%, and proximity 6.25. The repository sample panel appeared, stereo compositor pixels changed, and `/dev/video99` supplied a mirror frame. Synthetic OpenVR pointer input changed the sample setting through actual Decky; the value survived a fresh iframe reopen. Native scroll did not move. The runner removed its temporary services and recovery timer, read back the original `false`/`5` power values, found battery 99%, reported no cleanup errors, and released its lock.
The same source then passed `scripts/frame-smoke.sh` **13/13** on the real Frame, starting from standby at battery 99%. Its final cleanup step passed. The [sanitized result fields](framey-deck-shelves-browser-2026-10-26.json) omit room pixels, raw overlays, identifiers, and settings contents; private captures were not committed. These gates exercise the Framey repository sample and generic install/launch/remove paths. They do **not** exercise Deck Shelves' official ARM64 backend or prove its settings or shelves in VR. No person wore the headset and no physical controller was checked.
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

@@ -0,0 +1,418 @@
{
"checkedAtUtc": "2026-10-26T00:12:47.296778+00:00",
"method": "Local Chromium actual Framey headset page and opaque official plugin iframe; production method/event bridge relayed by SSH to real ARM64 Decky 3.3.0 in a private removable user service. Test relay restricts RPC methods and timer durations. Backend code is unmodified, no fake plugin replies. Only file paths to private install and outer status/data APIs differ. Not worn VR.",
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"hostSourceSha256": {
"framey-host.js": "3bad52546c0b78c20e6479c91899f1f3864b74687c6fce37e776abededfb5f1c",
"framey-events.js": "89046f4c89bdab0f578b910170a044d5ada540db0f7b71d6a370c68b4b008c3f",
"framey_panel.py": "8ca4b3ba5f0d51c51bb3601f9ccd24c4af397fbf8f63c383a3042e1ae3f7b39b",
"headset.html": "ebaf1711639a929ec7287c01dbbbe8eb08f000b852fdc877f31c9d99e5fe69fc"
},
"before": {
"battery": [
100
],
"decky": "inactive",
"panel": "inactive"
},
"after": {
"battery": [
100
],
"decky": "inactive",
"panel": "inactive"
},
"powerSettingsChanged": false,
"plugins": [
{
"id": 19,
"name": "Notebook",
"version": "0.1.5",
"sha256": "c35b09de4bba4b4157fbe84cb7b0d20fc3924f78fa3c48ae17686b4314c1cc0c",
"calls": [
{
"id": 19,
"method": "listPages",
"args": [
{
"appid": 0
}
],
"result": {
"result": {
"result": [],
"success": true
}
}
},
{
"id": 19,
"method": "loadLastSelectedPage",
"args": [
{
"appid": 0
}
],
"result": {
"result": {
"result": 0,
"success": true
}
}
},
{
"id": 19,
"method": "loadPage",
"args": [
{
"appid": 0,
"page": 0
}
],
"result": {
"result": {
"result": {
"page": 0,
"timestamp": 0,
"empty": true,
"data": ""
},
"success": true
}
}
},
{
"id": 19,
"method": "savePage",
"args": [
{
"appid": 0,
"page": 0,
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
}
],
"result": {
"result": {
"result": {
"timestamp": 1792973680303
},
"success": true
}
}
},
{
"id": 19,
"method": "listPages",
"args": [
{
"appid": 0
}
],
"result": {
"result": {
"result": [
{
"page": 0,
"timestamp": 1792973680303,
"empty": false
}
],
"success": true
}
}
},
{
"id": 19,
"method": "loadLastSelectedPage",
"args": [
{
"appid": 0
}
],
"result": {
"result": {
"result": 0,
"success": true
}
}
},
{
"id": 19,
"method": "loadPage",
"args": [
{
"appid": 0,
"page": 0
}
],
"result": {
"result": {
"result": {
"page": 0,
"timestamp": 1792973680303,
"empty": false,
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
},
"success": true
}
}
}
],
"events": [],
"checks": {
"pointerStrokeSaved": true,
"sameStrokeLoadedAfterBackendRestart": true,
"defaultAppId": 0,
"existingNotesTouched": false
}
},
{
"id": 96,
"name": "Simple Timer",
"version": "1.0.0",
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312",
"calls": [
{
"id": 96,
"method": "load_remaining_seconds",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_subtle_mode",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_recents",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "start_timer",
"args": [
300
],
"result": {
"result": null
}
},
{
"id": 96,
"method": "cancel_timer",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "start_timer",
"args": [
5
],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_remaining_seconds",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_recents",
"args": [],
"result": {
"result": null
}
},
{
"id": 96,
"method": "load_subtle_mode",
"args": [],
"result": {
"result": null
}
}
],
"events": [
{
"sequence": 1,
"event": "simple_timer_seconds_updated",
"args": [
0
]
},
{
"sequence": 2,
"event": "simple_timer_subtle",
"args": [
true
]
},
{
"sequence": 3,
"event": "simple_timer_refresh_recents",
"args": [
[]
]
},
{
"sequence": 4,
"event": "simple_timer_refresh_recents",
"args": [
[
300
]
]
},
{
"sequence": 5,
"event": "simple_timer_seconds_updated",
"args": [
300
]
},
{
"sequence": 6,
"event": "simple_timer_seconds_updated",
"args": [
294.9945251941681
]
},
{
"sequence": 7,
"event": "simple_timer_seconds_updated",
"args": [
289.99201130867004
]
},
{
"sequence": 8,
"event": "simple_timer_seconds_updated",
"args": [
284.9877219200134
]
},
{
"sequence": 9,
"event": "simple_timer_seconds_updated",
"args": [
0
]
},
{
"sequence": 10,
"event": "simple_timer_refresh_recents",
"args": [
[
5,
300
]
]
},
{
"sequence": 11,
"event": "simple_timer_seconds_updated",
"args": [
5
]
},
{
"sequence": 12,
"event": "simple_timer_seconds_updated",
"args": [
-0.005679607391357422
]
},
{
"sequence": 13,
"event": "simple_timer_seconds_updated",
"args": [
0
]
},
{
"sequence": 14,
"event": "simple_timer_event",
"args": [
"Your session has ended!",
true
]
},
{
"sequence": 1,
"event": "simple_timer_seconds_updated",
"args": [
0
]
},
{
"sequence": 2,
"event": "simple_timer_refresh_recents",
"args": [
[
5,
300
]
]
},
{
"sequence": 3,
"event": "simple_timer_subtle",
"args": [
true
]
}
],
"checks": {
"uiStart300Seconds": true,
"uiCancel": true,
"shortTimer5Seconds": true,
"completionToastVisible": true,
"recentTimersAndSubtleModeSurvivedRestart": true,
"subscriptionsAfterPanelClose": 0
},
"upstreamError": "Simple Timer 1.0.0 _main after restarting with an expired timer raises AttributeError: 'Plugin' object has no attribute 'timer_task' (main.py:94,129). Frontend read requests still load recent timers and subtle mode."
}
],
"cleanup": {
"baseAbsent": true,
"unitAbsent": true,
"serviceActive": false,
"independentlyRechecked": true,
"lockReleased": true
},
"regressions": {
"unitTests": 1606,
"unitSkips": 16,
"browserPlugins": 116,
"browserReady": 96,
"browserFailed": 20,
"fakeFrameInstalled": 60,
"fakeFrameRejected": 56
},
"limitations": [
"Timer sound asset and suspend action unsupported; no suspend/power action run.",
"Closing a Framey panel removes its event channel; it is not a global background notification service.",
"Notebook default notebook only (appid 0): no per-game auto-selection or background artwork.",
"No worn VR, physical controller, keyboard, or optical check."
]
}
@@ -0,0 +1,140 @@
{
"checkedAtUtc": "2026-10-26T08:14:37.777131+00:00",
"plugin": {
"id": 84,
"name": "Junk-Store",
"version": "1.1.13",
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1"
},
"projection": {
"field": "local_per_client_data.installed",
"wire": "optional ninth scalar: boolean or null",
"unknown": "null creates an empty detached metadata object; installed stays undefined",
"privateFieldsCopied": false,
"oldEightScalarRows": "accepted; running updates preserve existing library metadata",
"liveObservation": false
},
"realFrame": {
"os": "Frame OS 0.5.6",
"arch": "aarch64",
"uid": 1000,
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"transport": "trusted SSH through lxso2",
"sharedLock": true,
"runtime": "private bounded removable user service; not enabled",
"library": {
"apps": 1106,
"games": 895,
"installedTrue": 41,
"installedFalse": 0,
"installedUnknown": 1065
},
"protonRuntimeInstalledFields": "undefined for both EasyAntiCheat and BattlEye; no false value invented",
"legendaryInstalled": false,
"allowedMethods": [
"execute_action",
"get_websocket_port"
],
"staticActions": [
"MainMenu/GetContent",
"init/InitActions"
],
"noMutationActions": true
},
"localBrowser": {
"host": "actual headset.html/framey-host.js, vendored React, opaque iframe",
"backend": "exact official ARM64 plugin through production receipt-bound bridge",
"library": "production receipt-bound snapshot from real Frame CEF",
"outerStatusInstallCheck": "fixture responses",
"passed": [
"main menu",
"About content and tab list",
"Dependencies tab controls render; no buttons invoked",
"Back to main menu",
"close/reopen and About again"
],
"limits": [
"optional localhost websocket blocked by test relay CSP",
"RSS calls denied",
"running-context relay returns fixture/unavailable data; no lifetime claims",
"outer extension/status fixture is incomplete; unrelated outer status/extension errors excluded from plugin claim",
"deployed headset HTTP endpoint unverified",
"worn VR unverified"
]
},
"cleanup": {
"initialSshStop": "timed out while test unit deactivating",
"recovery": "SIGKILL to exact owned unit only after matching lock owner; private files removed",
"result": {
"baseAbsent": true,
"unitAbsent": true,
"archiveAbsent": true,
"state": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
"normal": {
"frame-control-decky.service": "inactive",
"frame-control-headset.service": "inactive"
},
"battery": [
99
]
},
"freshLockedAudit": {
"services": 1,
"allArtifactsAbsent": true,
"allUnloadedInactivePidZero": true
},
"lockReleased": true,
"batteryBeforeAfter": [
[
99
],
[
99
]
],
"powerSettingChanged": false,
"localRelayAndBrowserClosed": true
},
"verification": {
"python": {
"run": 1628,
"skipped": 16,
"failures": 0
},
"panelFocused": {
"run": 22,
"failures": 0
},
"nodeContracts": [
"framey_actions.mjs",
"framey_running.mjs"
],
"siteTestFiles": {
"passed": 2,
"failures": 0
},
"fakeFrame": {
"blocked": 56,
"installed-removed": 60
},
"officialStore": {
"plugins": 116,
"errors": 0,
"identitySetUnchanged": true,
"checkedAtUtc": "2026-10-26T08:21:01.653768+00:00"
}
},
"status": "partial",
"unverified": [
"dependency/runtime installation",
"login",
"game listing/download/launch",
"RSS",
"optional websocket operations",
"Steam shortcut mutation",
"worn VR"
]
}
@@ -0,0 +1,62 @@
# Junk-Store About and read-only installed metadata
Junk-Store 1.1.13 (official ID 84) now renders its About and Dependencies tabs
without the `local_per_client_data.installed` crash recorded in the
[script-entrypoint audit](framey-script-entrypoints-2026-10-26.md). It remains
**partial**. The catalog and both store views use the same updated reason.
The library snapshot omitted the entire `local_per_client_data` object. The
official frontend dereferences it to display its dependency controls. Under the
shared headset lock, a read-only probe confirmed that the real Frame exposes
this object for the two Proton runtimes, but neither has an `installed` boolean.
Across 1,106 app overviews, 41 had `installed: true`, none had `false`, and 1,065
had no installed boolean. Those absent values must not be relabelled as false.
The snapshot adds one bounded scalar per app: a Steam-supplied boolean or null.
Framey's detached overview creates a minimal metadata object, copying `installed`
only for a boolean. Null leaves that property undefined. No client IDs, paths,
other metadata, live Steam objects or methods cross the iframe boundary. Eight
scalar rows remain accepted, and running-app updates preserve library metadata.
The snapshot still refreshes at panel open; it is not a live installation feed.
## Actual ARM64 backend and local Chromium
The unmodified, hash-verified official archive ran in published Decky 3.3.0 ARM64
on Frame OS 0.5.6 using a private, bounded, removable user service. Legendary was
absent; no dependency installer, runtime install, login, game operation or custom
backend download was invoked. The exact plugin and loader digests are in the
[redacted results](framey-installed-context-2026-10-26.json).
Local Chromium ran the actual Framey host and official frontend in an opaque
iframe. The production receipt-bound bridges read the real Steam library and
called the official ARM64 backend's static menu actions. About content and its
tab list, Dependencies controls, Back, close/reopen, and About again passed.
No dependency buttons were pressed. The real library counts matched the probe.
The optional localhost websocket was blocked by the test relay CSP; RSS was
deliberately denied. Outer status/install/check replies were fixtures; incomplete
outer extension/status and running-context replies are excluded from the plugin
claim. The screenshot still used the catalog reason from before this follow-up.
Dependency installation, login, game operations, RSS, websocket operations,
shortcut changes, deployed headset HTTP and worn VR remain unverified.
## Cleanup and regression checks
The initial cleanup SSH timed out while the private Decky unit was deactivating.
Recovery checked the matching lock owner, killed only that exact test unit, and
removed its private base, unit file and archive. A fresh locked audit independently
confirmed unloaded/inactive/PID 0 and no artifacts, then released its lock. Normal
managed Decky/panel services stayed inactive; battery was 99 → 99. No power-setting
action ran. Local relay and owned browser tab are closed.
- Full Python suite: 1,628 tests, 16 skipped, no failures; focused panel: 22 passed.
- Framey action/running Node contracts passed, including true/false/unknown,
malformed metadata, detached values, old rows and running-update preservation.
- Both site test files passed. Generated docs and diff checks passed.
- All 116 exact official archives passed the fake-Frame audit: 60 installed/removed,
56 expected blocks; disposable project removed. Backend execution is stubbed in
that harness. The fresh official store has 116 entries, no audit errors, and an
unchanged ID/version/digest set.
No new works claim is made. Catalog totals remain **4 works / 49 partial / 52 broken
/ 11 N/A**; only Junk-Store's verified scope and remaining limits change.
+25
View File
@@ -0,0 +1,25 @@
# Legacy Decky frontend bridge
The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures)
has no `api_version` field and returns its initializer from a Decky IIFE.
Framey now wraps that expression in an ES module inside the existing
opaque-origin iframe. It passes a legacy `serverAPI` to the initializer.
Local Chromium ran the unchanged official frontend, including React effects,
with all backend calls rejected and network access blocked. It emitted
`framey:ready` without a JavaScript startup error during the observation window.
This proves initialization, not playback, MPRIS access, or a useful populated UI.
The fake Frame installed, packed, checked the frontend bytes and removed this
exact archive. Its synthetic Sample Panel also read a legacy keyword argument,
wrote a setting, and read that setting through the modern API. This exercised
the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was
checked against Decky's `handle_plugin_method_call_legacy`: it calls with a
keyword object and returns `{success, result}`. Invalid argument shapes and
unmanaged plugin IDs are rejected.
Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'`
(21 passed), `node tests/page/framey_actions.mjs`, and
`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or
headset state was changed by these checks. Real Frame backend behavior,
controller input, playback and worn VR use remain unverified.
@@ -0,0 +1,326 @@
{
"timeUtc": "2026-10-26T02:23:08.939647+00:00",
"theme": "Detached read-only Steam library and current-user context",
"baselineMain": "3921e59",
"hostFileSha256": "3f70e9769c6c95d39cfa329ccf3f822df85d411e9295bdf888508b4738679e9d",
"realFrame": {
"platform": "Frame OS 0.5.6 ARM64",
"path": "SSH via lxso2; Steam SharedJSContext CDP",
"lock": "/tmp/frame-test.lock atomic mkdir; matching owner released in finally after every probe",
"apps": 1106,
"games": 895,
"compactBytesWithoutUser": 88451,
"invalidRows": 0,
"missingGameOverviews": 0,
"currentUser": "17-digit string verified; value withheld",
"mutations": "none; no wake, service, setting, screenshot or library changes; no device artifacts created",
"limits": "read-only CEF projection, not an official backend or worn-VR test"
},
"browser": {
"mode": "opaque iframe Chromium; detached real Frame snapshot replay; all backend/network actions denied",
"attempted": 116,
"initializerReady": 101,
"failed": 15,
"initializerGains": [
46,
53,
66
],
"regressions": [],
"focused": [
{
"id": 37,
"name": "Discord Status",
"ready": true,
"error": null,
"logs": [],
"methods": [],
"library": "snapshot replay"
},
{
"id": 46,
"name": "Steamback",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "TypeError: saveInfos.map is not a function"
}
],
"methods": [
"set_account_id",
"get_saveinfos"
],
"library": "snapshot replay"
},
{
"id": 53,
"name": "Shotty",
"ready": true,
"error": null,
"logs": [],
"methods": [
"set_id_map_fronend"
],
"library": "snapshot replay"
},
{
"id": 66,
"name": "PlayTime",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Failed to set current user: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Error: Read-only compatibility audit: backend method blocked"
}
],
"methods": [
"set_current_user",
"fetch_playtime_information",
"statistics_for_last_two_weeks",
"daily_statistics_for_period"
],
"library": "snapshot replay"
},
{
"id": 93,
"name": "Deck Settings",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
},
{
"kind": "error",
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: Error: Read-only compatibility audit: live Steam API blocked"
}
],
"methods": [],
"library": "snapshot replay"
},
{
"id": 132,
"name": "SuggestMe",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "[SuggestMe] Failed to load config: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load library status: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load play next list: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load excluded games: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load filter presets: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load history: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load non-steam info: Error: Read-only compatibility audit: backend method blocked"
},
{
"kind": "error",
"message": "[SuggestMe] Failed to load collections: Error: Read-only compatibility audit: backend method blocked"
}
],
"methods": [
"log_message",
"get_config",
"get_library_status",
"get_play_next_list",
"get_excluded_games",
"get_filter_presets",
"get_suggestion_history",
"get_non_steam_games",
"get_collections",
"get_available_genres",
"get_available_tags",
"get_available_community_tags"
],
"library": "snapshot replay"
},
{
"id": 134,
"name": "Deck Progress Tracker",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_settings",
"get_tag_statistics"
],
"library": "snapshot replay"
},
{
"id": 135,
"name": "Deck Shelves",
"ready": false,
"error": "Failed to resolve module specifier \"./chunks/pluginApi-B8sV7970.js\". Invalid relative url or base scheme isn't hierarchical.",
"logs": [],
"methods": [],
"library": "snapshot replay"
}
]
},
"fakeFrame": {
"attempted": 116,
"installedRemoved": 60,
"expectedBlockers": 56,
"backendExecution": "stubbed",
"cleanup": "private Compose containers/network/volumes removed"
},
"localBackends": {
"timeUtc": "2026-10-26T02:18:51.848256+00:00",
"verification": "local Chromium + actual Decky 3.3.0 amd64, detached real Frame SSH snapshot; no worn VR",
"calls": [
{
"id": 46,
"method": "set_account_id",
"resultType": "dict",
"resultCount": 2
},
{
"id": 46,
"method": "get_saveinfos",
"resultType": "dict",
"resultCount": 2
},
{
"id": 66,
"method": "set_current_user",
"resultType": "NoneType"
},
{
"id": 66,
"method": "statistics_for_last_two_weeks",
"resultType": "list",
"resultCount": 0
},
{
"id": 66,
"method": "fetch_playtime_information",
"resultType": "list",
"resultCount": 0
},
{
"id": 66,
"method": "daily_statistics_for_period",
"resultType": "dict",
"resultCount": 3
},
{
"id": 66,
"method": "fetch_playtime_information",
"resultType": "list",
"resultCount": 0
},
{
"id": 66,
"method": "statistics_for_last_two_weeks",
"resultType": "list",
"resultCount": 0
}
],
"runtime": {
"tag": "v3.3.0",
"name": "PluginLoader",
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
},
"legacyReadback": {
"plugin": 46,
"method": "get_saveinfos",
"replyType": 1,
"success": true,
"saveInfoCount": 0
}
},
"uiChecks": {
"Steamback": "Supported games panel, actual private backend set_account_id and get_saveinfos returned successfully; empty isolated Steam save data",
"PlayTime": "Statistics panel (empty database) and routed General settings/tabs rendered; actual private backend selected user and returned statistics",
"Deck Settings": "Installed game picker rendered real Frame install-folder/name snapshot; search modal rendered",
"Shotty": "Initializer repaired; actual Decky 3.3.0 backend fails importing unused click.get_app_dir: ModuleNotFoundError; no screenshot methods invoked"
},
"notVerified": [
"Real ARM64 official backends in this theme",
"Worn VR/controllers/keyboard",
"Live MobX or running-game observations",
"Steamback backups/restores and automatic notifications",
"PlayTime automatic recording, existing databases or account switches while panel remains open",
"Deck Settings remote reports/proxy fetch and settings changes",
"Shotty screenshot aggregation/rescue"
],
"cleanup": {
"localContainer": "stopped/removed; docker ps -a matching name empty",
"headsetLockReleased": true,
"httpHelpers": "three task-local HTTP servers stopped"
},
"plugins": [
{
"id": 37,
"name": "Discord Status",
"version": "1.5.0",
"sha256": "00a45dd919abb6e050685e1e408ce8023b9c4a09d6e91ba528d50aed2a63bf45"
},
{
"id": 46,
"name": "Steamback",
"version": "1.0.0-1",
"sha256": "ed5f2fbb47cdfc3aa9a4349a139ab20c29976117a3be0de7e8a588bb265ee7f3"
},
{
"id": 53,
"name": "Shotty",
"version": "0.1.0",
"sha256": "ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe"
},
{
"id": 66,
"name": "PlayTime",
"version": "3.2.5",
"sha256": "10fc3beea48991c77cb02867e919b618d4cd6d5e9c6a6d0ccd5266c13214039f"
},
{
"id": 93,
"name": "Deck Settings",
"version": "1.2.6",
"sha256": "23c2a933a6883e2622f0184c33a76ce4a4e88c0a7dcd730f9bab71b8c3702132"
}
],
"validation": {
"serverTests": 1617,
"serverSkips": 16,
"siteTests": 18,
"fakeFramePanel": "2 targeted panel/store flows passed; private Compose resources removed",
"focusedFrameyTests": 40
}
}
@@ -0,0 +1,72 @@
# Framey detached Steam library context — 2026-10-26
Framey now reads a bounded library snapshot before importing official frontends
that reference `appStore`, `collectionStore` or `m_CurrentUser`. It supplies app
IDs/names/types/game IDs, sorting/playtime scalars, the actual all-games
collection and the current public Steam ID. It does not copy credentials,
balances, account names, filesystem paths or executable Steam objects.
The projection is a fixed, parameterless `GetLibrarySnapshot` read through the
existing installed-store-plugin boundary. The parent checks the iframe source,
session, method and empty argument list. The host validates rows before exposing
detached Maps and lookup methods. Plugin patches remain local to that iframe.
Missing or invalid data leaves the globals unavailable and logs a clear warning;
there is no invented account or empty-library fallback. Reopening the panel
refreshes the snapshot. Live MobX observations, account-switch notifications and
running-game events are outside this change.
Library-sized backend arguments now have a 256 KiB UTF-8 JSON budget. The old
8 KiB ceiling rejected normal app-name maps. Payloads travel as quoted literals
on SSH stdin, avoiding operating-system limits on a single command argument.
Store receipt ownership, method validation and legacy keyword envelopes still
apply. The library read has a separate 2 MiB / 10,000-app limit; other Steam
reads retain their 256 KiB response limit.
## Observations
| Plugin | Verified behavior | Remaining limit |
| --- | --- | --- |
| Steamback 1.0.0-1 | Panel opens; account setup and empty save-list reads succeed through actual Decky 3.3.0 amd64 | Private container had no Steam saves. ARM64 backend, backup/restore and automatic hooks unverified. Partial. |
| PlayTime 3.2.5 | Actual backend selects the user, returns empty statistics; statistics panel and routed settings/tabs render | No sessions were created. ARM64 backend, existing databases, live observations and automatic recording unverified. Partial. |
| Deck Settings 1.2.6 | Installed game picker renders the real Frame folder/name snapshot; search modal opens | Remote reports/proxy fetch, settings changes and ARM64 backend unverified. Partial. |
| Shotty 0.1.0 | Library-based initializer now completes | Exact backend fails importing `click.get_app_dir`: `ModuleNotFoundError: No module named 'click'`. Archive does not bundle it. Broken. |
| Discord Status 1.5.0 | Detached overview lookups available | Live running-app observations and artwork helpers remain absent; Discord activity unverified. Broken. |
Exact plugin versions/digests, runtime digest, scoped calls and counts are in the
[redacted observations](framey-library-context-2026-10-26.json). The canonical
table/store labels use the same digest-scoped JSON. None of these plugins is
promoted to **works** by this pass.
## Verification boundaries
- **Real Frame SSH:** atomic `/tmp/frame-test.lock` acquisition for every probe,
matching-owner release in `finally`. Frame OS 0.5.6 ARM64 Steam CEF returned
1,106 apps and 895 game IDs; no missing collection members or invalid rows.
The compact library-only response was 88,451 bytes. Current-user field shape
was separately inspected. Values remain private; only aggregate counts are
published. No wake, power/service changes or device artifacts were needed.
- **Local Chromium:** production `headset.html`, current host, opaque iframe and
unmodified digest-checked official frontends. Library/user and install-folder
data were detached snapshots read from the Frame, replayed locally. This is
not a deployed headset HTTP or worn-VR test.
- **Actual local backends:** published Decky 3.3.0 amd64 executable, private
unprivileged container, network disabled, capabilities dropped, no devices,
host home, saves or Steam files mounted. Only account setup within private
plugin data and read methods were allowed. Backup, restore, aggregation,
screenshot rescue and game actions were denied. Shotty's import failure was
observed from the runtime log before invoking screenshot methods.
- **Catalog regression:** 116 exact frontends attempted, 101 initializer-ready
and 15 failed, compared with 98 ready before this theme. Steamback, Shotty and
PlayTime gained initializer readiness; no previous ready state regressed.
Backend/network calls stayed denied in this sweep. Readiness alone does not
establish useful operation.
- **Fake Frame:** 116 archive/transport cases; 60 install/remove cycles and 56
expected blockers. Official backend execution was stubbed. Its private
Compose containers, network and volumes were removed.
The browser audit accepts `--library-snapshot /private/library.json` to reproduce
the detached replay. This flag does not convert the audit into live Steam or
backend evidence. Never commit personal library snapshots or account values.
No worn-VR, physical controller, keyboard, automatic recording or backup claim
is made. Further device backend checks still require the shared hardware lock.
@@ -0,0 +1,97 @@
{
"checkedAtUtc": "2026-10-26",
"plugin": {
"id": 125,
"name": "Decky Localsend",
"officialArchiveStatus": "x86_64; blocked; no substitutions or safeguard changes"
},
"source": {
"url": "https://github.com/MoYoez/LocalSend-Go",
"revision": "66edc509a28b9f877e7374504892dcbc653a4da4",
"license": "MIT",
"licenseUrl": "https://github.com/MoYoez/LocalSend-Go/blob/66edc509a28b9f877e7374504892dcbc653a4da4/LICENSE",
"pluginLicense": "BSD-3-Clause; distinct from the MIT helper",
"officialElfBuildInfo": "Go 1.25.7, CGO_ENABLED=0, GOARCH=amd64; no embedded VCS revision. Exact source correspondence unproven."
},
"build": {
"toolchain": "go1.25.7",
"environment": {
"GOTOOLCHAIN": "go1.25.7",
"CGO_ENABLED": "0",
"GOOS": "linux",
"GOARCH": "arm64"
},
"command": "go build -trimpath -o localsend-core .",
"elfMachine": 183,
"architecture": "aarch64",
"staticallyLinked": true,
"sha256": "e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9",
"rebuildSha256Matches": true
},
"realFrame": {
"os": "Frame OS 0.5.6 ARM64",
"sharedLockOwner": "kp0z857q58-live-steam-reads-20261026",
"helpExitCode": 0,
"officialBackendFlagsAllRecognized": true,
"helpFlags": [
"-doNotMakeSessionFolder",
"-log",
"-scanTimeout",
"-skipNotify",
"-useAlias",
"-useAutoSave",
"-useAutoSaveFromFavorites",
"-useConfigPath",
"-useDefaultUploadFolder",
"-useDownload",
"-useHttp",
"-useMultcastAddress",
"-useMultcastPort",
"-usePin",
"-useReferNetworkInterface",
"-useWebOutPath"
],
"runtimeExitCode": 0,
"isolation": "unshare -Urn; loopback and private veth pair only; no external interface/default route",
"receiving": "-useAutoSave=false and -useAutoSaveFromFavorites=false; no upload/transfer requests",
"notifications": "-skipNotify",
"readOnlyRequest": "GET http://127.0.0.1:53317/api/localsend/v2/info",
"result": {
"ok": true,
"protocolVersion": "2.0",
"deviceType": "headless",
"responseKeys": [
"alias",
"deviceModel",
"deviceType",
"download",
"fingerprint",
"version"
],
"aliasMatches": true
},
"cleanup": {
"helperExited": true,
"filesCreated": [
"config.yaml",
"helper.log"
]
},
"privateDirectoryAbsent": true,
"servicesInstalledOrStarted": false,
"powerChanges": false,
"sharedLockReleasedAfterTests": true,
"independentReacquireAudit": "Deferred: another job had acquired the shared lock; it was left untouched. The helper process exited and TemporaryDirectory removal completed within the original locked probe."
},
"failedProbe": {
"interface": "loopback only",
"result": "Upstream rejects lo as a discovery interface and exits. Repeated with a veth pair wholly inside the private namespace; no source changes."
},
"unverified": [
"Exact source correspondence to shipped official ELF",
"Unmodified official Python backend invoking this ARM64 artifact",
"HTTPS, discovery on real network, pairing and file transfers",
"Framey frontend/proxy protocol integration",
"Worn VR/controller/keyboard"
]
}
@@ -0,0 +1,57 @@
# LocalSend ARM64 build and isolated Frame probe
The official Decky Localsend archive remains **broken/blocked**: its helper is
x86_64. A source rebuild is now proven to execute on the ARM64 Frame and answer
the LocalSend 2.0 info endpoint. This is a core runtime result, not a working
official-plugin or file-transfer claim. [Machine-readable evidence](framey-localsend-arm64-2026-10-26.json)
records the exact scope and cleanup.
## Reproduce the build
The helper is MIT-licensed; the separate official Python plugin is BSD-3-Clause.
Use the pinned helper source and retain its license for any redistribution.
No binary is redistributed in Frame Control, and no installer digest or
architecture safeguard was changed.
```sh
framey_build_dir=$(mktemp -d)
git clone https://github.com/MoYoez/LocalSend-Go "$framey_build_dir/source"
git -C "$framey_build_dir/source" checkout --detach 66edc509a28b9f877e7374504892dcbc653a4da4
cd "$framey_build_dir/source"
env GOTOOLCHAIN=go1.25.7 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
go build -trimpath -o "$framey_build_dir/localsend-core" .
sha256sum "$framey_build_dir/localsend-core"
```
Two builds produced the same static aarch64 ELF SHA-256:
`e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9`.
The official ELF uses Go 1.25.7 too, but embeds no VCS revision, so the exact
source correspondence is still unproven. The rebuilt helper recognizes every
CLI flag supplied by the official Python backend.
## Real Frame scope
Tests ran under the normal atomic `/tmp/frame-test.lock` with a private temporary
directory. `-h` exited before configuration or networking. For the health probe,
`unshare -Urn` created a private user/network namespace with loopback and a veth
pair entirely inside it: no external interface or default route. The veth used
the documentation-only `192.0.2.1/24` address. Upstream rejects a loopback-only
discovery interface, which is why the second isolated attempt used a veth.
The helper used a private config/upload path, `-skipNotify`,
`-useAutoSave=false`, `-useAutoSaveFromFavorites=false`, `-useHttp=true`,
`-useReferNetworkInterface probe0` and `-scanTimeout 1`. The test client sent only
`GET /api/localsend/v2/info` over loopback; it returned version `2.0`, device type
`headless` and the private test alias. No upload, transfer, pairing or external
discovery request ran. The helper exited; its private config/log and executable
directory were removed; no service or power setting changed. The lock was
released. An independent cleanup reacquisition found another job's lock and
left it alone; cleanup success comes from the original probe's process wait and
temporary-directory exit, not that deferred audit.
Before enabling an ARM64 official package, verify the unmodified Python backend
and frontend/proxy protocol with this source revision, then HTTPS, discovery and
an explicitly safe private transfer. The official store must publish a matching
ARM64 archive or Frame Control must gain a separately reviewed, digest-scoped
build/package path. Compiling the core does not justify accepting the current
x86 archive or marking the full plugin works.
@@ -0,0 +1,222 @@
{
"schemaVersion": 1,
"timeUtc": "2026-10-26T03:29:05.328492+00:00",
"baseCommit": "c89ddff",
"scope": "Finite Framey-local DFL component/CSS registry and explicit unsupported optional UI-audio export",
"upstreamReferences": [
"https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/webpack.ts",
"https://github.com/SteamDeckHomebrew/decky-frontend-lib/blob/main/src/class-mapper.ts"
],
"plugins": [
{
"id": 16,
"name": "Audio Loader",
"version": "1.6.1",
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
"status": "broken",
"reason": "This plugin needs root and is not supported by the Frame user service. Steam UI sound lookup is present as explicitly unsupported; the frontend still requires Steam m_GamepadUIAudioStore.m_AudioPlaybackManager, which is unavailable in the isolated panel.",
"browser": {
"id": 16,
"name": "Audio Loader",
"ready": false,
"error": "Cannot read properties of undefined (reading 'm_AudioPlaybackManager')",
"logs": [
{
"kind": "warn",
"message": "Resolve failed: [object Object] promise [object Promise]"
}
],
"methods": [
"store_read"
]
}
},
{
"id": 36,
"name": "SteamGridDB",
"version": "1.8.1",
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
"status": "broken",
"reason": "Local module mapping is available; frontend still requires the Steam LibraryContextMenu implementation, which has no target in the isolated Framey panel.",
"browser": {
"id": 36,
"name": "SteamGridDB",
"ready": false,
"error": "Cannot convert undefined or null to object",
"logs": [],
"methods": []
}
},
{
"id": 61,
"name": "TabMaster",
"version": "2.15.1",
"sha256": "e08333859a32b0c177b8e9a7959c5eeeafe38f946578f7f1d161c721f0e05da5",
"status": "partial",
"reason": "Local Chromium rendered the QAM and navigable Overview/Filters documentation with an actual Decky 3.3.0 amd64 backend. Library-tab editing remains unavailable: Steam login/library patches and MobX reactions have no target. ARM64 and worn VR unverified.",
"browser": {
"id": 61,
"name": "TabMaster",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "%c TabMaster %c ERROR %c background: #ff6d05; color: black; background: #c70808; color: black; background: transparent; Could not find mobx.reaction"
}
],
"methods": [
"log_message"
]
}
},
{
"id": 84,
"name": "Junk-Store",
"version": "1.1.13",
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
"status": "broken",
"reason": "Local CSS lookup fixes the loading view crash. Actual Decky 3.3.0 amd64 startup/static InitActions return a script execution/JSON error; the published scripts are not executable after extraction. Store install/login/game launch and ARM64 remain unverified.",
"browser": {
"id": 84,
"name": "Junk-Store",
"ready": true,
"error": null,
"logs": [],
"methods": [
"fetch_rss_feed",
"execute_action"
]
}
},
{
"id": 102,
"name": "DeckSP",
"version": "1.1.3",
"sha256": "d6cbedcd64430e1f94a2b12caf936ba270f1ceafe77c6252808ff39c88640b40",
"status": "broken",
"reason": "Optional UI-audio lookup succeeds, but the published frontend raises \"Cannot access PluginManager before initialization\" when Steam MobX reaction lookup is unavailable. JamesDSP installation/audio routing and ARM64 remain unverified.",
"browser": {
"id": 102,
"name": "DeckSP",
"ready": false,
"error": "Cannot access 'PluginManager' before initialization",
"logs": [],
"methods": []
}
}
],
"fullCatalogFrontend": {
"attempted": 116,
"ready": 102,
"failed": 14,
"previousReady": 101,
"newlyReady": [
61
],
"regressions": [],
"backendAndNetworkDenied": true,
"library": "Private detached real Frame snapshot replay, not live Steam or backend verification"
},
"focusedBrowser": {
"host": "Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe, exact official bundles",
"installedPluginAndStatusResponses": "Fixture-shaped; no headset HTTP endpoint exercised",
"backend": "Actual official Decky 3.3.0 amd64 in unprivileged network-none container; no host audio/device/data mounts",
"deckySha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"allowedMethods": {
"TabMaster": [
"log_message",
"get_setting",
"get_users_dict",
"get_tags"
],
"Junk-Store": [
"execute_action only init/InitActions"
]
},
"checks": [
"TabMaster QAM renders; library editing controls remain disabled",
"TabMaster Open Docs -> Overview renders",
"TabMaster Filters tab renders",
"Back to plugin returns to QAM",
"Junk-Store static InitActions via real backend returns Type Error; not promoted"
],
"denied": [
"External RSS/network",
"Other execute_action arguments",
"Login, installation, game launch, library patching, audio playback"
],
"directBackendReads": {
"TabMaster.get_setting": "default string returned",
"TabMaster.get_users_dict": {},
"TabMaster.get_tags": [],
"Junk-Store.scriptMode": "0644, Python shebang present"
}
},
"headset": {
"newPluginDeviceExecution": false,
"lockProbe": "Normal atomic lock acquired/released for read-only dependency availability probe; no service or power changes",
"ARM64OfficialBackend": "Not exercised in this theme",
"wornVR": false
},
"verification": {
"frameyTests": 42,
"fullServerTests": {
"count": 1620,
"skipped": 16,
"result": "OK"
},
"siteTestFilesPassed": 2,
"fakeFrame": {
"attempted": 116,
"installedRemoved": 60,
"expectedBlockers": 56,
"backendExecuted": false,
"privateProject": "framey-catalog-6456d3d939",
"projectRemoved": true,
"baseCommit": "c89ddff"
}
},
"storeRefresh": {
"timeUtc": "2026-10-26T03:29:46.606326+00:00",
"currentCount": 116,
"pinnedCount": 116,
"unchanged": true,
"changedIds": []
},
"postMainMerge": {
"mainCommit": "38c388f",
"mergedPRs": [
286,
287,
288
],
"frameyTests": 43,
"fullCatalogFrontend": {
"attempted": 116,
"ready": 103,
"failed": 13,
"additionalReadyFromUpstream": [
135
],
"regressionsComparedWithPreMergeAudit": []
},
"catalog": {
"works": 1,
"partial": 50,
"broken": 54,
"na": 11
},
"fullServerTests": {
"count": 1621,
"skipped": 16,
"result": "OK"
}
},
"cleanup": {
"focusedBackendContainerAbsent": true,
"loopback18770Closed": true,
"loopback18771Closed": true,
"fakeProjectAndVolumesRemoved": true
}
}
@@ -0,0 +1,64 @@
# Framey-local module lookup: official plugin checks
TabMaster 2.15.1 now renders its QAM and bundled documentation in local Chromium.
Opening its docs, selecting Overview and Filters, and returning to the panel
passed. Its status moves from **broken to partial**: library editing controls
remain disabled because Steam login, live MobX reactions and library-page
patches are unavailable. This is not working library-tab customization.
The [observations](framey-module-lookup-2026-10-26.json) pin each tested official
archive and the actual Decky 3.3.0 amd64 binary. The complete current official
store was refreshed: all 116 IDs, versions and digests match the catalog.
| Plugin | Result | Remaining limitation |
| --- | --- | --- |
| TabMaster 61 | Partial: QAM and documentation navigation render | Steam library editing unavailable; ARM64 and worn VR unverified |
| Junk-Store 84 | Loading and backend-error views render without the CSS lookup crash | Actual startup/static InitActions return an execution/JSON error; extracted scripts lack executable permission; remains broken |
| SteamGridDB 36 | Module mapping is callable | Still needs Steam's LibraryContextMenu implementation; remains broken |
| DeckSP 102 | Optional UI-audio lookup succeeds | Published bundle accesses PluginManager before initialization when MobX reaction is absent; remains broken |
| Audio Loader 16 | Optional UI-audio lookup succeeds | Still requires Steam's mutable audio playback store; remains broken |
## What ran
The host exposes a finite registry of its own components and styled CSS maps,
with DFL module/export/class lookup and mapping semantics. Unknown matches return
undefined or an empty mapping. An optional audio export explicitly reports
unavailable; invoking playback throws. No Steam webpack cache, function-source
signature, audio store or library patch was manufactured.
The full exact-bundle browser pass ran with backend and network calls denied.
It completed 102 initializers and failed 14, versus 101/15 before this theme.
Initializer readiness alone does not establish rendered or useful behavior.
After integrating main’s #286/#287/#288, all 116 were retried: 103 initializers
completed and 13 failed. The additional completion is Deck Shelves from #286;
no previously completed initializer regressed.
Focused checks used actual `ui/headset.html`, React 18.3.1 and opaque
`allow-scripts` iframes. Status/installed-plugin responses were fixture-shaped,
and the private detached library snapshot from the earlier Frame SSH check was
replayed. The relay allowed only TabMaster log/settings reads and Junk-Store's
static `init/InitActions` action. TabMaster's official backend returned the
default setting, empty users dictionary and empty tags. Junk-Store returned a
structured Type Error, which its panel displayed. RSS/network, login, installs,
launch actions and other backend methods were denied.
The unmodified Python backends ran in actual Decky 3.3.0 inside a disposable
unprivileged amd64 container with no network, host audio, devices or user-data
mounts. This bypasses Steam CEF and is not a headset HTTP/RPC check. The normal
headset lock was briefly acquired and released for read-only dependency/service
availability; no official plugin ran there in this theme. No ARM64 or worn-VR
result is claimed.
## Regression checks
- 1,621 server tests passed, 16 skipped; all 43 focused Framey tests passed
after integrating the latest main.
- Both site test files passed.
- All 116 exact archives passed the fake-Frame audit: 60 install/remove flows
and 56 expected blockers. Official backend execution remained stubbed.
- The unique fake-Frame project and its volumes were removed. The focused
backend container and both loopback helpers were removed after the checks.
The catalog and generated compatibility table use the same status/reasons as
both store views. The result is **1 works / 50 partial / 54 broken / 11 N/A** (including main’s independent Deck Shelves improvement);
the majority goal remains open.
@@ -0,0 +1,116 @@
{
"checkedAtUtc": "2026-10-25",
"source": "https://plugins.deckbrew.xyz/plugins",
"method": "Downloaded exact current official store versions; local SHA-256 matched each store hash; inspected ZIP plugin.json and dist/index.js without running plugin code.",
"limit": "Static inspection on PC. Prior backend load results are documented in docs/decky-frame.md; VR visibility and controller input remain unverified.",
"plugins": [
{
"storeId": 7,
"name": "CSS Loader",
"storeVersion": "2.1.3",
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
"manifestApiVersion": null,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 14,
"name": "ProtonDB Badges",
"storeVersion": "1.3.4",
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
"manifestApiVersion": 2,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 16,
"name": "Audio Loader",
"storeVersion": "1.6.1",
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
"manifestApiVersion": null,
"manifestFlags": [
"_root"
],
"frontendBundle": true
},
{
"storeId": 18,
"name": "Bluetooth",
"storeVersion": "3.1.1",
"sha256": "f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 21,
"name": "PowerTools",
"storeVersion": "2.0.3",
"sha256": "47614f53b8c538c4caa15f89a01e4ab106fa328e89f78545bacb3166d104d964",
"manifestApiVersion": null,
"manifestFlags": [
"root",
"global-dfl"
],
"frontendBundle": true
},
{
"storeId": 23,
"name": "Animation Changer",
"storeVersion": "1.3.2",
"sha256": "f2c62b90ca60d8a80b6d0f75d8027552b1509c7a05842c6f4a24a9072846d133",
"manifestApiVersion": null,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 26,
"name": "HLTB for Deck",
"storeVersion": "2.0.12",
"sha256": "bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 36,
"name": "SteamGridDB",
"storeVersion": "1.8.1",
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
"manifestApiVersion": 1,
"manifestFlags": [
"debug"
],
"frontendBundle": true
},
{
"storeId": 50,
"name": "Storage Cleaner",
"storeVersion": "1.4.0",
"sha256": "1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
},
{
"storeId": 68,
"name": "Wine Cellar",
"storeVersion": "0.2.1",
"sha256": "7e077b933c234414d172b5c6d1c804b026bf39fa4e18e6663495a282637031e8",
"manifestApiVersion": 1,
"manifestFlags": [
"debug"
],
"frontendBundle": true
},
{
"storeId": 82,
"name": "MagicPods",
"storeVersion": "2.0.21",
"sha256": "abe2eef5788cb3ff6e73fcb9783e30a54b127e28c761a9bf99e64ab5fc4be9ab",
"manifestApiVersion": 1,
"manifestFlags": [],
"frontendBundle": true
}
]
}
@@ -0,0 +1,66 @@
{
"timeUtc": "2026-10-25T23:35:22.022586+00:00",
"method": "Real Frame ARM64 Decky 3.3.0, isolated removable user service; explicit read-only plugin methods only",
"plugins": [
{
"id": 14,
"version": "1.3.4",
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
"method": "get_setting",
"result": {
"result": "default"
},
"errors": [
"Transient CEF reconnect during Decky frontend injection; succeeded on retry."
]
},
{
"id": 123,
"version": "1.2.3",
"sha256": "9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e",
"method": "get_settings",
"result": {
"keys": [
"enable_notification_antiflood",
"enable_notifications",
"gateway_api_key",
"gateway_url",
"refresh_interval_seconds",
"show_history",
"show_regions",
"show_trending_games"
],
"defaultRefreshInterval": 120
},
"errors": []
}
],
"before": {
"battery": [
100
],
"deckyService": "inactive",
"panelService": "inactive"
},
"service": {
"created": true
},
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"passed": true,
"cleanup": {
"baseAbsent": true,
"unitAbsent": true
},
"after": {
"battery": [
99
],
"deckyService": "inactive",
"panelService": "inactive"
},
"lockReleased": true,
"cleanupErrors": []
}
@@ -0,0 +1,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.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,101 @@
# Running-game context and Discord Status, 2026-10-26
Framey now projects running apps and the selected main app from Steam CEF into
detached `SteamUIStore` / `DFL.Router` getters. It relays actual Steam lifetime
callbacks while the panel is open. Public Steam CDN capsule URLs are projected
for running apps; local artwork paths and URL query data never cross the bridge.
The [machine-readable evidence](framey-running-context-2026-10-26.json) records
the exact archives, fixtures, browser observations and cleanup results.
| Plugin / scope | Result | Verification |
| --- | --- | --- |
| Discord Status 1.5.0 | **Broken → partial.** Disconnected panel renders; activity construction accepts the synthetic running game without the previous missing-store/artwork errors. | Exact unmodified official frontend in opaque Chromium iframe; backend/network denied. Actual official `is_connected` returns false in real Decky 3.3.0 ARM64, with no Discord IPC present. |
| Pause Games | **Partial.** Running-list initialization reaches `pid_from_appid` for the synthetic game. | Local Chromium fixture; all backend calls, including settings writes and process actions, denied. |
| Notebook | **Works for its previously verified default notebook scope.** Per-game context is now supplied, but real per-game selection remains unverified. | Synthetic game causes the official frontend to request `loadPage`; backend calls denied in this pass. Earlier default save/reopen ARM64 evidence remains separate. |
| PlayTime | **Partial.** Running snapshots do not satisfy its private MobX observation. | Local Chromium explicitly logs that its running-app observer is unavailable. No automatic tracking claim. |
| 14 other focused panels | Labels retained. Initialization alone does not establish backend functionality. | Exact frontends with synthetic nonempty running/library snapshots and backend/network denied; Game Sync still stops on the denied `get_config` read. |
The bridge addresses a shared dependency detected in 34 official frontend
bundles, including hardware-inapplicable plugins whose installation remains
blocked. This is infrastructure coverage, not 34 working plugins. Catalog after
this pass: **3 works / 49 partial / 53 broken / 11 N/A**.
## Real Frame checks and cleanup
Every device probe acquired the atomic `/tmp/frame-test.lock` and released only
its matching owner. Read-only probes verified the actual RunningApps and
MainRunningApp getter implementations, the lifetime API's object with an
`unregister()` method, and a public `https://shared.steamstatic.com` capsule URL.
A private removable user service ran the published Decky 3.3.0 ARM64 executable
and the exact unmodified Discord Status 1.5.0 store archive. Production
`framey_panel.call_method` returned false from `is_connected`. Production
`poll_running` verified two empty running-list reads, an actual lifetime
subscription and explicit close. The device had no running apps: **nonempty
reads and launch/exit callbacks were not observed on hardware**. Decky startup
reinjected its frontend/restarted Steam CEF. No plugin game, power, audio or process-control
action was invoked, and no Discord connection/activity mutation was allowed.
After close, CEF reported zero sessions and a null timer. The test service,
unit and private base were removed. A fresh independently locked audit read
`LoadState=not-found`, `ActiveState=inactive`, `MainPID=0`, absent paths and zero
CEF sessions/timers. Battery stayed **99 → 99**; normal Decky stayed failed and
the normal panel service stayed inactive. No headset wipe or system service
installation was used.
## Local verification and limits
- Full current official store fetch: 116 IDs/versions/digests unchanged at
2026-10-26 05:18:22 UTC.
- Fake Frame: 116 transport cases, 60 install/remove cycles and 56 expected
blockers; private Compose project and volumes removed. Plugin execution was
stubbed.
- Full local Chromium sweep: 103 of 116 initializer-ready with explicitly
synthetic library/running data. No backend/network request was allowed.
The focused Discord Status observation lasted eight seconds and constructed
`Test Running Game` activity; the visible UI correctly remained disconnected.
- Node contract tests cover actual-event queue projection, retry/deduplication,
bounds, expiry, unregister, reconnect, update-before-callback ordering,
invalid data, public-artwork filtering, stale sessions, overlapping reads,
failure clearing stale state and late replies after close. Nonempty app rows
and launch/exit notifications in these tests are explicit synthetic fixtures.
The queue retains at most 64 real notifications per session; overflow can lose
old callbacks. Polling runs about every two seconds with no overlap. At most four
CEF sessions survive; unreachable cleanup expires after 15 seconds, checked
every five seconds. The bridge does not implement private MobX internals,
suspend/input events, game actions or closed-panel automation.
Discord shortcut detection, real IPC connection/activity, local custom artwork,
actual running-game transitions, worn VR and physical controller/keyboard use
remain unverified. None of the new fixture checks promotes a plugin to works.
After #301 merged, running polls and both close paths adopted its pinned SHA-256
receipt guard. Local Python/Node tests verify the parent carries that receipt,
missing/stale receipts are rejected before CEF after plugin replacement, and a
reopened panel can poll and close with the new receipt. Rejected stale closes
retain the tested finite-lease cleanup. This authorization-only follow-up did
not repeat the device checks above; their recorded hardware scope is unchanged.
## Post-merge unworn panel exercise
On merged main `daee952873255b86018cce1d2a4d3bd070ec4341`,
`scripts/framey-vr-check.py --exercise --output /tmp/framey-post304-exercise`
acquired its own `/tmp/frame-test.lock` before any headset read. At 05:54 UTC,
preflight found SteamVR activity 3 (standby), proximity 6.25 and battery 99%.
The temporary sample panel passed the real Decky setting save and fresh-iframe
reopen, a trusted synthetic OpenVR pointer, quick action, slider value 50 and
CDP text entry after OpenVR focus. Stereo compositor SHA-256 changed from
`50fcab1ee25e6d9398106cc0c24ec6616e07bcf4573b52be1774c715b756060b`
to `98241806fb1f775e7f4c5ef0b0564bfd3c552ee896ff225b4d0cb5b7b53f70d1`;
the `/dev/video99` mirror capture hash was
`c284c05f86b60362168c1d1f58fe775974b70ac54bbfd57834802b892eb4509e`.
The command exited 0 with `passed=true`. The original power values (`false` and
`5.000000`) were read back, battery remained 99%, the recovery timer was removed,
cleanup errors were empty and the run released only its own lock. The private
full report is `/tmp/framey-post304-exercise/result.json` on the test host.
This exercise covered the sample panel path after the running-context merge. It
did not observe a nonempty running-game list, a game lifetime callback, Discord
activity, a worn display or physical controller input. Synthetic native scroll
again did not move (`scrollTop=0`, no wheel event).
@@ -0,0 +1,184 @@
{
"checkedAtUtc": "2026-10-26T07:22:08.822239+00:00",
"plugin": {
"id": 84,
"name": "Junk-Store",
"version": "1.1.13",
"sha256": "f7ad2a0103c85bf308929b2e96ec9aeeb0d76bcc6ae97eb1e4b5f31ffba0cbb1",
"license": "BSD-3-Clause",
"sourceModified": false
},
"installer": {
"beforeScriptMode": "0644",
"afterScriptMode": "0744",
"policy": "Add owner execute for a shebang or ZIP-declared executable; do not restore archive privilege/write bits. Existing installs require update or reinstall."
},
"isolatedBackend": {
"runtime": "published Decky 3.3.0 amd64",
"loaderSha256": "e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181",
"isolation": [
"unprivileged",
"no network",
"no devices",
"no capabilities",
"read-only root",
"private tmpfs home and tmp"
],
"beforeInit": {
"result": {
"Type": "Error",
"Content": {
"Message": "Error parsing json result Expecting value: line 1 column 1 (char 0)",
"Data": "",
"ActionName": "init",
"ActionSet": "init"
}
},
"success": true
},
"afterInit": {
"result": {
"Type": "Success",
"Content": {
"Message": "Initialized"
}
},
"success": true
},
"beforeInitActions": {
"result": {
"Type": "Error",
"Content": {
"Message": "Error parsing json result Expecting value: line 1 column 1 (char 0)",
"Data": "",
"ActionName": "InitActions",
"ActionSet": "init"
}
},
"success": true
},
"afterInitActions": {
"outerSuccess": true,
"innerType": "ActionSet",
"setName": "MainMenu",
"actionCount": 4
}
},
"realFrame": {
"os": "Frame OS 0.5.6",
"arch": "aarch64",
"uid": 1000,
"decky": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
},
"transport": "existing trusted SSH via lxso2",
"sharedLock": true,
"service": "private removable user service; not enabled; RuntimeMaxSec=600",
"installedWithProductionApply": true,
"scriptMode": "0o744",
"legendaryInstalled": false,
"backendMethods": [
"execute_action",
"get_websocket_port"
],
"staticActions": [
"JunkStoreActions/GetContent",
"JunkStoreActions/GetEpicActions",
"MainMenu/GetContent",
"MainMenu/JunkStoreInit",
"init/InitActions"
],
"websocketPort": 8765,
"websocketCommandsSent": false,
"librarySnapshotCounts": {
"apps": 1106,
"games": 895
},
"explicitInitActionInvoked": false,
"normalDeckyBeforeAfter": [
"inactive",
"inactive"
],
"normalPanelBeforeAfter": [
"inactive",
"inactive"
],
"batteryBeforeAfter": [
[
99
],
[
100
]
],
"powerSettingChanged": false,
"cleanup": {
"baseAbsent": true,
"unitAbsent": true
},
"lockReleased": true
},
"localBrowser": {
"runtime": "local Chromium, real headset.html/framey-host.js, vendored React, official frontend in opaque iframe",
"backend": "production receipt-bound bridge to actual ARM64 backend",
"steamLibrary": "production receipt-bound GetLibrarySnapshot; only counts retained",
"outerStatusInstallResponses": "fixtures",
"passed": [
"Custom Stores main menu and Games/News/About navigation items render",
"Games route opens static Epic tab",
"Back returns to main menu",
"close/reopen returns to main menu"
],
"remainingFailure": "About: Cannot read properties of undefined (reading 'installed'); detached app overview lacks local_per_client_data.installed",
"deniedActions": [
"fetch_rss_feed",
"EpicActions/GetContent"
],
"denialIsNotBackendFailure": true
},
"cleanupAudit": {
"attempts": 3,
"allPrivateBasesAbsent": true,
"allPrivateUnitsAbsent": true,
"allArchivesAbsent": true,
"allServicesInactive": true,
"freshAuditLockReleased": true
},
"verification": {
"python": {
"run": 1628,
"skipped": 16,
"failures": 0
},
"site": {
"passed": 18
},
"focusedStore": {
"passed": 19
},
"fakeFrame": {
"blocked": 56,
"installed-removed": 60
},
"officialStore": {
"plugins": 116,
"errors": 0,
"identitySetUnchanged": true,
"checkedAtUtc": "2026-10-26T07:22:17.371046+00:00"
},
"staticEntrypointArchives": 28
},
"unverified": [
"Legendary dependency installation",
"Epic login",
"game listing",
"game downloads or launch",
"RSS feed",
"Steam shortcut mutation",
"optional websocket operations",
"deployed headset HTTP endpoint",
"worn VR"
],
"status": "partial"
}
@@ -0,0 +1,76 @@
# Executable script entrypoints: Junk-Store
Junk-Store 1.1.13 (official ID 84) moves **broken → partial**. The exact unmodified
BSD-3-Clause archive is pinned in the [redacted results](framey-script-entrypoints-2026-10-26.json).
No ARM64 rebuild or upstream plugin source patch was needed.
Python ZIP extraction drops execute permissions. This archive also publishes its
shebang scripts as 0644, relying on upstream Decky's recursive permission setup.
Framey's user service disables that setup. Direct script calls therefore fail.
The installer now adds **owner execute only** to shebang scripts or ZIP-declared
executables. It does not restore archive privilege/write bits, grant group/other
execute, or change unmarked data permissions. Archive validation and installation
blocks remain in place. Existing installations need update/reinstall for repair.
The upstream comparison is Decky 3.3.0's
[permission setup](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/browser.py)
and [Linux implementation](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/localplatform/localplatformlinux.py).
Static inspection found 28 accepted official archives with shebang or declared
executable members. This is shared installer coverage, not 28 functional claims.
## Before and after in an isolated real backend
Both runs used the exact official plugin and published Decky 3.3.0 amd64, with no
network/devices/capabilities, a read-only root and private temporary home/tmp.
The only difference was the production installer before/after this change.
| Check | Before | After |
| --- | --- | --- |
| `scripts/get-json.py` | 0644 | 0744 |
| `execute_action(init, init)` | inner `Type:Error`, empty output / JSON parse error | `Type:Success`, `Initialized` |
| `execute_action(init, InitActions)` | inner `Type:Error` | `Type:ActionSet`, `MainMenu`, four actions |
The legacy wrapper reports `success:true` even for the failing inner error.
Startup log text alone is also insufficient: the baseline emits initialized/started
messages despite those errors. Both private containers were removed.
## Real ARM64 Frame and local Chromium
Under the shared headset lock, the patched production installer installed the
official archive into a private, bounded, removable Decky 3.3.0 ARM64 user service
on Frame OS 0.5.6. The service was not enabled. Legendary was absent before and
after; no dependency installer, login, download, launch or websocket command ran.
The production receipt-bound bridge called the actual backend through live Steam
CEF. Static action sets, main-menu content and the Epic action set returned real
results. `get_websocket_port` returned 8765. Explicit `init/init` success was tested
in the isolated amd64 backend; it was not explicitly invoked in the final Frame run.
Local Chromium ran the actual Framey host and official frontend in an opaque
iframe, relaying only allowed static calls to that ARM64 backend. Main menu,
Games → static Epic tab, Back and fresh close/reopen passed. Steam library reads
used the production receipt-bound bridge; only counts were retained. Outer
status/install responses were fixtures, and the deployed headset HTTP endpoint
was not tested.
The About route still crashes on missing `local_per_client_data.installed` in
the detached app overview. Epic game-list and RSS calls were deliberately denied;
their loading/error views do not demonstrate backend failure. Dependency
installation, login, game operations, RSS, Steam shortcut mutation and worn VR
remain unverified. This supports **partial**, not works.
Three temporary attempts were cleaned, including an initial Steam reinjection
readiness failure and a test-relay omission of the library endpoint. A fresh
shared-lock audit found all three services inactive/unloaded (PID 0), with their
private bases, unit files and archives absent. Normal managed Decky/panel services
stayed inactive; final battery was 99 → 100 while charging. No power-setting action
ran. The audit lock was released, and local relays/browser tabs were closed.
## Verification
- Python suite: 1,628 tests, 16 skipped, no failures; store suite: 19 passed.
- Site suite: 18 passed. Two new regression tests failed before the fix.
- All 116 official plugins: fake-Frame 60 installed/removed, 56 expected blocks;
no actual plugin backends run by this harness. Disposable project cleaned.
- Fresh official-store audit: 116 plugins, zero errors; ID/version/digest set
matches the catalog. Other plugin statuses are unchanged.
@@ -0,0 +1,23 @@
# Unworn Framey scroll transport probe, 26 October 2026
The previous `scripts/framey-vr-check.py --exercise` runs reported `scrollY=0` before and after one injected smooth-scroll event. That was inconclusive because the repository sample may fit inside the iframe. This probe changed only the **test script**: it created a temporary 160-pixel-high scroll region with 716 pixels of content, moved the native OpenVR pointer over it, and measured its own `scrollTop` and captured wheel events. No production Framey panel code changed.
Two local diagnostic versions ran on the real Frame from base main `784118b` after PR #292. The final version in this PR tried OpenVR `VREvent_ScrollSmooth` (309) and `VREvent_ScrollDiscrete` (305), using Valve's documented `VREvent_Scroll_t` ABI. The overlay listing showed the temporary Frame Control dashboard visible with `+smoothscroll`; a trusted native pointer move reached the iframe. Neither scroll event reached Chromium's capture listener and the region stayed at `scrollTop=0`. This establishes only that `IVROverlayView_003.PostOverlayEvent` did not deliver these synthetic scroll events in this run. A physical controller may follow a different path.
| Check | First probe | Final probe |
| --- | --- | --- |
| Unique shared lock and standby gate | Acquired; activity 3, battery 99% | Acquired; activity 3, battery 100% |
| Scroll region | 160 px viewport, 716 px content | 160 px viewport, 716 px content |
| Native pointer | Moved over region | Trusted pointer move reached iframe |
| Injected scroll | Smooth 309 | Smooth 309, then discrete 305 |
| Wheel events / offset | 0 / 0 → 0 | 0 / 0 → 0 |
| Other sample checks | Passed | Passed |
| Cleanup | Power restored to `false/5`, recovery timer removed, owned lock released; battery 99% | Same cleanup; battery 99% |
Both runs captured changed stereo compositor frames and a `/dev/video99` mirror frame, saved only in private folders `/tmp/framey-native-scroll-probe/` and `/tmp/framey-native-scroll-events/`. No room pixels are committed. The final run's `result.json` reported `passed=true`, `cleanupErrors=[]`, `powerRecoveryRemoved=true` and `lockReleased=true`. Here `passed` covers the sample setting, quick action, slider, text and cleanup checks; **native scroll did not pass**. The final immediate SteamVR activity was 0 after the temporary wake; original power settings were restored and read back. No person wore the headset, and physical controller scrolling, rays, haptics, keyboard, optical readability and comfort remain unverified. The hourly worn-check schedule remains enabled.
## Post-#294 exact-main sample check
After PR #294 merged, the **unmodified** `scripts/framey-vr-check.py --exercise` from exact main `c38e7e8da459e44a9a717d15e6a07ab8a8e8ef8d` exited 0 on the real Frame. Locked preflight found activity 3 standby, proximity 6.3125 and battery 99%. The sample saved `{"enabled": true}` through trusted synthetic OpenVR input and the live Decky backend, read the file independently, then reopened a fresh iframe with the value retained. Quick action, slider value 50 and text entry completed; compositor frames changed and the mirror capture returned a frame. The old scroll diagnostic still reported no offset movement.
The run removed its temporary services and recovery timer, restored/read back power `false/5`, reported no cleanup errors, and released only its owned lock. Its immediate activity was 0 after the temporary wake. Private captures/result are under `/tmp/framey-post294/` outside Git. This was one setting activation, so it does not prove the new rapid-double-input guard on physical controllers. No person wore the headset.
@@ -0,0 +1,26 @@
# Framey session cache browser check, 25 October 2026
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
used a per-panel memory substitute. It did **not** change the iframe's sandbox
flags or give plugin code Frame Control's UI key.
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
from `tests/fixtures/framey-official/123.zip` (SHA-256
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
four `get_settings` calls. That reply was a stub, not the Decky backend.
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
The parent could not read the iframe document (`contentDocument` was null).
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
Managers and Players Online. Its public status request returned HTTP 200.
No cache `SecurityError` appeared in the captured console, while the
same archive had logged that error with the v0.4.34 host.
Node host tests also checked item updates, removal, clear, key order, string
conversion, quota rejection, and preservation of an existing item after a
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
values **in memory**. Closing or reloading the panel loses this cache. This
check did not test offline behavior, persistent plugin settings, a live Decky
backend, SteamVR visibility, controller input, or headset performance.
@@ -0,0 +1,99 @@
{
"checkedAtUtc": "2026-10-26T00:40:00Z",
"scope": "Actual SteamClient parameterless reads through Steam CEF on ARM64 Frame. Official frontends ran in local Chromium with plugin backend/network calls denied. Private loopback relay used the production read bridge with fixture installed-plugin status and the real frame_steam module supplied in memory because the optional headset service was not deployed. This does not verify a deployed real-Frame HTTP endpoint.",
"allowlist": [
"User.GetIPCountry",
"InstallFolder.GetInstallFolders",
"System.GetSystemInfo"
],
"probeResults": {
"GetIPCountry": {
"ok": true,
"type": "string",
"length": 2,
"value": "redacted"
},
"GetInstallFolders": {
"ok": true,
"folders": 1,
"apps": 29,
"pathsAndTitles": "redacted"
},
"GetSystemInfo": {
"ok": true,
"fields": 27,
"identifiers": "redacted"
}
},
"officialPanels": {
"IsThereAnyDeal for Deck": {
"observation": {
"id": 81,
"name": "IsThereAnyDeal for Deck",
"ready": true,
"error": null,
"logs": [],
"methods": [
"settings_load",
"settings_save"
]
},
"countryDropdownMatchesLiveFrameValue": true,
"backendSettingsSave": "attempted by official frontend; denied; no setting written",
"dealLookups": "not run"
},
"Deck Settings": {
"observation": {
"id": 93,
"name": "Deck Settings",
"ready": true,
"error": null,
"logs": [
{
"kind": "error",
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
},
{
"kind": "error",
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: TypeError: Cannot read properties of undefined (reading 'allGamesCollection')"
}
],
"methods": []
},
"remainingBlocker": "collectionStore.allGamesCollection"
}
},
"boundariesTested": [
"Only three explicit zero-argument APIs",
"Installed store plugin required",
"Headset-service gate",
"Iframe source and current session required",
"Late replies dropped after reopen",
"256 KiB UTF-8 response cap and socket cleanup"
],
"deviceState": {
"sharedLock": "owned normally; released after testing",
"servicesInstalledOrStarted": false,
"powerChanges": false,
"deviceFilesForSteamProbe": false
},
"unverified": [
"Deployed headset HTTP endpoint",
"Plugin backend settings/deal lookups",
"Steam page patches and live library context",
"Worn VR/controller/keyboard"
],
"catalogRefresh": {
"source": "https://plugins.deckbrew.xyz/plugins",
"checkedAtUtc": "2026-10-26T00:44:00Z",
"visiblePlugins": 116,
"idVersionDigestChanges": 0
},
"localRegressionAudit": {
"exactArchivedFrontends": 116,
"ready": 98,
"failed": 18,
"initializationRegressions": 0,
"pluginBackendAndNetworkActions": "denied, including live Steam read APIs"
}
}
@@ -0,0 +1,28 @@
# Steam Status in the local Framey browser, 25 October 2026
This check used the unmodified official Steam Status 1.2.3 archive already
pinned in `tests/fixtures/framey-official/123.zip`. Its SHA-256 is
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`.
The archive declares Decky API v2. No headset was contacted for this check.
On a Linux PC, we extracted the archive to a disposable plugin folder with a
Frame Control store receipt. A local HTTP server served the page from
`framey_panel.panel(123)`, Frame Control's React 18.3.1 runtime and its
`framey-host.js`. A parent page put the panel in an iframe with
`sandbox="allow-scripts"` and returned `{}` for the plugin's `get_settings`
backend calls. This was a **stub**, not the Decky service.
Chromium reported `framey:ready` and four `get_settings` calls. The page showed
Steam Status, Store, Community, WebAPI, Connection Managers, Players Online,
and other controls. Its own public `https://steamstatus.schelstraete.org/api/v1/status`
request returned HTTP 200 and populated the status view. A click on **Refresh**
sent Framey's trusted pointer event to the parent; this test did not prove a
fresh status fetch or settings write. The [browser screenshot](framey-steam-status-browser-2026-10-25.png)
contains only the local test page and public service data.
The plugin logged `SecurityError` while reading and writing `localStorage` in
the opaque-origin iframe. It caught the errors and rendered anyway. Its cache
and offline behavior in Framey are **not verified**. The real Decky backend,
live settings, SteamVR visibility, controller input and headset performance
were not tested. This result supports only local frontend rendering for this
exact archive and Frame Control v0.4.34 host code.
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

@@ -0,0 +1,107 @@
{
"checkedAtUtc": "2026-10-26T00:29:00Z",
"scope": "Exact archived frontends only. Production Framey host with bounded SteamClient JSON cache, image fallback and inert Steam subscriptions/context. Opaque iframe; all backend calls and network denied. No headset or plugin backends used in this follow-up.",
"baseHostCommit": "52a0974",
"before": {
"ready": 96,
"failed": 20
},
"after": {
"ready": 98,
"failed": 18
},
"regressions": [],
"observations": [
{
"id": 13,
"name": "Pause Games",
"ready": true,
"error": null,
"logs": [],
"methods": [
"load_settings"
]
},
{
"id": 43,
"name": "Tailscale Control",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_tailscale_state"
]
},
{
"id": 46,
"name": "Steamback",
"ready": false,
"error": "Cannot read properties of undefined (reading 'm_CurrentUser')",
"logs": [],
"methods": []
},
{
"id": 66,
"name": "PlayTime",
"ready": false,
"error": "Cannot read properties of undefined (reading 'm_mapApps')",
"logs": [
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
},
{
"kind": "error",
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
}
],
"methods": [
"fetch_playtime_information",
"statistics_for_last_two_weeks"
]
},
{
"id": 120,
"name": "Reroll",
"ready": true,
"error": null,
"logs": [],
"methods": [
"get_setting",
"version"
]
}
],
"tailscaleFocusedCheck": {
"rendered": [
"Settings",
"Toggle Tailscale",
"Exit Node IP",
"Toggle LAN Access",
"Advanced Settings",
"Device Status",
"Tailscale Setup"
],
"assetError": "Hardcoded localhost:1337 QR SVG blocked by image CSP",
"actions": "Inspected only; no toggles, exit nodes, installs or connection actions"
},
"pauseGamesFocusedCheck": {
"rendered": [
"Pause before Suspend",
"Pause on focus loss"
],
"actions": "Inspected only; no process signaling or suspend actions"
},
"storageChecks": [
"Per-plugin cache survives panel reopen",
"Missing key rejects Not found",
"JSON serialization and cache quota enforced",
"Malformed and stale-session cache updates rejected"
],
"unverified": [
"Real backends for these plugins",
"Tailscale network state/actions",
"Live games and pause/resume",
"Worn VR/controller/keyboard"
]
}
@@ -0,0 +1,405 @@
{
"baseCommit": "daee952873255b86018cce1d2a4d3bd070ec4341",
"plugin": {
"id": 58,
"name": "Volume Boost",
"version": "0.0.7",
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc"
},
"runtime": {
"version": "v3.3.0",
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6",
"os": "Frame OS 0.5.6",
"arch": "aarch64"
},
"verificationLimits": {
"fakeFrame": "Prior exact archive HTTP/SSH install/receipt/removal; plugin execution stubbed, not repeated",
"localBrowser": "Actual headset.html, React and Framey host, exact unmodified official frontend in opaque iframe; direct RPC relay to actual ARM64 backend",
"realFrameSSH": "Production receipt-bound framey_panel.call_method over SSH and live Steam CEF DeckyBackend.call into actual Decky 3.3.0 ARM64 plugin; default speaker sink remained muted throughout",
"wornVR": "Not tested",
"audibleOutput": "Not tested; gain writes and readbacks only",
"deployedHTTP": "Not tested; outer status/install/check responses were fixtures, methods used production call_method with exact current receipt"
},
"catalogRefresh": {
"checkedAtUtc": "2026-10-26T06:10:58.537029+00:00",
"plugins": 116,
"changedIdsVersionsDigests": 0
},
"runs": [
{
"timeUtc": "2026-10-26T06:07:19.370988+00:00",
"locale": "LC_ALL=C for the private Decky service",
"originalSink": {
"mute": true,
"volumes": {
"front-left": 0,
"front-right": 0
}
},
"writes": [
{
"request": {
"value": 125,
"channel": "both"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 81920,
"front-right": 81920
}
},
"backendReadback": {
"left": {
"result": 125,
"success": true
},
"right": {
"result": 125,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 75,
"channel": "both"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 49152,
"front-right": 49152
}
},
"backendReadback": {
"left": {
"result": 75,
"success": true
},
"right": {
"result": 75,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 74,
"channel": "both"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 48496,
"front-right": 48496
}
},
"backendReadback": {
"left": {
"result": 74,
"success": true
},
"right": {
"result": 74,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 33,
"channel": "left"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 21626,
"front-right": 0
}
},
"backendReadback": {
"left": {
"result": 33,
"success": true
},
"right": {
"result": 0,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 67,
"channel": "right"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 0,
"front-right": 43909
}
},
"backendReadback": {
"left": {
"result": 0,
"success": true
},
"right": {
"result": 67,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
},
{
"request": {
"value": 33,
"channel": "right"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 0,
"front-right": 21626
}
},
"backendReadback": {
"left": {
"result": 0,
"success": true
},
"right": {
"result": 33,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
}
],
"reads": [
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
},
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
}
],
"reopen": "Fresh query/session, ready; UI returned to actual restored zero; no write on mount",
"before": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "inactive",
"panel": "inactive"
},
"after": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "inactive",
"panel": "inactive"
},
"cleanup": {
"baseAbsent": true,
"unitAbsent": true
},
"exactSinkRestored": true,
"lockReleased": true
},
{
"timeUtc": "2026-10-26T06:12:04.889613+00:00",
"locale": "Inherited user-service environment, matching production unit; no LANG or LC_ALL override",
"originalSink": {
"mute": true,
"volumes": {
"front-left": 0,
"front-right": 0
}
},
"writes": [
{
"request": {
"value": 150,
"channel": "both"
},
"result": {
"result": true,
"success": true
},
"independentPulseReadback": {
"mute": true,
"volumes": {
"front-left": 98304,
"front-right": 98304
}
},
"backendReadback": {
"left": {
"result": 150,
"success": true
},
"right": {
"result": 150,
"success": true
}
},
"guardArmedBeforeWrite": true,
"exactOriginalRestored": true
}
],
"reads": [
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
},
{
"id": 58,
"method": "get_volume_state",
"args": [
{
"channel": "left"
}
],
"result": {
"result": 0,
"success": true
}
}
],
"reopen": "Fresh query/session, ready; UI returned to actual restored zero; no write on mount",
"before": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "inactive",
"panel": "inactive"
},
"after": {
"battery": [
100
],
"uid": 1000,
"arch": "aarch64",
"decky": "inactive",
"panel": "inactive"
},
"cleanup": {
"baseAbsent": true,
"unitAbsent": true
},
"exactSinkRestored": true,
"lockReleased": true
}
],
"guard": {
"scope": "Private test helper only; not a shipped feature or plugin patch",
"timeoutSeconds": 60,
"runtimeMaxSec": 120,
"sequence": [
"Capture exact default sink identity, native per-channel volume integers and original mute",
"Persist recovery state; start bounded device-side systemd watchdog before mute/write",
"Require stereo front-left/front-right and mute readback before plugin call",
"Only official get_volume_state/set_volume_state; value 0..150, known channels",
"Read native Pulse volume and mute independently; read both channels through actual plugin",
"Restore exact native volume while muted, verify, then restore original mute and verify exact snapshot",
"Remove recovery state, stop only own watchdog; cleanup own service/base and release matching lock"
],
"automaticTimeoutExercised": false,
"explicitRestoreExercised": true
},
"freshCleanupAudit": {
"exactSinkRestored": true,
"baseAbsent": true,
"unitAbsent": true,
"services": {
"framey-boost-kp0z857q58-b4126054.service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
"framey-boost-kp0z857q58-b4126054-guard.service": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n"
},
"battery": [
100
]
},
"remaining": [
"Audible sound/quality, perceived loudness and physical speaker gain are unverified",
"Worn VR, headset controller input and Bluetooth sinks are unverified",
"No default route change, playback or power-setting action was invoked",
"Decky startup reinjects its frontend and can restart Steam CEF; normal managed services stayed inactive"
],
"repositoryChecks": {
"python": "1,626 tests, 16 skips, no failures",
"site": "18 passed",
"catalogDocCheck": "passed",
"diffCheck": "passed"
}
}
@@ -0,0 +1,96 @@
# Volume Boost sink controls on the ARM64 Frame
Volume Boost 0.0.7 (official store ID 58) now qualifies as **works for sink
controls**. The exact official, unmodified Python backend ran in Decky 3.3.0
ARM64 on Frame OS 0.5.6. Local Chromium rendered its official frontend through
Framey's existing legacy bridge. Actual control changes reached the Frame's
default speaker sink, including above-100% gain, with independent Pulse
readbacks. The sink stayed muted; no audible-output or worn-VR claim is made.
[Digest-pinned observations and cleanup](framey-volume-boost-arm64-2026-10-26.json)
record both runs. This supersedes the Volume Boost read-only limitation in the
[previous ARM64 audio check](framey-audio-arm64-2026-10-26.md). No plugin patch,
native rebuild or new compatibility shim was necessary. Volume Boost's backend
uses Python and the Frame's existing ARM64 `pactl`; its hard-coded UID 1000
matches the actual Frame user.
| Local Chromium action | Actual ARM64 result, independently read back |
| --- | --- |
| Linked numeric input 125% | Native Pulse channel values 81920 / 81920; plugin reads 125 / 125 |
| Linked slider click, then ArrowDown | 75% then 74%; native values 49152 / 49152 then 48496 / 48496 |
| Unlink, set left to 33% | Native values 21626 / 0; plugin reads 33 / 0 |
| Set right to 67% | Native values 0 / 43909; plugin reads 0 / 67 |
| Re-link | Official frontend issues a right-only 33% write; native values 0 / 21626 |
| Close and reopen | New document/session, ready; backend reads and displayed linked value return to the restored 0%; no mount write |
| Repeat linked numeric 150% with inherited service locale | Native values 98304 / 98304; plugin reads 150 / 150; reopen again reads restored zero |
The test restores the original sink state after **each** write. Therefore the
separate-channel checks intentionally begin from 0% / 0%, rather than from the
previous UI value. A right-only re-link request is the published plugin's
behavior. These checks verify channel selection and preservation of the other
channel; they do not claim a continuous unmuted listening session. Pulse's
integer conversion truncates fractional native values; the evidence retains
those integers rather than replacing them with rounded percentages.
## Runtime and transport
Each run separately acquired the atomic `/tmp/frame-test.lock`; neither stole
an existing lock. A unique private Decky user service used its own plugin,
settings and runtime directories, was never enabled, and had an eight-minute
runtime limit. The upstream ARM64 loader matched GitHub's published SHA-256
and ELF machine 183. The plugin ZIP matched the official digest.
The local relay served actual `ui/headset.html`, vendored React and
`framey-host.js`, with exact official frontend bytes in an opaque
`allow-scripts` iframe. Outer status/install/check responses were fixtures.
Plugin calls used production `framey_panel.call_method`, including the current
store receipt, SSH through lxso2, live Steam CEF and `DeckyBackend.call` into the
actual plugin subprocess. Only the temporary install paths were redirected.
This does not verify the deployed headset HTTP endpoint or a VR compositor.
The first private service explicitly used `LC_ALL=C`. The second inherited the
user-service locale without a `LANG` or `LC_ALL` override, matching the
production service configuration, and repeated actual reads, a 150% write and
reopen. Zero alone was not accepted as success: upstream returns zero on
command failure. Every write returned true, both backend channel reads agreed,
and independent native Pulse readbacks confirmed nonzero changes.
## Bounded muted writes and restoration
The default sink was already muted at native channel values 0 / 0. Before each
plugin write, a private device-side helper captured the exact sink identity,
channel values and mute state; persisted recovery state; and started a bounded
60-second systemd watchdog. It then required a successful mute readback before
allowing the plugin call. The relay allowed only `get_volume_state` and
`set_volume_state`, known channels and values from 0 through 150.
After the call, independent `pactl --format=json list sinks` and both plugin
reads checked the result. In `finally`, the helper restored exact native
channel values while muted, checked them, restored the original mute state,
and verified the entire snapshot before removing recovery state and stopping
its watchdog. No default route, playback, existing stream or power setting was
changed. Automatic watchdog expiry was armed but not deliberately exercised;
explicit restoration passed after all seven writes.
Both private Decky services/bases were removed and their locks released.
Fresh-lock cleanup audits found their Decky and guard units unloaded/inactive
with PID 0, paths absent, and the exact original sink/mute restored. Battery
stayed 100 → 100; normal managed Decky and headset-panel services stayed
inactive. Decky startup reinjects its frontend and can restart Steam CEF.
## Catalog and verification limits
The canonical store catalog and generated table report **4 works / 48 partial /
53 broken / 11 N/A**. A fresh full official-store fetch at 06:10:58 UTC found
all 116 IDs, versions and digests unchanged. Historical fake-Frame checks cover
this exact archive's install/receipt/removal with backend execution stubbed;
that test was not repeated or relabelled as real-backend evidence.
Audible gain, sound quality, physical speaker output, Bluetooth sinks,
headset-controller input and worn VR remain unverified. The store row includes
these limits. This change records new real-backend evidence and updates its
compatibility classification; it changes no shipped audio implementation.
The full server suite completed 1,626 tests with no failures (16 skips); all
18 site tests passed. Generated-document consistency and `git diff --check`
also passed. No implementation change followed those tests.
@@ -0,0 +1,119 @@
# Framey unattended VR evidence, 25 October 2026 UTC
The committed runner exited **0** on the real Frame, using the existing
`ssh lxso2 'ssh frame …'` route from lxso1. No person wore the headset.
Frame OS **0.5.6**, build **20261022.6153909**, ARM64, SteamVR **2.18.2**,
Decky **3.3.0**. The run began at **23:27:34 UTC**; the Frame's own clock/logs
use UTC+11 and therefore show 26 October.
```sh
python3 scripts/framey-vr-check.py --via lxso2 --exercise --output /tmp/framey-vr-verified
```
[Full machine result](result.json) records source commit
`cb9ad1b6cb7302008783d5f7fe42fa77a4dd43af`, bundle digest, before/after
SteamVR listings, power settings, input observations, and private image hashes.
The checked app code includes the sample settings-path and iframe-reopen fixes
and main through `d77e2bd`. This is source-bundle testing, not a newly published
release/AppImage test. The sample has a random test name and store-shaped local
receipt; it is not presented as an official catalogue listing.
| Check | Observed | Scope |
| --- | --- | --- |
| Overlay registration and visibility | `IVROverlay_027.IsOverlayVisible=true` | Actual Frame Control gamescope overlay, not a browser fixture |
| Transform | type **5**, DashboardTab; absolute getter returns **18**, WrongTransformType | Dashboard owns placement; no transform/dock setters used |
| Texture queries | main size 0×0; image read **24**, InvalidTexture | Direct texture refresh not established; compositor capture used instead |
| Stereo compositor | Before/after SHA-256 differ; final image visibly contains Framey tabs, quick action and saved result | Actual `IVRScreenshots_001` production screenshot path |
| OBS mirror | Current run's random name and “Saved setting is on” appear after draining 60 frames | `/dev/video99` pixels; OBS app itself was not launched in this task |
| OpenVR click | Move/down/up reaches intended control; iframe pointer event is trusted | `IVROverlayView_003.PostOverlayEvent`, no DOM `.click()` |
| Live setting | off → on; real backend file `{"enabled":true}`; fresh iframe reads on | Actual Framey HTTP → Steam CEF → Decky websocket → Python backend; no stubs |
| Tabs/quick action | Settings/Panel clicked; “Saved setting is on” returned | Actual shipped host controls and sample action |
| Slider | 10 → **50** | Native OpenVR click on actual DFL slider, diagnostic UI-only value |
| Text | **framey-ci** | OpenVR pointer focus, then CDP `Input.insertText`; not SteamVR keyboard proof |
| Smooth scroll attempt | `scrollBefore=0`, `scrollAfter=0` | No scrolling established by this attempt; do not count it as a pass |
| Battery | **99% → 99%** | Probed only under the owned lock |
| Power restoration | original pause **false**, timeout **5.000000**, exact values read back | Temporary values false/120; no idle-suspend timer or proximity changes |
| Cleanup | `cleanupErrors=[]`, `powerRecoveryRemoved=true`, `lockReleased=true` | Temporary managed services, sample/settings/profile, timer and restore script removed |
`IsDashboardVisible` and `IsActiveDashboardOverlay` are false while the panel is
visible in these captures: this is an existing placed/docked panel. They cannot
be used alone as a Framey visibility verdict. Activity changes from standby 3
to active 0 without wearing the headset, so activity alone is not worn proof.
Native OpenVR events also produce trusted pointers, so `event.isTrusted` alone
is not physical-controller proof. The worn script now requires explicit
`--worn-confirmed` and reports trusted pointer input with that narrower name.
![Actual compositor panel, room masked](stereo-left-panel-redacted.png)
![Actual mirror panel, room masked](mirror-panel-redacted.png)
These are inset panel crops from the raw captures, with all other pixels
replaced by solid black. No UI content was synthesized. [Image provenance](images.json)
contains source hashes, mask polygons, crops and published hashes. Raw images
remain private under `/tmp/framey-vr-verified` on lxso1. They are not committed.
## Bugs found and fixed
1. The author sample used `decky.decky_SETTINGS_DIR`, absent from real Decky
3.3.0. The actual iframe displayed its backend `AttributeError`. Decky's
[pinned module](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/plugin/imports/decky.py)
exports `DECKY_PLUGIN_SETTINGS_DIR`. The sample, docs and Python test fixture
now use that name. The old fixture invented the same wrong attribute and
masked the failure. The live fixed backend save/reopen is in `result.json`.
2. Reopening the same plugin changed only `#session`, so Chromium retained the
old document/host/cache session. It could retain an error and eventually
show a false “No compatible panel loaded” message. A fresh query parameter
forces a new document while keeping the fragment session. The cache
regression now checks document URLs, rather than manually assuming a new
iframe. The real reopen with the saved setting passed.
## Methods tried and limits
Read-only `vrcmd` stats/overlay listings, the shipped OpenVR ABI, compositor
logs and actual CEF/Chromium targets were inspected first. A simple
`--handlewakeup` initially produced the Frame Control status panel in a stereo
screenshot. The full test uses a bounded screen timeout so it stays active.
Decky's service startup reloads Steam's web helper. During exploratory service
recreation, SteamVR logged `UpdateInputFocus: Failed to give focus to panel`
and returned a hidden, zero-sized main overlay despite a successful focus
request. Retagging only the test browser's `STEAM_GAME` property recovered it;
no Steam, SteamVR, gamescope or headset restart was needed. The final run did
not need this recovery. The runner records it when used.
The first mirror image was demonstrably stale: it contained a previous test's
random plugin name while the contemporaneous stereo image contained the current
one. Reading 60 frames before selecting the mirror image fixed that observation;
the final two published images contain the same current run name.
The diagnostic itself initially failed its cleanup check because an idle
systemd watchdog service was already unloaded. It restored power and removed
the test services, retained the lock, then the exact owned remainder was cleaned
up. Cleanup now checks `LoadState` and tolerates an already-unloaded transient
unit. Subsequent full runs, including the committed run here, exited 0 with no
cleanup errors. A four-minute power-recovery timer was created and cancelled;
forced host death or network loss was not fault injected.
A null driver or virtual tracked controller was not needed for pointer delivery
and was not installed. No root/system files, tracking/proximity configuration,
Steam global binaries, or spatial transforms were edited. PR #113 was untouched.
This check does not establish every official plugin, sustained frame rate,
backend persistence across Steam/Decky reboot, physical controller tracking or
haptics, a functioning virtual keyboard, native scrolling, comfort or legibility
through the optics. Follow the [three-minute worn checklist](../../framey-vr-check.md#three-minute-worn-checklist).
## Local checks
- Full Python suite after integrating current main and worn-confirmation gate:
**1,595 tests, 16 skipped, OK**. The final added scroll/mirror helpers also
passed the **24 focused Framey tests** and the real-device run above.
- App and website test commands passed under **Node 24.22.1**, matching CI's
Node major. System Node 22 cancelled unrelated Meta sign-in timer tests;
those files were not changed. Node 24 was used for final app verification.
- `git diff --check` and Python compilation passed. Hosted CI status belongs to
the PR; local checks do not substitute for its macOS/Windows/ARM64 jobs.
A separate [locked cleanup audit](cleanup-verification.json) confirms both test
services inactive, no panel bundle or Decky binary, no test plugins/profiles or
remaining timers, power restored to false/5, battery 99→99, and its own lock
released. It also removed the stale DevTools discovery file created during the
earlier exploratory use of the original browser profile.
@@ -0,0 +1,2 @@
{"batteryBefore": [99], "headsetService": {"code": 4, "out": "inactive", "err": ""}, "deckyService": {"code": 4, "out": "inactive", "err": ""}, "panelBundleExists": false, "deckyBinaryExists": false, "testPluginFolders": [], "testProfiles": [], "timers": {"code": 0, "out": "", "err": ""}, "pause": {"code": 0, "out": "power.pauseCompositorOnStandby=false", "err": ""}, "timeout": {"code": 0, "out": "power.turnOffScreensTimeout=5.000000", "err": ""}, "removedOwnedStaleDevToolsDiscovery": true, "batteryAfter": [99], "lockReleased": true}
@@ -0,0 +1,80 @@
[
{
"source": "stereo-private.png",
"sourceSha256": "25944a3f3ffe0c217ef25cbc3f5e08a104f9d8901075b755c928ec5049a4cb3c",
"published": "stereo-left-panel-redacted.png",
"publishedSha256": "0de6fbf0f31d0587c8ba91817406acf455b8adb10d343bd56daa605f9a3051c7",
"maskPolygon": [
[
50,
435
],
[
910,
365
],
[
940,
920
],
[
700,
910
],
[
350,
945
],
[
90,
1000
]
],
"crop": [
50,
365,
941,
1001
],
"background": "solid black; only inset panel pixels retained"
},
{
"source": "mirror-private.png",
"sourceSha256": "44d193943ca3164269feeea38f5cbfb0d58cefb79d6c0e8231eab4a5069985b8",
"published": "mirror-panel-redacted.png",
"publishedSha256": "86fa296a36171efca6a9aa627ec3589496df73f3c03f8f9428454c411a904a77",
"maskPolygon": [
[
455,
445
],
[
1420,
390
],
[
1470,
940
],
[
1150,
910
],
[
850,
940
],
[
495,
1020
]
],
"crop": [
455,
390,
1471,
1021
],
"background": "solid black; only inset panel pixels retained"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Loaded 100 of 517 files, more files were not shown because too many files have changed in this diff. Show more