Compare commits

..
Author SHA1 Message Date
SAPHID 8b706cf8dd Record Round 3 headset mirror and charge-limit checks 2026-10-24 21:21:19 +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
SAPHID a13f4fafab fix(ui): reconcile PC host controls with current main 2026-10-18 00:36:47 +00:00
SAPHID ea0b6a80bd Merge main and resolve native host documentation and build triggers 2026-10-18 00:06:01 +00:00
SAPHID b351fe7ad6 chore: resolve conflicts with main 2026-10-17 22:09:58 +00: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
203 changed files with 26167 additions and 407 deletions

No files matched your search

+19
View File
@@ -41,6 +41,10 @@ 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: App tests
@@ -70,6 +74,21 @@ jobs:
- 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
+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
+65 -6
View File
@@ -3,14 +3,14 @@ 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 change how the installers are built (the Electron app, the
# macOS helper it compiles, or this workflow) build the same installers as
# 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/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
@@ -26,7 +26,9 @@ jobs:
# "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. It is created with the REST API so the id comes straight back: the
# 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.
@@ -40,8 +42,13 @@ jobs:
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
# A rerun finds the draft an earlier attempt made.
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
# 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 \
@@ -75,6 +82,28 @@ 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
@@ -95,13 +124,43 @@ jobs:
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: |
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
# 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}"
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/') }}
+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/
+151
View File
@@ -0,0 +1,151 @@
# Changelog
## Unreleased
- 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.
+86 -35
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,7 +19,7 @@ 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>
@@ -44,13 +44,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 +69,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 +95,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 +119,30 @@ 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 |
| **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).
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
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.
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
**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
@@ -128,7 +155,7 @@ distribution's. If you already have `adb`, the app uses yours.
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:
quarantine once, only for a download you trust from the releases linked above:
```sh
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
@@ -157,26 +184,44 @@ If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
or run it with `--appimage-extract-and-run`.
</details>
## Open the app
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
open it from Applications. For the unsigned-app 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.
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).
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).
**What it changes:** only what you click. Installs go to your user account on
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
@@ -212,18 +257,22 @@ 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) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [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 |
| [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 |
@@ -239,8 +288,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
@@ -266,3 +315,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.
+4
View File
@@ -13,6 +13,10 @@ 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",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
+6
View File
@@ -282,6 +282,12 @@ 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.9",
"version": "0.4.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.9",
"version": "0.4.20",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+14 -3
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.9",
"version": "0.4.20",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -68,7 +68,9 @@
"from": "../scripts",
"to": "scripts",
"filter": [
"*.sh"
"*.sh",
"frame_power_sample.py",
"frame_power_report.py"
]
},
{
@@ -198,7 +200,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,
+1 -1
View File
@@ -1,7 +1,7 @@
// 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"];
const KEYS = ["mvSeparate", "padUsed", "pcView"];
const valid = value => value === "0" || value === "1";
function preferenceStore(file) {
+3 -2
View File
@@ -1,5 +1,5 @@
// 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 put a screenshot on the clipboard as an image, open Set Up Connection when
@@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld("frameApp", {
},
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
+9 -1
View File
@@ -36,7 +36,7 @@ test("client choices survive a new native session and local server origin", t =>
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
});
test("only the two non-sensitive binary choices can enter the native store", t => {
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/);
@@ -58,3 +58,11 @@ test("a missing or damaged preference file retains usable defaults", t => {
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/);
});
@@ -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
+75
View File
@@ -190,6 +190,35 @@ IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on th
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
@@ -402,3 +431,49 @@ 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
standalone APK 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 standalone APK and optional 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
one-APK/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 refuses multiple APKs, split-only APKs and
bases marked as requiring splits. It does not select device variants or restore
`Android/data`, private app data, saves, or entitlements. For the separate
restricted native-library/asset split workflow see
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
code and feature splits are not supported by that merger.
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.
+207
View File
@@ -0,0 +1,207 @@
# 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 (never followed), and single files over 2 GiB.
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 never followed, 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.
`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.
+22 -5
View File
@@ -3,11 +3,28 @@
**Question:** can Frame Control set an 80% battery charge limit on the Frame
over SSH or adb, safely?
**Verdict: don't build it yet; it needs a headset check.** 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 seems to have is a
vendor charger attribute that Valve's own charging daemon also manages.
Research as of 2026-10-23. **Not tested on a headset.**
**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
+6
View File
@@ -97,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.
@@ -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,114 @@
# Linux PC host feasibility on lxso2, 2026-10-24
**Blocked on this host's current desktop environment. No feature enabled and
no feature PR opened.** Investigation for [issue #23](https://github.com/saphid/frame-control/issues/23),
starting at `4e45648` (main after #154). This concerns flat desktop panels,
not Linux PC VR streaming.
## Decision
The smallest useful candidate is one explicitly selected **whole screen**,
captured through the ScreenCast portal's restricted PipeWire connection,
encoded locally with GStreamer/x264, and sent to the existing viewer through
the shared SSH controller. Input, individual windows and virtual displays
would remain unsupported until separately verified. This candidate was **not
implemented**: lxso2 currently cannot exercise the real capture permission path.
Development packages could unblock compilation, but would not create a desktop,
portal backend or consented screen. Synthetic encoding, an Xvfb screen grab or
using the Frame as its own host would not verify Linux PC desktop capture.
No host packages or services were changed.
## Evidence
Host: `lxso2`, Linux x86-64, kernel `6.8.0-139-generic`.
- `DISPLAY`, `WAYLAND_DISPLAY` and `XDG_SESSION_TYPE` were unset. The user bus
was reachable at `/run/user/1000/bus`.
- `/run/user/1000/pipewire-0` and `/run/user/1000/wayland-0` did not exist.
No processes matched Xorg, Xwayland, Xvfb, GNOME Shell, KWin Wayland, Weston,
PipeWire, WirePlumber or xdg-desktop-portal at the process check.
- D-Bus `ListNames` returned D-Bus, systemd and anonymous connections, with no
`org.freedesktop.portal.Desktop`. `ListActivatableNames` also had no desktop
portal. These calls queried D-Bus itself without activating capture services.
- `dpkg-query` found no `xdg-desktop-portal*` or `pipewire*` packages.
`libgstreamer1.0-0:amd64` was installed at `1.24.2-1ubuntu0.1`, but
`gstreamer1.0-tools` was uninstalled. `gst-launch-1.0` and `gst-inspect-1.0`
were absent from PATH. Xvfb was installed, but supplies no portal by itself.
- `pkg-config --modversion` failed for `gstreamer-1.0`,
`gstreamer-app-1.0`, `gstreamer-video-1.0` and `libpipewire-0.3`.
`gio-unix-2.0` reported `2.80.0`.
- `desktop/bundle/pc-host.so` did not exist.
`python3 desktop/build.py --with-x264 --out /tmp/frame-linux-pc-feasibility`
exited **1** before compilation: required GStreamer pkg-config modules were
missing. No native build or encoder runtime success is claimed.
Reproduce the non-activating portal checks in the intended desktop user's
session, using that session's actual runtime directory and bus:
```sh
gdbus call --session --dest org.freedesktop.DBus \
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListNames
gdbus call --session --dest org.freedesktop.DBus \
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListActivatableNames
pkg-config --modversion gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0 gio-unix-2.0
```
## Existing implementation reviewed
- `ui/frame_pcview.py` selects PCView only on Windows and rejects Linux even
when enabled. Linux installers do not carry its native bundle.
- `desktop/portal.c` creates a **RemoteDesktop** session, requests pointer and
keyboard devices, and selects monitors and windows (`types=3`). It is not
ScreenCast-only or screen-only. Its restricted fd/node and fresh-connection
handling are reusable.
- `ui/frame_pc_agent.py` calls the selection a shared window or screen and
picks VA-API when the element is present, falling back to x264. A bounded
x264 slice needs explicit encoder policy and unsupported-input behavior;
exposing the existing Linux path alone would not meet that scope.
- `app/main.js` starts Python with the inherited environment; it does not
supply a compositor, portal backend or capture permission.
- `.github/workflows/pc-host.yml` installs native dependencies and tests
synthetic x264/protocol behavior with fake capture consent/input. This does
not establish interactive Linux desktop capture.
- Issue #23 and its six existing comments distinguish prior synthetic
helper/viewer results from untested Linux desktop capture and consent.
## Verification and limits
`python3 -m unittest discover -s tests -p 'test_pc*.py' -v` exited **0**:
27 tests ran successfully, with one additional native-class setup skip
(`native PC libraries not built on this host`). Coverage includes the compiled
C controller trace, Python adapters, grants and default-off/Windows-only gates.
It does not prove native GStreamer capture or local-client streaming.
No fake/local native stream, consent prompt, real desktop capture, Frame viewer
run, benchmark, input or window mode was tested in this investigation. No SSH
connection to the Frame was needed; no lock was acquired and no headset install,
launch, stop or settings change was attempted. Issue #113 was left alone.
No issue comments or other public messages were sent.
## Concrete unblock and next slice
1. Supply an interactive Linux desktop, physical or VM, with a working
ScreenCast portal/backend, PipeWire and accessible consent dialog. Installing
the portal frontend alone is insufficient. A VM can establish software
capture/x264 behavior while physical-host behavior remains unverified.
2. Install the native dependencies listed in `pc-host.yml` in that environment
and build a local `--with-x264` bundle. Resolve x264 distribution
licensing/source obligations before shipping it: the current build labels
that option test-only and the Windows installer omits it.
3. Implement a ScreenCast-only session with monitor-only selection, no
RemoteDesktop/device request, disabled input in protocol and viewer, hidden
window/virtual-display modes, and explicit x264 selection. Preserve the
default-off gate and existing Mac/Windows paths.
4. Verify consent acceptance/refusal/cancellation, Stop/reopen, connection loss
and portal closure with a local client decoding actual captured content.
Run the shared benchmark and record results without inferring hardware or
physical laser behavior.
5. Only after local capture works, acquire the shared Frame lock once and
follow [the headset procedure](../testing.md#headset-smoke-test) for a brief
viewer test. If occupied, leave the lock alone and defer device testing.
These are prerequisites and proposed scope, not a ready feature. The Linux
host request in #23 remains open.
+142
View File
@@ -0,0 +1,142 @@
# Media surround hardening: 2026-10-24
PR [#51](https://github.com/saphid/frame-control/pull/51), branch
`theatre-media-surround-hardening`, merged main `2ded4c5` without rebasing.
Both fixes remain necessary on that main: an optional-surround error escapes
`play()`, and a non-`RuntimeError` during overlay teardown skips the remaining
handles and OpenVR shutdown.
## Offline checks
- Focused media suite: **23 tests passed** on Linux / Python 3.12.3.
- Final full suite: **1,381 tests, OK (15 skipped)** in 145.384 s with
`PYTHONFAULTHANDLER=1 python3 -m unittest discover -s tests -v`.
The service-regression module passed **33 tests**. Player/server compilation
and `git diff --check` passed. Current macOS/Windows validation is left to CI.
- The startup/upload, creation/setup, deferred-surround refusal and teardown
regression tests were also run with main's player implementation: they
failed as expected (six errors across subtests). The screen-refusal test
passed, confirming that actual screen failures still propagate.
- The historical macOS failure was job
[109239733717](https://github.com/saphid/frame-control/actions/runs/36516462623/job/109239733717),
on `dac9dbc`, Python 3.12: 619 tests, three failures in artwork resolver
tests (`test_resolver_slot_released_when_thread_cannot_start`,
`test_timed_out_lookups_are_capped`,
`test_url_fetch_refuses_private_hosts_and_honours_deadline`). Media tests
passed. Late DNS workers retained semaphore slots; the first failed
assertion could also leak acquired slots into later tests.
- Main already contains `8051687`'s wait-for-idle and unconditional slot
cleanup, preserved by `d546568`. The current artwork module passed all
**40 tests** locally. No additional artwork changes were needed.
- The first full run completed **1,380 tests, 15 skipped**, with one failure:
`test_a_second_server_is_refused_until_the_first_exits` observed SIGSEGV
(`-11`) instead of exit 1. All 41 server tests passed on a focused rerun.
Main started daemon index/TLS workers before refusing a second server;
its existing shutdown notes document a race with OpenSSL interpreter exit.
This crash mechanism is inferred; the first run did not retain a native
fault trace. The subprocess assertion now retains stderr for diagnosis.
A deterministic regression now checks that rejection starts no listener,
cleanup sweep or background workers. It failed before moving the ownership
check ahead of startup work and passed afterwards.
The surround retry now logs a refusal before discarding that cosmetic work.
Creation, setup and first-upload failures also allow the screen to play;
standby remains retryable, and screen errors remain fatal. Teardown tests
exercise OpenVR errors, unexpected exceptions and a late `InterruptedError`,
checking that all handles are attempted and shutdown runs.
## Earlier blocked real Frame attempt
Read-only SSH preflight: SteamOS **0.5.6**, BUILD_ID **20261015.6141207**;
SteamVR server PID **297506**, compositor PID **297620**; existing media
service `inactive/dead`, MainPID 0; battery **99%**, fuel gauge `Not charging`,
charger `Full`. The shared lock was absent at preflight.
The subsequent atomic `mkdir /tmp/frame-test.lock` failed with
`FileExistsError(17, 'File exists')`: another session acquired it in between.
The probe stopped before uploading files, launching media, creating overlays,
or changing any state. The foreign lock was neither modified nor released.
All generated fixtures remained on the computer and were then removed; no
headset cleanup was necessary. Steam/SteamVR and the prior player state were
left alone.
## Follow-up real Frame smoke at the exact PR head
**Passed, 2026-10-24 10:35:02–10:35:23 UTC**, with byte-identical media modules
from **`5d551a81658d9ff14f49285c28609d506cbc20aa`**. This later run resolves
the earlier lack of fresh playback evidence. No production code changed.
The headset was unworn. SteamOS **0.5.6**, BUILD_ID **20261015.6141207**,
SteamVR **2.18.2**; battery **100%, Full** before and after.
Read-only preflight found Game Mode (`XDG_CURRENT_DESKTOP=gamescope`,
`start-gamescope` running, no `plasmashell`), Steam/SteamVR running, no media
overlays, and the installed media service inactive/not loaded. The test then
acquired `/tmp/frame-test.lock` with atomic `mkdir`; it did not take over a
foreign lock. Under the lock, those safety conditions were checked again.
Only generated media and the PR's four unchanged modules were uploaded, under
the test-owned, uid-1000 directory
`/tmp/pr51-5d551a8-4c0976a52564-utfbk_m0/`. The clip was a **12 s, 30 fps,
960×540 half-SBS H.264** MP4 with solid red/cyan eye images and **silent AAC**.
It was generated with FFmpeg's `color`, `hstack` and `anullsrc` sources; no
personal or external media was used.
The exact `frame_media_remote.run()` Play/status/Stop path was exercised by
importing its unmodified module and changing only its in-memory `ROOT`,
`RUNTIME`, `STATUS` and `UNIT` constants to the isolated test locations and
`frame-control-media-pr51-5d551a8-4c0976a52564.service`. The real
`frame_media_player.py` ran unchanged with `--layout auto --theatre`, using
`h264_v4l2m2m` and Pulse output. This checks the player and its systemd lifecycle;
it does **not** reverify the HTTP/UI upload or runtime installer, nor the
literal production unit name. The installed media runtime, log, status and
library were preserved.
- First Play: `starting` → `playing` → **`ended`**, **360 frames**, **160 dropped**,
**12.0397 s**, `running=false`. The drops were the normal unworn/standby
`SetOverlayRaw` retry path; no fault was forced. At 60 frames, dropped was 0.
`vrcmd --overlays` listed `framecontrol.media.surround` as **1×1 visible**
and `framecontrol.media.screen` as **visible**. These are SteamVR metadata,
not a wearer or eye-capture verification.
- After EOF: unit **inactive/dead, not-found, MainPID 0**, no test player or
decoder, and neither owned overlay remained. Stop after EOF succeeded and
retained `ended`.
- Second Play reached `playing`, 60 frames, 0 dropped. Explicit Stop returned
**`stopped`, 72 frames, 0 dropped, `running=false`**. Again the unit was
inactive/dead/not-found with MainPID 0, no player/decoder and no owned overlays.
- The test unit's `ExecStart` and cgroup identified the unique temporary
runtime. First-run player/decoder PIDs were **309650/309655**; second-run
PIDs were **309805/309810**. Stop controlled only this dedicated test unit.
Its player log was empty; no surround refusal or teardown error was logged.
### Uploaded SHA-256 values
```text
generated_SBS.mp4 1b39cac1d3b092b89657250c817019b68e87a474976ccd44aeb38e8eee44ffca
frame_media.py 001e3158f4c3931794846b21f1e4cb553349d9326b4db1454a01976d446ad97a
frame_media_player.py 86b0a140ebff857c2819c74d3ac45d1897735c585d44c7360ad6f18c2ecf2645
frame_media_remote.py b60386eb192d32594358246a4e665474c543600f2c564c2efd554ef38d7a631d
frame_splat.py f35750926f1149c41b95617a302012406d355c0dfdd4530438b0c42ee6d55daa
```
### Cleanup and limits
The temporary directory, including its fixture, code, bytecode, status and log,
was removed only after verifying that its player and decoder had exited. The
transient unit was collected. The test released only its own lock after cleanup.
All installed media runtime file hashes, including `player.log` and
`status.json`, were unchanged. The installed `frame-control-media.service`
remained inactive/not loaded. Steam **297826**, vrserver **297506**,
vrcompositor **297620**, vrdashboard **297721**, and start-gamescope **296997**
kept the same PIDs. The pre-existing overlay key/dimension/visibility/type
inventory matched the final inventory, and all dashboard main panels remained
`not_visible`. No dashboard command, wake request, global setting, reboot or
Steam/SteamVR stop was issued.
Cosmetic surround refusal and exceptional teardown remain **fake-tested**;
no real refusal or teardown fault was induced. This short unworn-headset
smoke does not verify appearance, eye isolation, comfort, audible sound/lip
sync (the AAC was silent), sustained display performance, or the issue #28
report of laggy 1080p video. It does not cover new H.265, photo or splat cases.
Historical checks remain in the earlier PR #51 description and
[media playback notes](../vr-video.md). The PR remains draft and unmerged
for parent review and CI.
+178
View File
@@ -0,0 +1,178 @@
# Keyboard and trackpad through gamescope, 2026-10-24
PR: [#66](https://github.com/saphid/frame-control/pull/66), `pad-gamescope`.
Parent/main: v0.4.18 commit `0da1fe5`. Merge commit: `1737366`, with parents
`cba23fb` (the existing PR) and `0da1fe5` (main). No history was rewritten.
## Merge and implementation
Both `/api/input` (keyboard/trackpad) and `/api/touch` (live-view Control)
start and poll the gamescope helper. KDE Connect starts only when Unicode
typing needs it. The existing ordered, bounded queue and switch cancellation
remain in place; cross-service typing order is an estimate, not an application
acknowledgement.
The merge keeps main's intended-device validation and work reservation before
a GET starts an input helper, immutable SSH routes, launch generations and
serialized writes. Switching headsets also stops the power report and resets
both input agents and queued typing before publishing the new route. The page
keeps main's Tools sections, clipboard controls and single instances of the
keyboard/trackpad and VR tools, with the PR's gamescope help text.
The start/status regression tests now exercise the gamescope helper for both
endpoints. An explicit `/api/input` test checks that start and poll use that
helper without starting KDE Connect. Existing stale/missing-headset and
route-reservation tests remain active.
## Real Frame: focused click and ASCII keys received
Host: lxso2, `ssh frame`. SteamOS **0.5.6**, BUILD_ID **20261015.6141207**,
SteamVR **2.18.2**. At **11:05 UTC**, Game Mode was active, full Plasma was
inactive, Steam and SteamVR were running, battery was **99%**, and gamescope's
focusable/focused window lists were empty. The shared test lock was absent.
Existing X11 tools and libX11 were available; Tk could not load its library.
At **11:09:23 UTC**, an initial read-only preflight saw no focused window
but activity level **0** (idle), rather than the required **3** (standby), and
aborted before lock acquisition or input. At **11:09:57 UTC**, another task
held the shared lock with a focused panel; that lock and panel were left alone.
At **11:20:22 UTC**, read-only checks found the lock absent, no focused or
focusable windows, activity **3** and battery **98%**. The bounded probe ran at
**11:20:37 UTC** against implementation commit `5641d3c`:
1. Rechecked empty focus and standby, acquired `/tmp/frame-test.lock` with
atomic `mkdir`, wrote a unique owner marker and rechecked both conditions.
2. Created only a disposable 640×400 X11 event-recorder window on `:0`, tagged
with test app ID `2000999066`. It had a 45-second alarm and closed on stdin
EOF. No existing application, full desktop or dashboard was started.
3. Waited for gamescope to report that exact window (`62914561`) focused.
Started the production server's `TouchAgent` over SSH; it reported ready.
4. Called production `server.remote_touch` for positioning, then
`server.remote_input` with a left click, `Frame66` and the Enter key button.
Both responses reported `sent: true`. More importantly, the **window itself
recorded ButtonPress/Release (button 1), the ASCII key events in order and
Return**. `XLookupString` decoded exactly `Frame66\r`. The events were
delivered through gamescope's EIS socket, not fabricated with XSendEvent.
5. Closed the helper by EOF and waited for exit, closed the recorder, checked
empty focus and standby, then removed only the owner-matching test lock.
[State and received events](pad-gamescope-2026-10-24/readback.json) and the
[probe](pad-gamescope-2026-10-24/probe.py)/[X11 recorder](pad-gamescope-2026-10-24/recorder.py)
are retained. The archived scripts use checkout-relative source paths; the
recorded run used the same harness from `/tmp`. Motion records omit an unused
field that only has meaning for key/button events.
Delayed readback at **11:21:46 UTC** confirmed no test lock or recorder process,
empty focused/focusable lists, Game Mode active, full Plasma inactive, activity
3 and battery 98%. The original Steam and vrserver PIDs were unchanged. No
install, security/global setting change, dashboard/session switch, reset or
reboot was performed. No headset helper or owned lock remained.
This establishes **focused-panel click and ASCII key delivery on `:0`**.
It does not establish wearer-visible presentation, Unicode delivery or GUI
interaction. The absolute-position setup reached the recorder's edge
(639,399), rather than its centre; pixel-accurate absolute pointing is **not**
proved by this small-window probe. The trackpad click and typed keys still
arrived in the owned focused window. The original PR's earlier acceptance-only
observations remain historical; this observation adds actual window events.
## Offline checks and remaining limits
- `python3 -m unittest discover -s tests -p test_input.py -v`: **58 passed**.
- `python3 -m unittest discover -s tests -p test_touch.py -v`: **43 passed**.
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
**34 passed**.
- `python3 -m unittest discover -s tests -v`: **1,403 ran, OK, 15 skipped**
(Linux x86_64, Python 3.12; 150.825 seconds).
- Full fake-Frame e2e in task-specific Docker project `frame-control-pr66`:
**47 passed** after the lock fix (192.270 seconds). Both task containers,
their network and all three volumes were removed afterward. The run emitted
an unclosed test-socket ResourceWarning; the suite returned OK.
- Node page suites: `ctrl_gestures.mjs`, `pad_queue.mjs`, `desk_panels.mjs`
passed, using the production page functions.
- `node --test app/test/*.test.js`: **3 test files passed**.
- `node --test site/test/*.test.mjs`: **2 test files passed**.
- Python compilation, desktop/site JavaScript syntax and `git diff --check`
passed. Fake-Frame e2e and exact-head CI results are recorded in the PR after completion.
The first full regression run executed 1,401 tests and found two tests still
expecting `/api/input` to start the old KDE Connect helper. They were updated
to the intended gamescope route. The final full run includes the lazy-KDE
regression above and the lock-order regression below. The initial restricted-sandbox input run could not open
loopback sockets; the focused rerun with managed network access passed.
Review of the merged code then found a lock inversion: the Unicode worker
held the pad-send lock while `InputAgent.start()` acquired the route lock,
whereas a headset switch held the route lock before resetting the pad. A
bounded subprocess regression reproduced the deadlock with the real
`InputAgent.send/start` methods and an offline launch stub. Unicode delivery
and resets now take the route lock before the pad lock; the route lock is
reentrant so an agent can capture its route inside that reservation. The new
regression passed after the fix, as did all **58 focused input tests** and
the **1,403-test full suite** (15 skipped). The local fake-Frame suite also
passed **47 tests after this fix**. Exact-head CI results are recorded in the PR.
Remaining device work requires an idle, available Frame and the shared lock:
verify Unicode in a real text field, ordering under real typing load and
precise absolute pointing for small panels. Worn-headset presentation,
packaged Electron pointer lock and Windows GUI execution remain separate
checks. Automated Windows/macOS CI does not establish those interactions.
## Review round 2: queue saturation and failure recovery
The coordinator identified silent eviction in both `_queue_for_kde()` and the
worker's retry path. Both sliced the oldest accepted events off at 200 entries,
even though the page had already received `sent: true` and discarded its copy.
Admission now serializes concurrent requests, checks capacity before any pointer
or keyboard send, and returns `sent: false, state: waiting` if the whole batch
will not fit. Text stays in the bounded queue while in flight, and is removed
only after the helper accepts it. Rejected mixed batches send no clicks; retrying
them cannot duplicate clicks already sent by this server. The page polls until
it can retry. Pointer input stays usable while accepted text is pending.
The old worker also left its worker marker and one-hour timing sentinel behind
if `send()` raised. Failure/timeout handling now retains unsent text, clears the
worker/timing state and exposes an error through input status. The page polls
accepted text without requiring another keypress. **Try again** explicitly
restarts the retained queue. Thread-start failures follow the same retention
rule. A stale worker's exception cannot change the new route's state.
The proposed send-to-enqueue headset-switch race was not reachable through the
normal HTTP switch path: `Handler.do_POST()` reserves `working(meant)` across
both operations; `devices_post()` shares `_work_lock` and refuses a moving
operation while work is active. Connector configuration/reconnect paths defer
changed headsets while work runs. A production-handler regression attempts a
switch at both send and enqueue and passes even on the previous implementation.
No separate connector change was needed. Atomic queue admission now uses the
existing route-before-pad lock order as well, because starting either input
agent can capture its route synchronously.
Three deterministic saturation regressions fail on `215db7b` and pass with the
fix: capacity during the typing delay, capacity during an unsuccessful-send
retry, and two concurrent producers competing for the last slots. They check
exact accepted event sequences, reject a mixed click/text batch before its
pointer effects, and verify delivery exactly once after retry. Additional tests
cover explicit recovery after timeout, helper errors, send exceptions and
thread-start failure, stale-worker isolation, HTTP status/start behavior, and
the production page's polling/error behavior.
No new active headset test was performed: this change affects host queue
admission/recovery and status handling, not Frame event translation or injection.
The earlier real click/ASCII evidence remains scoped to its recorded commit.
Real Unicode, packaged Electron pointer lock, Windows GUI, wearer-visible
presentation and precise small-window absolute positioning remain unverified.
`sent: true` means accepted by this server, not application delivery. The queue
is in memory; switching headsets cancels it and server exit loses it. A transport
failure after a partial write can still make delivery ambiguous; this is not an
end-to-end exactly-once protocol.
Round 2 local verification: **141 focused input/touch/service tests passed**
(62 input, 43 touch, 36 service; 20.061 seconds), and **1,409 server tests ran, OK, 15 skipped**
(Linux x86_64, Python 3.12, 153.198 seconds). All three production-page Node
suites passed; app tests passed (3 files), site tests passed (2 files), Python
compilation and `git diff --check` passed. The existing skipped tests require
zsh, macOS/Swift or built native PC libraries. Current-head CI is tracked in
PR #66; the previous 47-test local fake-Frame run above is historical.
@@ -0,0 +1,129 @@
"""Active real-Frame evidence probe: aborts unless idle, then owns the shared lock.
Run manually on an authorised host: python3 docs/evidence/pad-gamescope-2026-10-24/probe.py
The recorded run used /tmp paths; archived paths are relative to this checkout.
"""
import base64
import datetime
import json
import os
from pathlib import Path
import queue
import shlex
import subprocess
import sys
import tempfile
import threading
import time
import uuid
ROOT = Path(__file__).resolve().parents[3]
data = tempfile.TemporaryDirectory(prefix='pr66-live-')
os.environ.update(FRAME_CONTROL_DATA_DIR=data.name, FRAME_CONTROL_TELEMETRY='0', FRAME_PRIVATE_SSH='1')
sys.path.insert(0, str(ROOT / 'ui'))
import server
SSH = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame']
owner = 'pr66-' + uuid.uuid4().hex
result = {'time': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'owner': owner,
'base': '0da1fe5', 'route': 'production server.remote_input -> TouchAgent SSH -> frame_touch.py -> gamescope EIS',
'events': []}
recorder = None
locked = False
rows = queue.Queue()
def ssh(cmd):
return subprocess.run([*SSH, cmd], capture_output=True, text=True, timeout=15, check=True).stdout
def focus():
return json.loads(server.ssh('python3 - focus', stdin=(ROOT/'ui/frame_touch.py').read_text(), timeout=15))
def activity():
return json.loads(ssh('/opt/steamvr/bin/linuxarm64/vrcmd --stats'))[0]['activity_level']
def read_rows():
for raw in recorder.stdout:
rows.put(json.loads(raw))
def collect(seconds):
end = time.monotonic() + seconds
while time.monotonic() < end:
try:
result['events'].append(rows.get(timeout=max(0.01, end-time.monotonic())))
except queue.Empty:
break
try:
result['initial_focus'] = focus()
result['initial_activity'] = activity()
assert not result['initial_focus'].get('window'), 'existing focused panel; do not interrupt'
assert result['initial_activity'] == 3, 'headset is not in standby'
ssh('mkdir /tmp/frame-test.lock && printf %s ' + shlex.quote(owner) + ' > /tmp/frame-test.lock/owner')
locked = True
result['lock_acquired'] = True
assert not focus().get('window') and activity() == 3, 'session changed during lock acquisition'
code = base64.b64encode(Path(__file__).with_name('recorder.py').read_bytes()).decode()
command = 'python3 -u -c ' + shlex.quote("import base64;exec(compile(base64.b64decode('" + code + "'),'pr66-recorder','exec'))")
recorder = subprocess.Popen([*SSH, command], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
threading.Thread(target=read_rows, daemon=True).start()
initial = rows.get(timeout=8)
result['recorder'] = initial
assert initial.get('ready'), initial
window = initial['window']
for _ in range(20):
current = focus()
if current.get('window') == window:
break
time.sleep(0.2)
result['focused_panel'] = current
assert current.get('window') == window and current.get('display') == ':0', 'owned panel did not obtain focus; no input sent'
server.remote_input({})
for _ in range(50):
if server._touch.status.get('state') in ('ready', 'error'):
break
time.sleep(0.1)
result['agent_status'] = dict(server._touch.status)
assert result['agent_status'].get('state') == 'ready', result['agent_status']
target = {'window': window, 'display': ':0'}
assert focus().get('window') == window and activity() == 3
result['position_response'] = server.remote_touch({'events': [dict(target, fx=0.5, fy=0.5)]})
time.sleep(0.2)
assert focus().get('window') == window and activity() == 3
result['pad_response'] = server.remote_input({'events': [{'singleclick': True}, {'key': 'Frame66'}, {'specialKey': 12}]})
collect(2)
received = result['events']
result['received_text'] = ''.join(e.get('text', '') for e in received if e.get('type') == 2)
result['click_received'] = any(e.get('type') == 4 and e.get('code') == 1 and e.get('window') == window for e in received)
result['release_received'] = any(e.get('type') == 5 and e.get('code') == 1 and e.get('window') == window for e in received)
result['passed'] = result['click_received'] and result['release_received'] and result['received_text'] == 'Frame66\r'
assert result['passed'], 'event recorder did not observe expected click and keys'
except Exception as e:
result['error'] = str(e)
finally:
proc = server._touch.proc
server.reset_pad_route()
if proc:
try:
proc.wait(timeout=8)
result['input_helper_exited'] = True
except subprocess.TimeoutExpired:
proc.terminate()
proc.wait(timeout=5)
result['input_helper_terminated'] = True
if recorder:
recorder.stdin.close()
recorder.wait(timeout=8)
collect(0.2)
result['recorder_exit'] = recorder.returncode
result['recorder_stderr'] = recorder.stderr.read()[-1000:]
if locked:
result['final_focus'] = focus()
result['final_activity'] = activity()
ssh('test "$(cat /tmp/frame-test.lock/owner)" = ' + shlex.quote(owner)
+ ' && rm /tmp/frame-test.lock/owner && rmdir /tmp/frame-test.lock')
result['owned_lock_released'] = True
Path('/tmp/pr66-live.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))
data.cleanup()
sys.exit(0 if result.get('passed') else 1)
@@ -0,0 +1,280 @@
{
"time": "2026-10-24T11:20:37.392607+00:00",
"owner": "pr66-7c2a8e8851f449bcb0bb6a040be94d99",
"base": "0da1fe5",
"route": "production server.remote_input -> TouchAgent SSH -> frame_touch.py -> gamescope EIS",
"events": [
{
"type": 6,
"window": 62914561,
"x": 320,
"y": 200,
"synthetic": false
},
{
"type": 6,
"window": 62914561,
"x": 639,
"y": 399,
"synthetic": false
},
{
"type": 4,
"window": 62914561,
"x": 639,
"y": 399,
"code": 1,
"synthetic": false
},
{
"type": 5,
"window": 62914561,
"x": 639,
"y": 399,
"code": 1,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 50,
"synthetic": false,
"text": "",
"keysym": 65505
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 41,
"synthetic": false,
"text": "F",
"keysym": 70
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 41,
"synthetic": false
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 50,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 27,
"synthetic": false,
"text": "r",
"keysym": 114
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 27,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 38,
"synthetic": false,
"text": "a",
"keysym": 97
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 38,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 58,
"synthetic": false,
"text": "m",
"keysym": 109
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 58,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 26,
"synthetic": false,
"text": "e",
"keysym": 101
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 26,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 15,
"synthetic": false,
"text": "6",
"keysym": 54
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 15,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 15,
"synthetic": false,
"text": "6",
"keysym": 54
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 15,
"synthetic": false
},
{
"type": 2,
"window": 62914561,
"x": 639,
"y": 399,
"code": 36,
"synthetic": false,
"text": "\r",
"keysym": 65293
},
{
"type": 3,
"window": 62914561,
"x": 639,
"y": 399,
"code": 36,
"synthetic": false
},
{
"cleaned": true
}
],
"initial_focus": {
"window": null
},
"initial_activity": 3,
"lock_acquired": true,
"recorder": {
"ready": true,
"window": 62914561,
"pid": 317210,
"display": ":0",
"appid": 2000999066
},
"focused_panel": {
"window": 62914561,
"display": ":0",
"name": "Frame Control PR66 disposable input probe",
"x": 0,
"y": 0,
"width": 640,
"height": 400,
"root": [
1920,
1080
],
"app": 2000999066
},
"agent_status": {
"state": "ready",
"focus": 62914561
},
"position_response": {
"state": "ready",
"focus": 62914561,
"sent": true
},
"pad_response": {
"state": "ready",
"focus": 62914561,
"sent": true
},
"received_text": "Frame66\r",
"click_received": true,
"release_received": true,
"passed": true,
"input_helper_exited": true,
"recorder_exit": 0,
"recorder_stderr": "",
"final_focus": {
"window": null
},
"final_activity": 3,
"owned_lock_released": true,
"implementation_commit": "5641d3c316dfe225321f7233840cc83ff8c3f527",
"device": {
"steamos": "0.5.6",
"build_id": "20261015.6141207",
"steamvr": "2.18.2",
"battery_before": 98,
"battery_after": 98
},
"delayed_cleanup": {
"time": "2026-10-24T11:21:46Z",
"shared_lock_absent": true,
"owned_recorder_absent": true,
"game_mode": "active",
"full_plasma": "inactive",
"steam_pid": 297826,
"vrserver_pid": 297506,
"focusable_windows": [],
"focused_windows": [],
"activity_level": 3
},
"limits": [
"No wearer or packaged Electron/GUI interaction",
"ASCII only; Unicode and cross-service ordering not tested on device",
"The positioning setup reached the recorder edge (639,399); pixel-accurate absolute targeting is not established by this probe"
]
}
@@ -0,0 +1,82 @@
"""Disposable X11 event recorder used for the PR #66 focused-input observation."""
import ctypes as C
import json
import os
import select
import signal
import time
X = C.CDLL('libX11.so.6')
P, U = C.c_void_p, C.c_ulong
for name, restype, args in [
('XOpenDisplay', P, [C.c_char_p]), ('XDefaultRootWindow', U, [P]),
('XCreateSimpleWindow', U, [P, U, C.c_int, C.c_int, C.c_uint, C.c_uint, C.c_uint, U, U]),
('XInternAtom', U, [P, C.c_char_p, C.c_int]),
('XChangeProperty', C.c_int, [P, U, U, U, C.c_int, C.c_int, P, C.c_int]),
('XStoreName', C.c_int, [P, U, C.c_char_p]),
('XSelectInput', C.c_int, [P, U, C.c_long]), ('XMapWindow', C.c_int, [P, U]),
('XFlush', C.c_int, [P]), ('XPending', C.c_int, [P]),
('XConnectionNumber', C.c_int, [P]), ('XNextEvent', C.c_int, [P, P]),
('XLookupString', C.c_int, [P, P, C.c_int, P, P]),
('XDestroyWindow', C.c_int, [P, U]), ('XCloseDisplay', C.c_int, [P]),
]:
fn = getattr(X, name)
fn.restype, fn.argtypes = restype, args
class InputEvent(C.Structure):
_fields_ = [('type', C.c_int), ('serial', U), ('send_event', C.c_int),
('display', P), ('window', U), ('root', U), ('subwindow', U),
('time', U), ('x', C.c_int), ('y', C.c_int), ('x_root', C.c_int),
('y_root', C.c_int), ('state', C.c_uint), ('code', C.c_uint),
('same_screen', C.c_int)]
class Event(C.Union):
_fields_ = [('type', C.c_int), ('input', InputEvent), ('pad', C.c_long * 24)]
def say(**row):
print(json.dumps(row), flush=True)
def end(*_):
raise TimeoutError('probe ended')
signal.signal(signal.SIGTERM, end)
signal.signal(signal.SIGALRM, end)
signal.alarm(45)
d = X.XOpenDisplay(b':0')
assert d, 'X display unavailable'
w = 0
try:
w = X.XCreateSimpleWindow(d, X.XDefaultRootWindow(d), 0, 0, 640, 400, 0, 0, 0x224466)
for name, value in [(b'STEAM_GAME', 2000999066), (b'_NET_WM_PID', os.getpid())]:
val = U(value)
X.XChangeProperty(d, w, X.XInternAtom(d, name, 0), 6, 32, 0, C.byref(val), 1)
X.XStoreName(d, w, b'Frame Control PR66 disposable input probe')
X.XSelectInput(d, w, 1 | 2 | 4 | 8 | 64 | 32768 | 2097152)
X.XMapWindow(d, w)
X.XFlush(d)
say(ready=True, window=w, pid=os.getpid(), display=':0', appid=2000999066)
while True:
while X.XPending(d):
event = Event()
X.XNextEvent(d, C.byref(event))
if event.type in (2, 3, 4, 5, 6):
e = event.input
row = dict(type=event.type, window=e.window, x=e.x, y=e.y,
synthetic=bool(e.send_event))
if event.type != 6:
row['code'] = e.code
if event.type == 2:
buf, keysym = C.create_string_buffer(32), U()
n = X.XLookupString(C.byref(e), buf, 32, C.byref(keysym), None)
row.update(text=buf.raw[:n].decode('utf-8', 'replace'), keysym=keysym.value)
say(**row)
ready, _, _ = select.select([0, X.XConnectionNumber(d)], [], [], 0.2)
if 0 in ready and not os.read(0, 1024):
break
except TimeoutError:
pass
finally:
if w:
X.XDestroyWindow(d, w)
X.XCloseDisplay(d)
say(cleaned=True)
@@ -0,0 +1,65 @@
# Panel discovery investigation, 2026-10-24
Base: v0.4.16 (`040581a`). Scope: issue #73, Desktop Capture and Control.
## Evidence and change
Issue #73 reports SteamOS 0.4.5 with its desktop visibly running, without a
Frame Control version or steps. The maintainer's 2026-10-12 comment identifies
the duplicate `loadPanels` UI function fixed in 0.4.3 and requests the exact
remaining message and SteamOS build if it persists. There is no reporter
follow-up. At this base, the functions are distinct and the existing Node
regression checks their uniqueness and the Desktop GET request.
Read-only source inspection found a remaining diagnostic ambiguity:
`read_props()` returns `{}` for failed/timed-out X queries and missing root
properties. `panels()` treated that like a confirmed empty focusable-window
list. With no detected Plasma process it claimed no panel was open; with Plasma
it claimed SteamOS was not listing the desktop. Neither claim follows from an
unreadable list.
The change checks for the presence of `GAMESCOPE_FOCUSABLE_WINDOWS` before
those claims. An absent list now reports unknown panel state, suggests checking
SteamVR and retrying/Headset view, and asks for the exact message and SteamOS
version if persistent. Missing-tool and whole-run deadline explanations retain
precedence. No new probes, retries, timeouts, input or capture routes are added.
## Real-device boundary
A preliminary read-only SSH inspection returned SteamOS 0.5.6, BUILD_ID
`20261015.6141207`, battery 99%, discharging, and X0/X1 socket names. It made
no headset changes. The shared lock was absent at that moment.
The subsequent atomic `mkdir /tmp/frame-test.lock` failed with `File exists`.
The guarded command exited with status 3 before the session, X-property or
panel-helper probes ran. The other task's lock was left alone. No application
was installed, started, stopped or focused; no headset settings changed.
That implementation probe was blocked by the occupied shared device. The
coordinator later supplied a separate narrow read-only observation on SteamOS
0.5.6, BUILD_ID `20261015.6141207`, at battery 99%, with the shared lock absent
and both `xprop` and `xwininfo` installed:
- In **Game Mode**, `DISPLAY=:0 xprop -root GAMESCOPE_FOCUSABLE_WINDOWS
GAMESCOPE_FOCUSED_WINDOW` returned both properties **present but empty**.
- On `DISPLAY=:1`, both properties were reported **not found**.
This observation distinguishes a confirmed empty list on `:0` from absent
properties on `:1`. It does **not** reproduce #73's Desktop Mode report on
SteamOS 0.4.5, and it does **not** test this PR's code on the headset. It is a
coordinator-supplied observation, separate from the blocked implementation probe
above; no new headset probe was run for this follow-up.
Remaining limit: there is no reproduction establishing a further
capture/discovery fix for the reported Desktop Mode/build combination. This PR
improves a demonstrated diagnostic error; it must not close #73 as a verified
new capture fix.
## Offline verification
Focused Python regressions distinguish absent, focus-only, failed/timed-out X
queries, confirmed empty and desktop-running lists. Existing tests cover
missing tools, alternate displays, successful discovery and the 12-second
deadline. The production page's Node behavior test verifies that both Capture
and Control preserve the unreadable-list advice. These use fake X/HTTP/DOM
boundaries and do not prove real SteamOS 0.4.5 capture or physical input.
+168
View File
@@ -0,0 +1,168 @@
# Saved panel layouts: feasibility recheck, 2026-10-24
Issue: [#31](https://github.com/saphid/frame-control/issues/31). Base: main
`5b47cd3`. This investigation covers spatial save/restore for existing
Steam/gamescope panels without another overlay application.
## Decision and user behavior
Keep saved layouts unavailable. The current Frame exposes some geometry,
including a standing-space coordinate transform, but this investigation did
not establish a complete dock-state snapshot or a controlled restore path for
these panels. The existing companion/headset switcher still lists panels and
requests focus; users arrange them with SteamVR's dashboard controls. No layout
file, new control, overlay application or background service is added.
This is a limit of the investigated route, not proof that every native
implementation is impossible. In particular, failure of
`GetOverlayTransformAbsolute` does **not** mean all pose readback is denied.
## Device and read-only results
SteamOS **0.5.6**, BUILD_ID **20261015.6141207**; `vrcmd --version`:
**2.18.2**. `gamescope-session.service` was active and
`plasma-workspace.target` inactive: Game Mode. gamescope used the OpenVR backend
and `--virtual-connector-strategy PerAppId`. Battery was 99%, Not charging,
before the probe and 100%, Full, afterwards.
The [exact probe](panel-layout-2026-10-24/probe.py) loads only
`FnTable:IVROverlay_028` from the shipped
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`. It uses application type 3
(`VRApplication_Background`, avoiding runtime startup) and shuts down its own
OpenVR connection in `finally`. Initialization and interface acquisition both
returned 0. It calls getters only, with no image capture or input injection.
[Raw result](panel-layout-2026-10-24/readback.json):
| Getter | `valve.steam.desktopgame.0` | `valve.steam.gamepadui.main` |
|---|---|---|
| FindOverlay | 0, Success | 0, Success |
| Rendering process | `gamescope-wl` | `gamescope-wl` |
| Width | 0; 2.6700000763 m | 0; 2.6670000553 m |
| Curvature | 0; 0 | 0; 0 |
| Transform type | 0; 5, DashboardTab | 0; 5, DashboardTab |
| Absolute transform | 18, WrongTransformType | 18, WrongTransformType |
| Tracked-device-relative transform | 18, WrongTransformType | 18, WrongTransformType |
| Coordinate transform, center, standing origin | 0; identity matrix | 0; scaled/translated matrix |
| Coordinate transform, center, seated origin | 23, RequestFailed | 23, RequestFailed |
| Coordinate transform, center, raw origin | 23, RequestFailed | 23, RequestFailed |
The dashboard and both panels were reported hidden; neither panel was active.
The standing-space matrix is the transform for the overlay point `(0.5, 0.5)`;
it does not identify World/Dashboard/Theater/controller docking or recreate a
dashboard frame. The Steam panel's basis has scale about 0.369. Treat neither
that scaled matrix nor the hidden Gamescope panel's identity as a validated
physical placement to feed to an absolute-transform setter. Visible placement,
matrix freshness and correspondence to dashboard size were not checked by a
wearer.
## API and dashboard boundary
The ABI and result names were checked against Valve's
[OpenVR v2.12.14 C header](https://github.com/ValveSoftware/openvr/blob/v2.12.14/headers/openvr_capi.h)
and [C++ interface comments](https://github.com/ValveSoftware/openvr/blob/v2.12.14/headers/openvr.h).
That header declares `IVROverlay_028`; its SHA-256 is
`d58a2b149e434b2b8ee25cb4253ca6157feed8092f621e78ecfed6846d35d3e4`.
The absolute getter is explicitly limited to absolute transforms. The
coordinate getter returns the 3D transform for a 2D overlay point. Public
`IVROverlay_028` has no getter/setter pair for dashboard dock location or frame
scene-graph state. The probe fails closed if that exact interface is absent.
Read-only inspection of the shipped dashboard
`/opt/steamvr/resources/webinterface/dashboard/chunk~8012d0c89.js`
(SHA-256 `937ec62c67a481f030a416099ceb98b0fe2a702aea5a0705601c6c40d4969f6d`)
found:
- `dockLocation` reads an internal `_getDockLocation(frameID)` store;
`SetDockLocation` updates that store or sends a VRLink request.
- `m_mapLastRelativeTransformForDockLocation` keeps per-location transforms.
World initialization requests a dashboard scene-graph transform through
`requestSGTransform`, then updates the component's `xfTransform`.
- The `vrcmd_dock_overlay` handler accepts `dashboard`, `lefthand`, `righthand`,
`theater` and `world`. It selects the frame associated with the supplied key,
**falls back to the active frame if no match exists**, sets dock location and
may switch frame. This is not an exact pose restore or a readback response.
Those are observations of shipped internal code, not proof that Frame Control
can safely access its state through a supported external API. No dashboard
JavaScript was injected, private store edited or VRLink command sent. The
active-frame fallback is an additional reason not to experiment with docking
commands on a stale key.
The [2026-09-28 probe](../panels.md#saved-spatial-layouts-blocked-on-the-current-panel-route)
on SteamOS 0.4.1 / SteamVR 2.18.1 recorded an absolute setter returning
12 (`PermissionDenied`) on a temporary, test-owned gamescope panel. That setter
result is **historical**, not a new test on 2.18.2. The newer read-only results
above neither overturn nor reverify it.
## Commands, safety and cleanup
Read-only inspection used `ssh frame` with `BatchMode=yes` and
`ConnectTimeout=10`, `cat /etc/os-release`, battery sysfs reads,
`systemctl --user is-active gamescope-session.service plasma-workspace.target`,
process inspection, and `vrcmd --version`, `--help`, `--overlays`. An initial
`--listoverlays` attempt returned `Unknown command`; `--overlays` is the actual
enumeration command. Dashboard sources were read/copied to local `/tmp` for
inspection. No files were installed on the Frame.
Run the retained probe from the repository root, following
[the shared-device rules](../testing.md#headset-smoke-test). Stop if the lock is
occupied or battery is at/below 20%; never remove another task's lock:
```sh
ssh -o BatchMode=yes -o ConnectTimeout=10 frame \
'set -eu; mkdir /tmp/frame-test.lock || exit 3; trap "rmdir /tmp/frame-test.lock" EXIT; timeout 25 python3 -' \
< docs/evidence/panel-layout-2026-10-24/probe.py
```
This command acquired the lock, completed with exit 0 and released it. A
subsequent check confirmed the lock absent, Game Mode active, and Steam,
`vrserver` and `vrcompositor` running. The probe's only headset filesystem
change was creating/removing that shared lock. Its Python source ran from
stdin. There were no test panels, installs, profiles or services to remove.
No mutation test was essential: without verified dock/relative-frame readback,
there was no exact original layout and bounded rollback to justify changing a
panel. Existing user panels were never focused, shown, hidden, resized, moved
or docked. No global settings, security, passwords, runtime processes or modes
were changed; no reboot or reset occurred.
## Validation and remaining gaps
Existing `tests/test_panels.py` covers main-panel enumeration, hidden panels,
focus revalidation, unavailable-runtime errors and HTTP authentication/Host/
Origin guards. `tests/test_page_scripts.py` checks the production page's shared
JavaScript scope. These offline checks do not prove spatial placement. The
retained probe is evidence tooling, not a new product API; no fake layout
backend or layout tests claiming device support were added.
Validation passed:
```sh
python3 -m unittest discover -s tests -p test_panels.py -v # 8 passed
python3 -m unittest discover -s tests -p test_page_scripts.py -v # 5 passed
python3 -m py_compile docs/evidence/panel-layout-2026-10-24/probe.py
python3 -m json.tool docs/evidence/panel-layout-2026-10-24/readback.json
git diff --check
```
The initial panel-suite attempt hit sandbox socket restrictions in four HTTP
tests; rerunning with loopback sockets permitted passed all eight. No real
headset connection is used by those tests. The full suite and fake Frame were
not run for this evidence-only change.
Before shipping layouts, a native route still needs proof of dock state and
relative-frame readback, authorized restore on an owned test panel with exact
rollback, and correspondence between saved size/pose and visible placement.
It also needs stable source identity and a fresh capture-permission/ticket
flow when reopening viewers. Reconnect, panel recreation, reboot recovery,
Steam-launched gameplay, controller docking and worn-headset input were not
tested here. The existing Frame Control-owned media renderer is not proof that
Steam/gamescope panels can be restored. No third-party overlay app was used.
## Announcement
We rechecked saved panel layouts on SteamOS 0.5.6. SteamVR exposes some panel
geometry, but we have not verified safe restoration of Steam's panel positions
and docking. Saved layouts remain unavailable; use SteamVR's dashboard to
arrange panels. The panel switcher continues to list and show open panels.
@@ -0,0 +1,89 @@
"""Read-only issue #31 probe; pipe to `ssh frame 'timeout 25 python3 -'`.
Acquire /tmp/frame-test.lock first per docs/testing.md. No overlay is created,
focused, docked or changed. Background init (3) does not start SteamVR.
ABI: ValveSoftware/openvr v2.12.14 headers/openvr_capi.h, IVROverlay_028.
"""
import ctypes as C
import json
from pathlib import Path
H = C.c_uint64
Matrix = C.c_float * 12
class Vector2(C.Structure):
_fields_ = [('v', C.c_float * 2)]
def probe():
vr = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
vr.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
vr.VR_InitInternal2.restype = C.c_uint32
vr.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
vr.VR_GetGenericInterface.restype = C.c_void_p
vr.VR_ShutdownInternal.argtypes = []
vr.VR_ShutdownInternal.restype = None
err = C.c_int()
vr.VR_InitInternal2(C.byref(err), 3, None)
report = {'init_error': err.value, 'interface': 'IVROverlay_028', 'panels': []}
if err.value:
return report
try:
ptr = vr.VR_GetGenericInterface(b'FnTable:IVROverlay_028', C.byref(err))
report['interface_error'] = err.value
if err.value or not ptr:
return report
table = C.cast(ptr, C.POINTER(C.c_void_p))
def function(slot, result, *args):
return C.CFUNCTYPE(result, *args)(table[slot])
def read(slot, handle, output_type):
output = output_type()
rc = function(slot, C.c_int, H, C.POINTER(output_type))(handle, C.byref(output))
return {'rc': rc, 'value': output.value if rc == 0 else None}
report['dashboard_visible'] = function(68, C.c_bool)()
for key in ('valve.steam.desktopgame.0', 'valve.steam.gamepadui.main'):
row = {'key': key}
report['panels'].append(row)
handle = H()
row['find_rc'] = function(0, C.c_int, C.c_char_p, C.POINTER(H))(
key.encode(), C.byref(handle))
if row['find_rc']:
continue
pid = function(10, C.c_uint32, H)(handle)
row['rendering_pid'] = pid
try:
row['rendering_comm'] = Path('/proc/%d/comm' % pid).read_text().strip()
except OSError:
row['rendering_comm'] = None
row['visible'] = function(45, C.c_bool, H)(handle)
row['active_dashboard'] = function(69, C.c_bool, H)(handle)
row['width_m'] = read(23, handle, C.c_float)
row['curvature'] = read(25, handle, C.c_float)
row['transform_type'] = read(32, handle, C.c_int)
for name, slot, scalar in (('absolute', 34, C.c_int),
('tracked_device_relative', 36, C.c_uint32)):
origin = scalar()
matrix = Matrix()
rc = function(slot, C.c_int, H, C.POINTER(scalar), C.POINTER(Matrix))(
handle, C.byref(origin), C.byref(matrix))
row[name] = {'rc': rc, 'origin_or_device': origin.value if rc == 0 else None,
'matrix': list(matrix) if rc == 0 else None}
row['coordinate_transforms'] = []
for origin in (0, 1, 2): # seated, standing, raw/uncalibrated
matrix = Matrix()
rc = function(46, C.c_int, H, C.c_int, Vector2, C.POINTER(Matrix))(
handle, origin, Vector2((.5, .5)), C.byref(matrix))
row['coordinate_transforms'].append({
'tracking_origin': origin, 'coordinates': [.5, .5], 'rc': rc,
'matrix': list(matrix) if rc == 0 else None})
return report
finally:
vr.VR_ShutdownInternal()
if __name__ == '__main__':
print(json.dumps(probe(), indent=2, allow_nan=False))
@@ -0,0 +1,152 @@
{
"init_error": 0,
"interface": "IVROverlay_028",
"panels": [
{
"key": "valve.steam.desktopgame.0",
"find_rc": 0,
"rendering_pid": 297037,
"rendering_comm": "gamescope-wl",
"visible": false,
"active_dashboard": false,
"width_m": {
"rc": 0,
"value": 2.6700000762939453
},
"curvature": {
"rc": 0,
"value": 0.0
},
"transform_type": {
"rc": 0,
"value": 5
},
"absolute": {
"rc": 18,
"origin_or_device": null,
"matrix": null
},
"tracked_device_relative": {
"rc": 18,
"origin_or_device": null,
"matrix": null
},
"coordinate_transforms": [
{
"tracking_origin": 0,
"coordinates": [
0.5,
0.5
],
"rc": 23,
"matrix": null
},
{
"tracking_origin": 1,
"coordinates": [
0.5,
0.5
],
"rc": 0,
"matrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0
]
},
{
"tracking_origin": 2,
"coordinates": [
0.5,
0.5
],
"rc": 23,
"matrix": null
}
]
},
{
"key": "valve.steam.gamepadui.main",
"find_rc": 0,
"rendering_pid": 297037,
"rendering_comm": "gamescope-wl",
"visible": false,
"active_dashboard": false,
"width_m": {
"rc": 0,
"value": 2.6670000553131104
},
"curvature": {
"rc": 0,
"value": 0.0
},
"transform_type": {
"rc": 0,
"value": 5
},
"absolute": {
"rc": 18,
"origin_or_device": null,
"matrix": null
},
"tracked_device_relative": {
"rc": 18,
"origin_or_device": null,
"matrix": null
},
"coordinate_transforms": [
{
"tracking_origin": 0,
"coordinates": [
0.5,
0.5
],
"rc": 23,
"matrix": null
},
{
"tracking_origin": 1,
"coordinates": [
0.5,
0.5
],
"rc": 0,
"matrix": [
0.3689989447593689,
0.0,
0.0008894503116607666,
-0.5048462152481079,
0.0,
0.36899998784065247,
0.0,
1.2510287761688232,
-0.0008894503116607666,
0.0,
0.3689989447593689,
-1.1726053953170776
]
},
{
"tracking_origin": 2,
"coordinates": [
0.5,
0.5
],
"rc": 23,
"matrix": null
}
]
}
],
"interface_error": 0,
"dashboard_visible": false
}
@@ -0,0 +1,36 @@
{
"date": "2026-10-24",
"base": "c7489a8 (v0.4.14)",
"feature": "Desktop Tools power and heat observations",
"automated": {
"targeted": "python3 -m unittest discover -s tests -p 'test_power*.py': 35 passed",
"uiAndResourceGuard": "node --test tests/power_desktop.cjs: 7 passed",
"desktopApp": "node --test app/test/*.test.js: 37 passed",
"packaging": "Materialized ui/scripts resources selected by app/package.json; isolated Python -I imported frame_power, constructed remote payload including frame_status, and read idle status. Missing resource detection tested. Shared phone bundle import passed.",
"syntax": "Python compilation, sampler shell syntax and git diff --check passed",
"fullSuite": "python3 -m unittest discover -s tests -v: 1363 tests, OK (skipped=15), 153.274 seconds",
"transientFailure": "An earlier full run had one nested sandbox-test failure: the existing OneServer second-server child exited -11 (SIGSEGV), expected 1. Isolated sandbox rerun passed (6 tests), then the complete suite passed. No unrelated server-lifecycle code was changed."
},
"browser": {
"environment": "T3 collaborative Chromium preview, Linux, local Python Handler server; synthetic observation subprocess at accelerated one-second intervals; normal headset SSH disabled during start/stop/download verification",
"observations": [
"Opened #powerReport and verified labeled duration selector, disabled Stop/downloads before start and accessible sample progress.",
"Started a synthetic observation and observed 11 of 12 samples in progress; completion allowed another start.",
"Started again, observed progress, stopped, and saw 7 of 12 samples retained with explicit incomplete state and original frame alias.",
"Clicked HTML download and saw the UI acknowledge the request; T3's download list did not record the blob download, so filesystem save was not verified.",
"Fetched all three authenticated export endpoints in the browser: HTML 200 (7599 characters), JSON 200 (2618 characters, 7 samples), CSV 200 (977 characters); each retained the stop reason."
]
},
"realFrame": {
"lock": "ssh frame 'mkdir /tmp/frame-test.lock' succeeded before real-device probe; this task removed only that owned empty lock with rmdir after verification",
"probe": "Ran the production desktop Observation using strict, batch SSH to frame. The first attempt exposed a missing structured reason on early remote lock refusal; fixed and reran successfully.",
"result": "0 of 12 samples; running=false; stopped='Smoke session active (/tmp/frame-test.lock); no sensors read'. Exported HTML included the same incomplete reason.",
"scope": "Lock refusal only. No successful real sensor session, installation, app launch/stop, power control or thermal control was performed for this feature."
},
"limitations": [
"Native Electron installers were not built or launched; resource staging and build-hook contracts were tested.",
"One latest report is retained only in server memory. Starting again, restarting the server or quitting discards it; the UI warns users to download first.",
"Stopping ends only the owned local SSH process. The bounded remote reader discovers closure on its next write; immediate remote cancellation is not guaranteed.",
"Phone-hosted observation starts are unsupported; the shared server bundle still imports without desktop sampler resources."
]
}
@@ -0,0 +1,34 @@
{
"date": "2026-10-24",
"scope": "Read-only three-sample HTML export and browser download check; no installs, app launches, or remote process-control commands",
"preflight": {
"smokeLock": "absent",
"version": "0.5.6",
"build": "20261015.6141207"
},
"command": "scripts/frame-power-sample.sh --count 3 --interval 1 --html /tmp/frame-power-export-check.html",
"exitCode": 0,
"sampleCount": 3,
"metricCount": 91,
"unavailableGroups": [
"proximity"
],
"browser": {
"engine": "T3 collaborative Chromium preview",
"widths": [
1280,
390
],
"jsonDownloadMatches": true,
"csvDownloadMatches": true,
"csvDataRows": 276,
"pageScripts": 0,
"horizontalOverflow": "confined to focusable table region"
},
"limits": [
"No real-device Ctrl+C cancellation test; local child termination and retained samples verified with subprocess fixtures",
"No screen-reader, Windows or macOS verification",
"No controlled baseline, load or worn-headset testing",
"Sensor accuracy not established"
]
}
+149
View File
@@ -0,0 +1,149 @@
{
"date": "2026-10-24",
"osVersion": "0.5.6",
"osBuild": "20261015.6141207",
"commands": [
"scripts/frame-power-sample.sh --count 3 --interval 1",
"scripts/frame-power-sample.sh --count 1 --csv"
],
"jsonSampleCount": 3,
"elapsedSeconds": [
0.078011,
1.088542,
2.107291
],
"stopped": null,
"unavailableGroups": [
[
"proximity"
],
[
"proximity"
],
[
"proximity"
]
],
"selectedSummary": {
"battery.percent": {
"unit": "%",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"min": 99,
"max": 99,
"mean": 99.0,
"first": 99,
"last": 99
},
"battery.status": {
"unit": "state",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"states": [
"Not charging"
]
},
"battery.tempC": {
"unit": "C",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"min": 26.400000000000002,
"max": 26.400000000000002,
"mean": 26.4,
"first": 26.400000000000002,
"last": 26.400000000000002
},
"battery.watts": {
"unit": "W",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"min": -0.12,
"max": -0.02,
"mean": -0.07,
"first": -0.02,
"last": -0.07
},
"cpu.percent": {
"unit": "%",
"sources": [
"/proc/stat"
],
"available": 2,
"unavailable": 1,
"min": 8.7,
"max": 11.0,
"mean": 9.85,
"first": 11.0,
"last": 8.7
},
"fan.pwmRaw": {
"unit": "raw",
"sources": [
"/sys/class/hwmon/hwmon50/pwm1"
],
"available": 3,
"unavailable": 0,
"min": 41,
"max": 41,
"mean": 41.0,
"first": 41,
"last": 41
},
"fan.slg4ax46073v.fan1_input": {
"unit": "RPM",
"sources": [
"/sys/class/hwmon/hwmon50/fan1_input"
],
"available": 3,
"unavailable": 0,
"min": 8624,
"max": 8638,
"mean": 8628.666667,
"first": 8638,
"last": 8624
},
"proximity.raw": {
"unit": "raw",
"sources": [
"/sys/bus/iio/devices/iio:device2/in_proximity_raw"
],
"available": 0,
"unavailable": 3,
"states": []
},
"rail.max34417_9_10.vph.power1": {
"unit": "W",
"sources": [
"/sys/class/hwmon/hwmon53/power1_input"
],
"available": 3,
"unavailable": 0,
"min": 4.656299,
"max": 4.804066,
"mean": 4.705555,
"first": 4.656299,
"last": 4.804066
}
},
"csvRows": 92,
"csvSummaryMetrics": 91,
"csvHasExplicitUnavailable": true,
"limits": [
"Read-only short transport/parser check; no controlled baseline or worn/load/battery experiment.",
"Proximity unavailable; numeric thermistors are not proof of sensor accuracy.",
"No settings, services, device files or application state changed; no remote installation.",
"No smoke lock at preflight or sampler checks; advisory checks cannot exclude a concurrent lock appearing during a read."
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,226 @@
{
"date_utc": "2026-10-24T09:27:25.354Z",
"result": "PASS: literal packaged desktop Return to Game Mode button switched the real Frame from full Plasma; a repeat visible click made no second switch",
"documentation_base_commit": "71288d8980040c14f777f0e417f41f7105eaaac4",
"host": "lxso2",
"steamos_version": "0.5.6",
"steamos_build": "20261015.6141207",
"prior_evidence": [
"https://github.com/saphid/frame-control/issues/142",
"https://github.com/saphid/frame-control/pull/143",
"https://github.com/saphid/frame-control/pull/156"
],
"package": {
"tag": "v0.4.17",
"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",
"verification": "sha256sum matched the live release asset digest. Independently extracted the retained asset into a new private test directory. cmp confirmed bundled frame_backup.py and frame_backup_remote.py equal the checkout versions. No source or payload was changed.",
"launch": "--appimage-extract, then squashfs-root/AppRun --no-sandbox --disable-gpu --remote-debugging-port=49372 --remote-debugging-address=127.0.0.1 --user-data-dir=/tmp/frame-return-button-20261024/electron-profile --window-size=1400,950",
"display": "Xvfb :138 -screen 0 1440x1000x24 -nolisten tcp; Electron content screenshot 1400x923",
"isolation": "Private XDG_CONFIG_HOME, XDG_CACHE_HOME, FRAME_CONTROL_DATA_DIR and Electron profile; FRAME_CONTROL_TELEMETRY=0 and FRAME_CONTROL_NO_UPDATE_CHECK=1. Existing authorized ssh frame identity retained; bundled server and Python used."
},
"preflight": {
"read_only_first": true,
"initial_mode": "gamescope-session.target=active; plasma-workspace-wayland.target=inactive",
"initial_battery_percent": 99,
"initial_battery_status": "Not charging; charger Full",
"steam_and_steamvr_running": true,
"running_steam_apps": [],
"default_login_mode": "game",
"lock": "mkdir /tmp/frame-test.lock succeeded atomically; no foreign lock was removed. Initial harmless probe attempt released its own lock, then a fresh atomic acquisition covered both full-session attempts."
},
"rollback": {
"unit": "fc-return-button-rollback-20261024",
"probe": "systemd-run --user --unit=fc-return-button-probe-20261024 --on-active=2s --timer-property=AccuracySec=1s --property=Type=oneshot --property=RemainAfterExit=yes /usr/bin/true",
"probe_execution": "Result=success\nExecMainStartTimestamp=Sat 2026-10-24 20:27:07 AEDT\nExecMainExitTimestamp=Sat 2026-10-24 20:27:07 AEDT\nExecMainCode=1\nExecMainStatus=0\n",
"arm": "systemd-run --user --unit=fc-return-button-rollback-20261024 --on-active=180s --timer-property=AccuracySec=1s /bin/sh -c <guarded command>",
"guarded_command": "if [ \"$(systemctl --user is-active gamescope-session.target)\" != active ]; then exec /usr/bin/steamosctl switch-to-game-mode; fi",
"verified_before_desktop": "TimersMonotonic={ OnActiveUSec=3min ; next_elapse=1d 21h 53min 49.130174s }\nNextElapseUSecMonotonic=1d 21h 53min 49.130174s\nBindsTo=\nPartOf=\nActiveState=active\nExecStart={ path=/bin/sh ; argv[]=/bin/sh -c if [ \"$(systemctl --user is-active gamescope-session.target)\" != active ]; then exec /usr/bin/steamosctl switch-to-game-mode; fi ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\nBindsTo=\nPartOf=\n",
"user_manager_linger": "yes",
"desktop_entry": "ssh frame steamosctl switch-to-desktop-mode",
"first_click_utc": "2026-10-24T09:27:27.037Z",
"cancelled_before_deadline": true,
"fired": false,
"limit": "The benign transient timer was observed executing. The actual rollback timer configuration was read back, then cancelled after the UI restored Game Mode; its recovery action was not deliberately allowed to fire."
},
"ui": {
"navigation": "Clicked nav Tools, Backup & recovery, and Check in the actual packaged Electron window. Initial Game Mode disabled Return. After full Plasma entry, Check enabled it.",
"automation": "Playwright connected to the packaged Electron instance over local CDP and used locator.click() on the visible button; accepted the native JavaScript confirm dialog. No Return API/CLI or production handler was directly invoked by the test. T3 preview was inspected separately and closed; it cannot control this Electron window.",
"clicks": [
{
"isTrusted": true,
"utc": "2026-10-24T09:27:27.037Z"
},
{
"isTrusted": true,
"utc": "2026-10-24T09:27:32.942Z"
}
],
"button_bounds_css_pixels": {
"x": 47,
"y": 709.59375,
"width": 156.203125,
"height": 44
},
"confirmation_dialogs": [
{
"type": "confirm",
"message": "Return the headset to Game Mode?\n\nThis closes the desktop and any apps open in it, then starts Steam's Game Mode.",
"utc": "2026-10-24T09:27:27.042Z"
},
{
"type": "confirm",
"message": "Return the headset to Game Mode?\n\nThis closes the desktop and any apps open in it, then starts Steam's Game Mode.",
"utc": "2026-10-24T09:27:32.943Z"
}
],
"desktop_check": {
"http": 200,
"body": {
"mode": "desktop",
"gamescope": true,
"plasma": true,
"nested_desktop": false,
"configured": "gamescope-wayland.desktop",
"config_file": "/etc/sddm.conf.d/steamos.conf",
"selector": "steamosctl",
"session_targets": {
"gamescope-session.target": "inactive",
"plasma-workspace-wayland.target": "active"
},
"mode_source": "session-targets"
}
},
"return_responses": [
{
"http": 200,
"body": {
"message": "Asked the headset to return to Game Mode; it restarts its session in a few seconds",
"state": {
"mode": "desktop",
"gamescope": true,
"plasma": true,
"nested_desktop": false,
"configured": "gamescope-wayland.desktop",
"config_file": "/etc/sddm.conf.d/steamos.conf",
"selector": "steamosctl",
"session_targets": {
"gamescope-session.target": "inactive",
"plasma-workspace-wayland.target": "active"
},
"mode_source": "session-targets"
},
"switched": true,
"command": "steamosctl switch-to-game-mode"
},
"utc": "2026-10-24T09:27:27.316Z"
},
{
"http": 200,
"body": {
"message": "The headset is already in Game Mode. The desktop you see is a panel: close it from the Steam dashboard.",
"state": {
"mode": "game",
"gamescope": true,
"plasma": false,
"nested_desktop": false,
"configured": "gamescope-wayland.desktop",
"config_file": "/etc/sddm.conf.d/steamos.conf",
"selector": "steamosctl",
"session_targets": {
"gamescope-session.target": "active",
"plasma-workspace-wayland.target": "inactive"
},
"mode_source": "session-targets"
},
"switched": false
},
"utc": "2026-10-24T09:27:33.126Z"
}
],
"final_check": {
"http": 200,
"body": {
"mode": "game",
"gamescope": true,
"plasma": false,
"nested_desktop": false,
"configured": "gamescope-wayland.desktop",
"config_file": "/etc/sddm.conf.d/steamos.conf",
"selector": "steamosctl",
"session_targets": {
"gamescope-session.target": "active",
"plasma-workspace-wayland.target": "inactive"
},
"mode_source": "session-targets"
}
},
"final_return_disabled": true,
"final_text": "Game Mode: the headset is running normally. A desktop you see is a panel; close it from the Steam dashboard.",
"screenshots": [
"return-game-mode-packaged-2026-10-24-before.png",
"return-game-mode-packaged-2026-10-24-after.png"
],
"screenshot_scope": "Cropped to the session controls and repeat-action notification; host identity, connection address and backup folder excluded."
},
"systemd_verification": {
"command": "ssh frame systemctl --user show gamescope-session.target plasma-workspace-wayland.target -p Id -p ActiveState -p SubState; ssh frame systemctl --user show gamescope-session.service -p InvocationID",
"before": "Id=gamescope-session.target\nActiveState=inactive\nSubState=dead\n\nId=plasma-workspace-wayland.target\nActiveState=active\nSubState=active\nInvocationID=892b843dbe1a4d2ba91b93057bd38463\n295589 /home/steamos/.local/share/Steam/steamrtarm64/steam -silent -vrdisable -deckard\n295066 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-294978\n",
"after_first_click": "Id=gamescope-session.target\nActiveState=active\nSubState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nSubState=dead\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n",
"after_repeat_click": "Id=gamescope-session.target\nActiveState=active\nSubState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nSubState=dead\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n297942 /home/steamos/.local/share/Steam/steamrtarm64/steam -child-update-ui -child-update-ui-socket 8 -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n",
"repeat_did_not_restart_game_mode": true,
"unchanged_game_service_invocation": "e8c1085b3bbd4e79a323b3e4a9bc6355",
"unchanged_steamvr_pid": 297506,
"delayed_final_readback_utc": "2026-10-24T09:28:05Z"
},
"cleanup": {
"complete": true,
"final_readback": "2026-10-24T09:28:05Z\nId=gamescope-session.target\nActiveState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\nNEXT LEFT LAST PASSED UNIT ACTIVATES\n\n0 timers listed.\nId=fc-return-button-rollback-20261024.timer\nLoadState=not-found\nActiveState=inactive\n\nId=fc-return-button-rollback-20261024.service\nLoadState=not-found\nActiveState=inactive\n\nId=fc-return-button-probe-20261024.timer\nLoadState=not-found\nActiveState=inactive\n\nId=fc-return-button-probe-20261024.service\nLoadState=not-found\nActiveState=inactive\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\n99\nCharging\ngame\n",
"owned_lock_release_exit": 0,
"owned_lock_absent": true,
"host_processes": {
"xvfb": {
"pid": 1562020,
"exists": false,
"cmd": null,
"state": null
},
"electron": {
"pid": 1562023,
"exists": false,
"cmd": null,
"state": null
},
"x_socket_absent": true
},
"remaining_test_servers": [],
"battery_final_percent": 99,
"battery_final_status": "Charging",
"battery_range_observed_percent": [
99,
100
],
"game_mode_steam_steamvr_running": true,
"default_login_mode_after": "game",
"headset_installs_or_global_settings_changed": false,
"rebooted": false,
"notes": "Stopping the timer succeeded. systemctl stop also named its never-started, unloaded service and returned 5; explicit readback confirms both rollback and probe timer/service units are not-found/inactive. No further switch was sent."
},
"attempts_and_harness_limits": [
"Initial harmless timer probe was garbage-collected before its exit metadata could be read. No Desktop Mode switch was attempted. Repeated with RemainAfterExit=yes and verified successful execution.",
"First full Plasma attempt clicked the visible packaged Return button and got HTTP 200 / switched=true. The harness then treated absent pgrep matches during the expected session restart as a fatal error. Independent readback confirmed Game Mode and SteamVR had returned. Rollback remained armed until that readback, then was cancelled. This incomplete attempt was not counted as the final pass.",
"Final full Plasma attempt tolerated transiently absent Steam/SteamVR while polling systemd targets, passed both trusted visible clicks, and completed the final Check. Stopping the unused rollback service returned unit-not-loaded; explicit subsequent readback verified all four owned probe/rollback units not-found/inactive and zero timers. Cleanup was completed before releasing our lock."
],
"limits": [
"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.",
"Automated trusted pointer clicks in a visible Electron window; no physical-monitor human click or worn-headset visual/interaction assessment.",
"Verified only SteamOS 0.5.6 build 20261015.6141207 and the Linux x86_64 v0.4.17 package; older selectors/builds and other desktop platforms remain covered only by their existing tests.",
"No release, tag, application code, passwords or security/global settings changed."
],
"validation": {
"focused_suite": "python3 -m unittest discover -s tests -p test_backup.py -v: 28 tests passed in 2.479s",
"json": "python3 -m json.tool: passed",
"diff": "git diff --check: passed"
}
}
@@ -0,0 +1,19 @@
# Round 3 headset checks, 25 October 2026
These checks used the real Frame after the device hold ended. SteamOS was
0.5.6, build `20261022.6153909`. Each device session acquired
`/tmp/frame-test.lock` atomically and released only its own lock. The battery
read 100% and Full in the first check and after the OBS session; Steam and
SteamVR were left running.
| Question | Observation | Conclusion |
|---|---|---|
| Does active passthrough appear in SteamVR's `/dev/video99` mirror? | The mirror service was active. One 1920×1080 RGB frame read from `/dev/video99` had a single pixel value, `(24, 28, 53)`, throughout. The headset was unworn; no active passthrough session or wearer-visible room view was established. Frame Control has no verified remote passthrough toggle. The temporary frame was removed. | **Still unknown.** A uniform standby frame cannot answer whether SteamVR composites active passthrough into its mirror. No headset setting was changed. |
| Can Frame Control prompt when a headset text box opens? | No desktop `plasmashell` process was running during this check. There is no verified cross-app focus event from SteamVR, Lepton and the nested desktop; KDE Connect's `keyboardstate` packet describes keyboard support, not a focused text field. | **Not built.** A prompt triggered by an unverified signal would be misleading. The existing **Type on the headset** control remains available. |
| Does reverse clipboard exist? | Frame Control already has **Copy the headset's clipboard here**. Its earlier real Frame test used a temporary nested desktop and confirmed that text copied through Klipper reached a Linux PC clipboard. No desktop panel was open during this session, so it was not rerun here. | **Shipped and previously verified**, text only. Windows/macOS backends and arbitrary headset apps remain unverified on a real Frame. |
| Is an 80% charge limit available and writable? | `steamosctl get-max-charge-level` returned `UnknownInterface: ...BatteryChargeLimit1`. No `charge_control_*` attribute was found under `/sys/class/power_supply`. Only `pm8550b-charger/charging_pause` and `charging_enable` were present, each mode `644`, owner `root:root`. | **No supported user-writable control.** No battery attribute was written. |
The separate OBS transport check on the same build sent the mirror and the
default-output monitor to a Linux PC. A 4.021-second temporary capture had
H.264 video and AAC audio according to `ffprobe`. It was removed; OBS itself
and audible playback were not tested. See the OBS source PR and guide.
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

@@ -0,0 +1,22 @@
# SteamOS 0.5.6 compatibility check, 25 October 2026
The real Frame was accessed **after** the 00:00 AEDT hold, under a separately
acquired `/tmp/frame-test.lock`. The coordinator had finished its v0.4.19 smoke
test and released its lock first. This check took place approximately 00:04–
00:08 AEDT (13:04–13:08 UTC). A device clock reading in the first snapshot was
**00:04:58 AEDT**. The one temporary capture was removed and our lock was
released. Steam and SteamVR were left running.
| Check | Observation |
|---|---|
| OS | `VERSION_ID=0.5.6`, `VARIANT_ID=vr`, `BUILD_ID=20261022.6153909` |
| Update channel | `atomupd-manager tracked-branch` returned `beta`; tracked variant `vr`; update status `idle`. `atomupd-manager status` and `check` returned D-Bus `AccessDenied` to the normal user. No root, update, or channel change was attempted. |
| Discovery | `frame.local` and a sample `frame-0000.local` name were unresolved from the Linux host. Avahi was active on the Frame, but `/etc/avahi/avahi-daemon.conf` has `publish-addresses=no`. The configured `ssh frame` alias worked. The host reached the Frame over Tailscale, so a local-subnet scan and same-LAN cached-IP reconnection were not exercised. |
| Script fallback | `steamos-session-select` was present and `holo-session-select` absent. The installed devkit utilities contained `steamos-prepare-upload` and `steamos-delete`; their `holo-*` replacements were absent. No session switch, install, or delete command was run. |
| Battery and memory | Frame Control's `ui/frame_status.py` returned a system battery at 98%, Charging, with its source under `/sys/class/power_supply/max1720x_bat_7-36`; memory was 16,422,952,960 bytes total and 11,256,848,384 bytes available. The battery was 97% at the initial raw sysfs check and 98% at the final check while charging. |
| Recording source | The user `steamvr-v4l2cam.service` and `/dev/video99` were present. A single frame from that device was black while the unworn headset was in standby. Frame Control's MP4 path reads that same SteamVR `system.HeadsetView` mirror and never opens a passthrough camera. Active passthrough inclusion in the mirror remains unverified. The sample image was not retained or published. |
| Dominant eye | Neither standard `steamvr.vrsettings` path checked had a readable dominant-eye setting. No source or effect on the mirror was verified; Frame Control does not attach dominant-eye metadata. |
This was a bounded compatibility check, not a rerun of the coordinator's
13-step smoke test. The coordinator reported that smoke passed on this same
build, with battery 95% to 96% while charging and cleanup complete.
+151
View File
@@ -0,0 +1,151 @@
# Windows VM and real Frame test, 2026-10-24
The v0.4.10 installer could enumerate windows/screens and encode a synthetic
Media Foundation stream, but every desktop capture failed at pipeline parsing:
`no property "capture-api" in element "d3d11screencapturesrc"`.
MSYS2's GStreamer 1.28.8 package disables WGC. After the fix, the same installed
bundle captured the actual Windows desktop with Desktop Duplication and sent
H.264 to a real Frame: 219 sent frame records, 215 decoded and drawn before the
snapshot. Individual windows are explicitly unavailable with this bundle.
The base was `e9cbb8fd5555f5cfceeba1babadf2d7d35dcd3be` (`v0.4.10`, origin/main,
including merged #121), in isolated branch `t3/test-windows-vm-headset`.
## Evidence
- [Capture before the fix](windows-vm-2026-10-24/capture-before.json): five
synthetic H.264 frames and five JPEG frames; zero desktop/window frames,
with the missing-property error for both codecs.
- [Actual bundled properties](windows-vm-2026-10-24/plugin-properties.json):
`monitor-handle` exists; `capture-api`, `window-handle`, `show-border` do not.
[MSYS2 package build](https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-gst-plugins-bad/PKGBUILD)
sets `-Dd3d11-wgc=disabled`. Upstream
[installs those properties conditionally](https://github.com/GStreamer/gstreamer/blob/main/subprojects/gst-plugins-bad/sys/d3d11/gstd3d11screencapturesrc.cpp).
- [Capture after the fix](windows-vm-2026-10-24/capture-after.json): the real
display produces five H.264 and five JPEG frames. Window sources are hidden
with an explicit reason, and the display selects `capture_api=dxgi`.
- [Raw locked headset session](windows-vm-2026-10-24/headset-session.json):
Windows helper, Media Foundation encoder, reverse SSH tunnel, production
panel launcher and shared viewer. The image below was captured from that
viewer's own X window on the Frame, rather than the VM's console.
- [Windows tests](windows-vm-2026-10-24/windows-tests.txt) and
[guest system details](windows-vm-2026-10-24/windows-system.txt).
![Windows VM desktop decoded in the Frame's own viewer](windows-vm-2026-10-24/headset-view.png)
## Host and VM prerequisites
lxso2: Intel Core i7-8700, 12 logical CPUs, VT-x/KVM; Docker 29.1.3 and Compose
2.40.3. Before starting: 31 GiB RAM, 15 GiB available; 147 GiB free disk.
Swap was already full. No other VM or service workload was started or stopped.
Docker listed no Windows VM, and ports 2222/8006/13389 were unused.
The execution sandbox hid KVM and denied Docker/network access. Checks outside
it found `/dev/kvm`, `/dev/net/tun` and the Docker daemon. Direct user access to
KVM was denied, but a disposable Docker container with `--device /dev/kvm`
opened it successfully; no host permissions changed.
Created only `frame-winvm-priority3`, labelled
`frame-control.task=priority3-windows-test`: 4 vCPUs, 8 GiB guest RAM, 32 GiB
growable disk, no restart, 150-second stop timeout. SSH 2222 and console 8006
bind only 127.0.0.1; RDP is unpublished. No GPU passthrough. Image:
`dockurr/windows@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea`
(Windows for Docker v6.06, QEMU v11.1.1).
The container downloaded Windows 11 from Microsoft, with no activation changes.
It took about 16 minutes to reach the first desktop. Guest build 26200.8037,
Red Hat VirtIO GPU DOD driver 100.103.104.30200. The old WindowsProductName
registry field still says Windows 10 Pro; the actual desktop/setup is Windows
11. After installing the app, the 32 GiB disk had about 9.3 GB free.
Official `Frame-Control-Setup-x64.exe` v0.4.10 SHA-256:
`30165bc5aa39f941cacc697d195dc5c30b01ad0b9ffb472da8a18a5220549202`.
Its installer exited 0. The VM stream used an intermediate Python/UI-only
fix with the released DLL bundle and bundled Python 3.12.14. A later CI failure
showed that probing GStreamer through a separately loaded DLL could see a
different, uninitialized instance. The final fix probes optional properties
through the capture DLL itself. Windows CI checks that native change; the VM
stream was not repeated with the final DLL.
OEM setup added OpenSSH Server, PowerShell and a dedicated public login key;
password authentication is disabled. Its config needed an explicit
`AuthorizedKeysFile C:/ProgramData/ssh/administrators_authorized_keys` before
key login worked. The default informational share was replaced with a private
writable staging share inside this container. These were test-VM setup steps,
not app defects. Host setup files and the stopped VM disk are retained under
`/tmp/frame-winvm-priority3/`; no private keys are committed or printed.
The Frame test key was generated inside Windows and its private key stayed
there. Only its public key was temporarily authorized on the Frame. After
testing, the guest key and scheduled task were removed; `docker stop -t 150`
shut down the dedicated VM. Inspection confirmed `exited`, restart `no`
(container exit 143; logs show the ACPI shutdown signal, waiting for Windows,
and `Shutdown completed!`). The disk is retained
for reuse; the VM is not running.
## Checks
- Baseline PC-focused suite: 24 tests passed, one native class skipped.
- After fix: full Linux unittest suite **1260 tests, OK, 15 skipped**, 144.366 s;
focused final PC suite **27 tests, OK, one native class skipped**. The C
controller's frozen 600-state trace ran on Linux.
- Actual Windows installer bundle with the intermediate Python/UI fix:
**33 tests, OK, four skipped** (no C compiler, Linux PipeWire test, two
CI-only x264 tests). Its GObject property probe, host/encoder check and
x264-absence check ran. The final native probe is checked in the PR's
Windows CI build, not by this VM run.
- Browser check with the real production HTML/JavaScript and mocked Windows API
responses: DXGI whole-screen Show remains available, the limitation appears
once, WGC window sources retain Show, off hides controls, and the limitation
text is escaped. This used a static asset server, without headset API access.
- `node --test app/test/*.test.js`: passed, three test files.
- `node --test site/test/*.test.mjs`: passed, one test file.
- The first full Linux run inside the sandbox failed (11 failures, 139 errors),
including loopback socket `PermissionError`. The successful full runs were
outside that socket restriction.
- An initial guest test archive omitted `apk-catalog`, causing one import
error; the complete archive produced the 33-test passing Windows run.
Raw local logs: `/tmp/frame-windows-{pc,app,site}-tests.log`,
`/tmp/frame-windows-full-tests-unrestricted.log`,
`/tmp/frame-windows-full-fixed-tests.log`, and
`/tmp/frame-winvm-priority3/`. The committed artifacts above preserve the
capture, Windows test and headset evidence independently of those local files.
## Shared Frame session and cleanup
Read-only checks first: SteamOS 0.5.6, BUILD_ID `20261015.6141207`, battery 99%,
Not charging (another supply reports Full). The lock was free.
Both attempts acquired `/tmp/frame-test.lock` atomically before any key or
viewer changes, and removed only the lock acquired by this task. The first,
35-second attempt failed because the test wrapper sent CRLF script input and
left SSH stdin open. Production already sends script bytes and closes stdin;
correcting the wrapper resolved both. Its key was removed and lock released.
The successful session held the lock **03:09:36–03:10:05 UTC (29 seconds)**.
It used a dedicated `pc-view-priority3` profile and port range 47960–47964,
leaving existing viewers/profiles alone. The app's Windows `PCView` captured
`display:65537`, 1280×800, Light preset (30 fps ceiling), and opened panel
`valve.steam.desktopgame.2001396898`. The viewer reported H.264 software decode,
218 received frames, 215 decoded/drawn before the snapshot (219 sent records).
This mostly idle, roughly ten-second sample reported 19 fps and content delay
p50/p95 51.6/75.2 ms. These are observations, not a performance-target pass.
Cleanup stopped the helper and tunnel, closed the own viewer, removed its two
possible profile paths and log, then removed the exact public-key line. The
`authorized_keys` SHA-256 was identical before/after. A follow-up login from
Windows with that key failed with exit 255. Separate checks found no task-owned
viewer processes, the profile absent, and the shared lock released. Battery
remained 99%; the sampled gamescope focus properties matched before/after.
No reboot, global settings changes, game launch or Steam/SteamVR stop occurred.
## Limits
This verifies a Windows VM's desktop capture, Media Foundation encoding and
real Frame viewer decode/rendering. It does not verify physical-PC hardware
encoding, WGC or individual-window capture, mixed DPI, multiple displays,
physical laser alignment/clicks, keyboard layouts, resize/minimise, busy-scene
performance, bitrate/encoder restarts, or worn-headset comfort. No input was
injected during the real Frame session. The installed app’s Tools navigation was not exercised; its streaming
controller, tunnel and viewer were exercised directly. No independent review was performed.
@@ -0,0 +1,53 @@
{
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
"elements": {
"d3d11screencapturesrc": true,
"mfh264enc": true,
"jpegenc": true,
"x264enc": false
},
"windowCaptureReason": "Individual windows are unavailable in this build. Choose a whole screen instead.",
"windows": [],
"displays": [
{
"src": "display:65537",
"name": "Display 1",
"x": 0,
"y": 0,
"w": 1280,
"h": 800,
"capture_api": "dxgi"
}
],
"runs": [
{
"source": "test",
"codec": "h264",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 154
},
{
"source": "test",
"codec": "jpeg",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 7336
},
{
"source": "display:65537",
"codec": "h264",
"frames": 5,
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 65
},
{
"source": "display:65537",
"codec": "jpeg",
"frames": 5,
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 113339
}
]
}
@@ -0,0 +1,96 @@
{
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
"elements": {
"d3d11screencapturesrc": true,
"mfh264enc": true,
"jpegenc": true,
"x264enc": false
},
"windows": [
{
"src": "window:131836",
"id": 131836,
"title": "Administrator: C:\\WINDOWS\\system32\\cmd.exe - powershell -noprofile -executionpolicy bypass -file \\\\host.lan\\data\\bootstrap.ps1",
"app": "Windows",
"w": 1115,
"h": 628,
"x": 51,
"y": 52
},
{
"src": "window:327926",
"id": 327926,
"title": "Administrator: Install",
"app": "Windows",
"w": 979,
"h": 512,
"x": 85,
"y": 78
},
{
"src": "window:65856",
"id": 65856,
"title": "Program Manager",
"app": "Windows",
"w": 1280,
"h": 800,
"x": 0,
"y": 0
}
],
"displays": [
{
"src": "display:65537",
"name": "Display 1",
"x": 0,
"y": 0,
"w": 1280,
"h": 800
}
],
"runs": [
{
"source": "test",
"codec": "h264",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 154
},
{
"source": "test",
"codec": "jpeg",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 7336
},
{
"source": "display:65537",
"codec": "h264",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "display:65537",
"codec": "jpeg",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "window:131836",
"codec": "h264",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "window:131836",
"codec": "jpeg",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
}
]
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

@@ -0,0 +1,65 @@
{
"gstreamer": "1.28.8",
"plugins": [
"coreelements",
"videotestsrc",
"videoconvertscale",
"app",
"jpeg",
"videoparsersbad",
"d3d11",
"mediafoundation"
],
"packages": [
"mingw-w64-ucrt-x86_64-gst-plugins-bad 1.28.8-2",
"mingw-w64-ucrt-x86_64-gst-plugins-bad-libs 1.28.8-2",
"mingw-w64-ucrt-x86_64-gstreamer 1.28.8-1"
],
"elements": {
"d3d11screencapturesrc": {
"name": "The name of the object",
"parent": "The parent of the object",
"blocksize": "Size in bytes to read per buffer (-1 = default)",
"num-buffers": "Number of buffers to output before sending EOS (-1 = unlimited)",
"typefind": "Run typefind before negotiating (deprecated, non-functional)",
"do-timestamp": "Apply current stream time to buffers",
"automatic-eos": "Automatically EOS when the segment is done",
"monitor-index": "Zero-based index for monitor to capture (-1 = primary monitor)",
"monitor-handle": "A HMONITOR handle of monitor to capture",
"show-cursor": "Whether to show mouse cursor",
"crop-x": "Horizontal coordinate of top left corner for the screen capture area",
"crop-y": "Vertical coordinate of top left corner for the screen capture area",
"crop-width": "Width of screen capture area (0 = maximum)",
"crop-height": "Height of screen capture area (0 = maximum)"
},
"d3d12screencapturesrc": null,
"mfh264enc": {
"name": "The name of the object",
"parent": "The parent of the object",
"qos": "Handle Quality-of-Service events from downstream",
"min-force-key-unit-interval": "Minimum interval between force-keyunit requests in nanoseconds",
"bitrate": "Bitrate in kbit/sec",
"rc-mode": "Rate Control Mode",
"adaptive-mode": "Adaptive Mode",
"vbv-buffer-size": "VBV(HRD) Buffer Size in bytes (0 = MFT default)",
"max-bitrate": "The maximum bitrate applied when rc-mode is \"pcvbr\" in kbit/sec",
"quality-vs-speed": "Quality and speed tradeoff, [0, 33]: Low complexity, [34, 66]: Medium complexity, [67, 100]: High complexity",
"cabac": "Enable CABAC entropy coding",
"sps-id": "The SPS id to use",
"pps-id": "The PPS id to use",
"bframes": "The maximum number of consecutive B frames",
"gop-size": "The number of pictures from one GOP header to the next. Depending on GPU vendor implementation, zero gop-size might produce only one keyframe at the beginning (-1 for automatic)",
"threads": "The number of worker threads used by a encoder, (0 = MFT default)",
"content-type": "Indicates the type of video content",
"qp": "QP applied when rc-mode is \"qvbr\"",
"low-latency": "Enable low latency encoding",
"min-qp": "The minimum allowed QP applied to all rc-mode",
"max-qp": "The maximum allowed QP applied to all rc-mode",
"qp-i": "QP applied to I frames",
"qp-p": "QP applied to P frames",
"qp-b": "QP applied to B frames",
"ref": "The number of reference frames",
"d3d11-aware": "Whether device can support Direct3D11 interop"
}
}
}
@@ -0,0 +1,18 @@
WindowsProductName WindowsVersion OsBuildNumber
------------------ -------------- -------------
Windows 10 Pro 2009 26200
Name DriverVersion Status
---- ------------- ------
Red Hat VirtIO GPU DOD controller 100.103.104.30200 OK
Used Free
---- ----
23951253504 9314816000
@@ -0,0 +1,79 @@
python.exe : test_original_mac_congestion_and_recovery
(test_pc_controller.ControllerTrace.test_original_mac_congestion_and_recovery) ... skipped 'C
compiler not installed; native build jobs cover this trace'
At line:12 char:1
+ & $python -B -m unittest discover -s tests -p 'test_pc*.py' -v 2>&1 | ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (test_original_m...ver this trace':String) [], Remote
Exception
+ FullyQualifiedErrorId : NativeCommandError
test_controller_is_inert_after_close
(test_pcview.AdapterTests.test_controller_is_inert_after_close) ... ok
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties (test_pcview.AdapterTests.
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties) ... ok
test_idle_source_is_not_a_stall (test_pcview.AdapterTests.test_idle_source_is_not_a_stall) ... ok
test_library_path_prepend_adds_no_empty_entry
(test_pcview.AdapterTests.test_library_path_prepend_adds_no_empty_entry) ... ok
test_linux_uses_only_portal_fd_and_node
(test_pcview.AdapterTests.test_linux_uses_only_portal_fd_and_node) ... ok
test_malformed_viewer_input_is_dropped_not_fatal
(test_pcview.AdapterTests.test_malformed_viewer_input_is_dropped_not_fatal) ... ok
test_pc_card_receives_window_capture_limitation
(test_pcview.AdapterTests.test_pc_card_receives_window_capture_limitation) ... ok
test_pc_reuses_launcher_and_namespaces_panel_ids
(test_pcview.AdapterTests.test_pc_reuses_launcher_and_namespaces_panel_ids) ... ok
test_portal_input_clamps_and_releases_held_state
(test_pcview.AdapterTests.test_portal_input_clamps_and_releases_held_state) ... ok
test_refused_windows_focus_never_clicks_the_covering_app
(test_pcview.AdapterTests.test_refused_windows_focus_never_clicks_the_covering_app) ... ok
test_session_end_releases_native_input_only_once
(test_pcview.AdapterTests.test_session_end_releases_native_input_only_once) ... ok
test_stats_keep_capture_time_and_bound_records
(test_pcview.AdapterTests.test_stats_keep_capture_time_and_bound_records) ... ok
test_windows_release_and_negative_monitor_origin
(test_pcview.AdapterTests.test_windows_release_and_negative_monitor_origin) ... ok
test_windows_sources_follow_actual_wgc_capabilities
(test_pcview.AdapterTests.test_windows_sources_follow_actual_wgc_capabilities) ... ok
test_windows_uses_wgc_and_media_foundation
(test_pcview.AdapterTests.test_windows_uses_wgc_and_media_foundation) ... ok
test_never_offered_off_windows_even_when_turned_on
(test_pcview.GatingTests.test_never_offered_off_windows_even_when_turned_on) ... ok
test_off_by_default_starts_nothing (test_pcview.GatingTests.test_off_by_default_starts_nothing)
... ok
test_only_windows_gets_the_pc_host (test_pcview.GatingTests.test_only_windows_gets_the_pc_host)
... ok
test_server_enable_action (test_pcview.GatingTests.test_server_enable_action) ... ok
test_turning_off_stops_streams_and_the_helper
(test_pcview.GatingTests.test_turning_off_stops_streams_and_the_helper) ... ok
test_turning_on_checks_for_the_bundled_libraries
(test_pcview.GatingTests.test_turning_on_checks_for_the_bundled_libraries) ... ok
test_expiry_and_stop_before_redeem (test_pcview.GrantsTests.test_expiry_and_stop_before_redeem)
... ok
test_source_bound_retry_ack_and_stop
(test_pcview.GrantsTests.test_source_bound_retry_ack_and_stop) ... ok
test_agent_reports_its_host_and_encoder
(test_pcview.NativeTests.test_agent_reports_its_host_and_encoder) ... ok
test_auth_and_real_h264_stats (test_pcview.NativeTests.test_auth_and_real_h264_stats) ... skipped
'x264 is only in CI test bundles, not the installer'
test_bundled_pipewire_context_needs_no_system_modules
(test_pcview.NativeTests.test_bundled_pipewire_context_needs_no_system_modules) ... skipped
'Linux PipeWire client libraries'
test_installer_bundle_leaves_out_gpl_x264
(test_pcview.NativeTests.test_installer_bundle_leaves_out_gpl_x264) ... ok
test_last_raw_picture_survives_a_closed_gate
(test_pcview.NativeTests.test_last_raw_picture_survives_a_closed_gate) ... skipped 'x264 is only
in CI test bundles, not the installer'
test_native_property_probe_checks_the_instantiated_element
(test_pcview.NativeTests.test_native_property_probe_checks_the_instantiated_element) ... ok
test_card_says_experimental_and_what_is_unverified
(test_pcview.PageTests.test_card_says_experimental_and_what_is_unverified) ... ok
test_opt_in_is_remembered_with_the_client_preferences
(test_pcview.PageTests.test_opt_in_is_remembered_with_the_client_preferences) ... ok
test_tool_link_is_windows_only_and_marked_experimental
(test_pcview.PageTests.test_tool_link_is_windows_only_and_marked_experimental) ... ok
----------------------------------------------------------------------
Ran 33 tests in 1.684s
OK (skipped=4)
@@ -0,0 +1,74 @@
# Windows Wireless Adapter doctor verification, 24 October 2026
PC-only implementation and verification. No Frame was contacted, before or
after 13:00 UTC. No adapters or security settings were changed. This evidence
does not establish a physical Steam wireless adapter fix or RF interference.
## Windows VM
An existing local Windows 11 VM (build 26200), Python 3.12.14 from the installed
Frame Control bundle, ran the isolated suite from a task-specific Downloads
folder:
```powershell
& 'C:\Users\frame\AppData\Local\Programs\Frame Control\resources\python\python.exe' -m unittest discover -s tests -p test_wireless.py -v
```
Result: **15 tests, OK, one skipped** (Node absent). This includes the real
read-only PowerShell inventory and authenticated HTTP handler tests. The app's
connector was never started. Existing test SSH credentials were used only for
the Windows VM; no credentials were copied into the repository.
- [Exact test output](windows-wireless-tests-2026-10-24.txt).
- [Sanitized live inventory](windows-wireless-summary-2026-10-24.json): two
virtual-machine network devices, with PnP/presence/adapter/route sources
available. WLAN unavailable; channels/bands remained null. All PnP IDs,
device names and interface names were removed from this artifact.
- The guest clock was approximately seven hours ahead of the host. `observedAt`
is retained as returned by Windows, not used to establish test timing.
The first probe used a list of requested default prefixes. A missing IPv6
route caused PowerShell to throw away available IPv4 data. The final query
filters `Get-NetRoute` results instead, and the rerun above confirms routes
are available. No network configuration was changed to make the query work.
## Browser UI and local tests
Production `ui/index.html` ran against a loopback-only synthetic API fixture,
with no connector and no headset transport. Opening/revisiting the tool caused
no wireless query; an explicit click rendered two distinct Wi-Fi Direct PnP
instances and a 2.4 GHz PC interface while the headset state was offline.
Unknown virtual-adapter channels, non-present/disabled status, and unconfirmed
interference remained visible. Checked at 1280 and 640 CSS pixels without
horizontal overflow.
![Production browser UI with explicitly labelled synthetic Windows readings](windows-wireless-synthetic-2026-10-24.png)
The screenshot uses synthetic readings, not the VM's two Ethernet interfaces.
The yellow synthetic-data label was added by the browser fixture for this
capture; it is not product UI.
Local commands:
```sh
python3 -m unittest discover -s tests -p 'test_wireless.py' -v
node tests/wireless.cjs
python3 -m unittest discover -s tests -v
python3 -m py_compile ui/*.py
node --check /tmp/wireless-index-scripts.js
```
The targeted suite passed 15 tests with the live Windows-only test skipped;
the direct Node run passed all four UI tests. The complete server suite passed
1,423 tests with 16 skips before the additional missing-metadata test; a final
complete run and Windows CI results are recorded in the PR. The syntax check
used inline scripts extracted from the production HTML.
## What remains unverified
No physical Steam dongle, Razer headset, live Wi-Fi Direct session, channel
reading from real wireless hardware, dropout reproduction or recovery action.
The English netsh parser is fixture-tested; localised or denied readings remain
unknown. Windows CI validates query execution on hosted virtual hardware.
There is no adapter disable action because the available evidence cannot
reliably identify a safe extra instance.
@@ -0,0 +1,40 @@
{
"platform": "Windows-11-10.0.26200-SP0",
"python": "3.12.14",
"state": "partial",
"sources": {
"pnp": "ok",
"present": "ok",
"adapters": "ok",
"routes": "ok",
"wlan": "unavailable"
},
"observedAt": "2026-10-24T19:15:45.946191+00:00",
"deviceCount": 2,
"devicesWithoutIdentifiers": [
{
"present": true,
"pnpStatus": "OK",
"problemCode": 0,
"operational": 6,
"media": 0,
"defaultRoute": false,
"wifi": false,
"hardware": false,
"channel": null,
"band": null
},
{
"present": true,
"pnpStatus": "OK",
"problemCode": 0,
"operational": 1,
"media": 1,
"defaultRoute": true,
"wifi": false,
"hardware": true,
"channel": null,
"band": null
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

@@ -0,0 +1,24 @@
test_ambiguous_adapter_join_never_selects_the_first_match (test_wireless.Inventory.test_ambiguous_adapter_join_never_selects_the_first_match) ... ok
test_channels_require_exact_guid_and_band_is_never_guessed (test_wireless.Inventory.test_channels_require_exact_guid_and_band_is_never_guessed) ... ok
test_counts_unique_pnp_ids_not_rows_or_suffix_order (test_wireless.Inventory.test_counts_unique_pnp_ids_not_rows_or_suffix_order) ... ok
test_distinct_instances_presence_disabled_and_activity_are_separate (test_wireless.Inventory.test_distinct_instances_presence_disabled_and_activity_are_separate) ... ok
test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct (test_wireless.Inventory.test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct) ... ok
test_missing_interface_metadata_and_invalid_guids_stay_unknown (test_wireless.Inventory.test_missing_interface_metadata_and_invalid_guids_stay_unknown) ... ok
test_missing_sources_are_unknown_and_adapter_fallback_is_retained (test_wireless.Inventory.test_missing_sources_are_unknown_and_adapter_fallback_is_retained) ... ok
test_raw_ssid_mac_and_errors_are_not_returned (test_wireless.Inventory.test_raw_ssid_mac_and_errors_are_not_returned) ... ok
test_concurrent_requests_do_not_spawn_more_probes (test_wireless.Probe.test_concurrent_requests_do_not_spawn_more_probes) ... ok
test_failure_timeout_and_invalid_output_are_explicit_and_release_lock (test_wireless.Probe.test_failure_timeout_and_invalid_output_are_explicit_and_release_lock) ... ok
test_http_is_authenticated_pc_only_and_never_calls_ssh (test_wireless.Probe.test_http_is_authenticated_pc_only_and_never_calls_ssh) ... GET "GET /api/host/wireless HTTP/1.1" 403 -
GET "GET /api/host/wireless HTTP/1.1" 400 -
GET "GET /api/host/wireless HTTP/1.1" 200 -
ok
test_live_windows_inventory (test_wireless.Probe.test_live_windows_inventory) ... ok
test_non_windows_starts_nothing (test_wireless.Probe.test_non_windows_starts_nothing) ... ok
test_static_noninteractive_local_command_and_timeout (test_wireless.Probe.test_static_noninteractive_local_command_and_timeout) ... ok
test_ui_behavior (test_wireless.Probe.test_ui_behavior) ... skipped 'Node required'
----------------------------------------------------------------------
Ran 15 tests in 8.139s
OK (skipped=1)
Windows wireless smoke: partial {'pnp': 'ok', 'present': 'ok', 'adapters': 'ok', 'routes': 'ok', 'wlan': 'unavailable'} devices= 2
+39
View File
@@ -6,6 +6,45 @@ This is Frame Control code using Python, Steam and SteamVR already on the Frame.
![Family and comfort controls in the desktop app](img/comfort-desktop.png)
## Desktop low-battery alert without a session
In the desktop app, open **Tools → Family & comfort** and check **Alert on this
computer at 15% or less**. This separate option does not start a session, install
a worker or change the headset. It starts off whenever the page opens and turns
off when you switch headsets. The choice is only for the current open page.
It consumes the battery parser already used by normal status refreshes (also
shared by the read-only power sampler). It adds no SSH calls, timers, sensor
reads, third-party push or heat thresholds. Normal automatic status refreshes
pause while the page is hidden, so this is **not a background or safety monitor**.
Keep the app visible and connected. Use **Enable / test notifications** to check
system delivery; operating-system settings may block notifications.
Two fresh readings, at least 20 seconds and at most 90 seconds apart, must show
15% or less, driver state `Discharging`, and signed battery power below −0.5 W.
This avoids alerting on plugged-in idle readings, which can report discharging.
Missing, invalid, stale or interrupted readings break confirmation. Disconnect,
a failed status request, hiding the page or toggling the option also breaks
confirmation. Unknown readings never mean zero charge.
An alert appears in the page and requests a native desktop notification once
per low-charge episode. Two readings at 20% or more rearm it, and at least ten
minutes must pass between alerts (including across headset switches). A single
recovery spike cannot rearm it. Toggling off/on does not clear the episode latch.
Notification failure stays visible in the page and does not retry on every
refresh. While an active family session has battery alerts enabled, that session
handles battery alerts instead. Multiple app windows have independent state;
this does not coordinate notifications between computers or windows.
A read-only check on 2026-10-24, build **20261015.6141207**, received two samples
at 99%, `Not charging`, approximately −0.06 W and 24 °C. No low-battery condition
was induced, no notification was sent to the real headset, and no headset state
was changed. The sampler's local and remote smoke-lock checks allowed the run;
no lock was acquired or removed. Deterministic tests exercise low charge,
recovery, missing readings, idle drain, timing and notification denial. See
[recorded evidence](evidence/desktop-battery-comfort-2026-10-24.json). Native OS
notification display, worn use and macOS/Windows packaging were not verified.
## Sessions
Set a limit of 1–240 minutes, optional break and check-in intervals, then
+35
View File
@@ -15,6 +15,10 @@ depends on SSH working through the `frame` alias from `scripts/connect.sh`.
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
Screenshots and Steam clips have their own buttons on **Tools → Capture &
Live**, and **Settings → Copy to this computer** copies new ones here
automatically: see [recording.md](recording.md#copying-new-ones-automatically).
## Clipboard
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
@@ -36,3 +40,34 @@ desktop has to be running in the headset. It's text only, and pastes over about
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
headset.
### In Frame Control
**Tools → Type on the headset** (also on Home, and press `T` anywhere) has the
clipboard next to the keyboard and trackpad, both ways:
- **Send text** or **Send this computer's clipboard** puts text on the headset's
clipboard, as above.
- **Copy the headset's clipboard** brings it back: copy something in a desktop
app on the Frame, then press it, and paste on your computer. It calls
Klipper's `getClipboardContents` on the same D-Bus bus, and sends the text
back as base64, so accents, emoji and line breaks arrive unchanged. The
desktop app and the iPhone app put it on their own clipboard. With the server
in a browser, the server sets the clipboard of the computer it runs on:
`pbcopy` on macOS, PowerShell's `Set-Clipboard` (or `clip.exe`) on Windows,
`wl-copy`, `xclip` or `xsel` on Linux.
It's text only, and copying back takes up to 1 MB; a bigger clipboard is
refused with a message, so send big things as a file. An empty clipboard, or
one holding an image, says so. Neither the server nor its log ever records what
was on the clipboard.
**Verified on a Frame, 2026-10-24:** SteamOS 0.5.6, build
`20261015.6141207`. With a temporary nested desktop open, Frame Control sent
a 47-character test marker to Klipper, then **Copy the headset's clipboard**
read it through `getClipboardContents` and set a Linux PC's X11 clipboard.
Reading that PC clipboard returned the exact marker. The prior headset
clipboard was restored and the test desktop was closed. The PC side used a
temporary X display on lxso2; macOS and Windows clipboard backends, a user's
copy action in a headset app, and image/size error cases remain untested on a
real headset. The fake Frame (`tests/fakeframe`) covers the protocol cases.
+38 -5
View File
@@ -25,7 +25,7 @@ settings, reports), **Tools** (in the headset: your computer's windows, the medi
player and the panel switcher; then sending files and text, Flatpaks, remote and
power), **Devices** (your headsets and their addresses), and **Control** (the
searchable Control Center, metrics, supported settings and headset guidance).
Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
Keys 1–6 switch between them, and `T` opens Type on the headset. The gear in the header (key 7) opens **Settings**:
privacy and updates, library artwork and the assistant. The battery chip opens
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
build. Files can be dropped anywhere in the window. A
@@ -49,11 +49,32 @@ counts them while they run.
gamescope's flat layer instead.
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
save them to `~/Pictures/SteamFrame`.
- **Record** what the headset shows, with its sound, to an `.mp4` in
`~/Videos/SteamFrame`, and **Steam clips**: export clips and background
recordings from Steam's game recording at any length, without re-encoding.
See [recording.md](recording.md). Not tested on a headset yet.
- **Backup & recovery** (Tools): copy game saves, settings and Android app data
to `~/Documents/Frame Control Backups/<time>/` with a checksummed manifest,
restore chosen parts after a factory reset, and **Return to Game Mode** when
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
- **Wireless Adapter doctor** (Windows, Tools → This PC): explicitly read local
PnP instances, presence/link states and available channel/band readings;
explain possible Wi-Fi Direct duplicates and 2.4 GHz audio overlap. No headset
access, automatic changes or disable button. See
[Windows wireless diagnosis](windows-wireless-doctor.md).
- **Battery** with charging state: charge rate in watts, time to full or empty,
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
the desktop, Lepton and xrdp are running.
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
- **Force quit** a game that has stopped responding: Steam games, sideloaded
titles, non-Steam shortcuts and Android apps. **Running now** at the top of
Games lists what's running; each game's own **Force quit…** (on its library
tile, or in a sideloaded title's ⋯ menu) and the Control Center's **Force quit
a game** card do the same. Each asks first, because unsaved progress is lost.
Steam is asked to quit the game, then its processes get SIGTERM and SIGKILL;
see [steam-games.md](steam-games.md#force-quitting-a-running-game).
- **Get games**: every game you own with its Steam Frame rating (Verified,
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
with live progress. Search the Steam store with prices and Frame ratings; Buy
@@ -80,8 +101,14 @@ counts them while they run.
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
Runtime picked from the program's header), listed under **Sideloaded titles**
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
Frame clipboard.
with Launch and Remove; see [sideloading.md](sideloading.md).
- **Type on the headset** (on Home, in Tools, in the Control Center, or press
`T`): use your computer's keyboard and mouse, or a phone's touchscreen, in the
Frame's apps through KDE Connect, and move clipboard text both ways. Send
typed text or your computer's clipboard to the headset, or copy the headset's
clipboard back: copy in a desktop app on the Frame, then **Copy the headset's
clipboard**. The clipboard needs the desktop panel open in the headset; it's
text only, up to 1 MB. See [file-transfer.md](file-transfer.md#clipboard).
- **Mac in the headset** (macOS): show any Mac window, or a whole screen, as
its own panel in the headset. Place it with the SteamVR dashboard, click and
scroll with the laser, and type on the Mac. Streams hardware H.264 through
@@ -127,6 +154,12 @@ GPU time is render time, not GPU utilisation. The temperature is the hottest
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
not a guessed universal temperature threshold.
**Force quit a game** lists what's running only when you choose **Check what's
running**, and says so when nothing is. Each **Force quit…** names the game and
warns that unsaved progress will be lost before anything is sent; it uses the
selected headset, refuses a second quit while one is running, and drops the
list when you switch headsets.
**Audio** controls the default output sink through the existing `/api/volume`
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
and **Unmute** send explicit mute states. The UI captures the selected headset,
@@ -220,8 +253,8 @@ Google publishes no arm64 Linux platform-tools. Set Up Connection runs
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
and others). The log is at
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
`xclip` (X11).
instead of the app, sending the clipboard, or copying the headset's to this
computer, needs `wl-clipboard` (Wayland), `xclip` or `xsel` (X11).
## Building
+179
View File
@@ -0,0 +1,179 @@
# Frame doctor
Run `scripts/frame-doctor.sh` from a checkout on macOS or Linux to diagnose
first-time setup, pairing and common Frame failures without applying repairs.
It needs Bash, Python 3 and OpenSSH on the computer, and Python 3 on the Frame.
Windows users can run `python scripts/frame_doctor.py` with Python and OpenSSH
installed; the desktop app does not bundle this command.
```sh
scripts/frame-doctor.sh
scripts/frame-doctor.sh --alias bedroom --lan 192.168.1.40
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
```
Use only addresses belonging to your intended headset. The command tries the
existing `frame` SSH alias first (Tailscale if setup configured it that way),
then an optional `--lan` address, then USB-C at `10.86.200.233`. SteamOS 0.5.6
stopped publishing mDNS names, so use a known IP for `--lan` if `.local` no longer
resolves. `--alias`, `--lan` and `--usb`
override those defaults. It reports the successful path without printing its
address. It neither discovers other devices nor enables Tailscale.
The existing alias supplies the user, port, keys and known-hosts file. LAN and
USB overrides preserve its `HostKeyAlias`, or its configured hostname if no
alias was set. A first connection therefore needs trusted setup first. The
doctor never accepts a new host key, deletes a saved key, generates keys or
sends a pairing request. It uses your trusted OpenSSH configuration; OpenSSH
can evaluate `Match exec` conditions in that configuration. Proxy commands,
jumps, local commands, forwarding and connection sharing are disabled for
these direct diagnostic connections.
## Reading the result
Each of the eleven runbook sections has one line: `OK`, `WARN`, `BROKEN` or
`UNKNOWN`. `OK` means only that the stated check passed, not that the whole
feature works. Optional features that are off may produce a warning.
| Section | Read-only evidence and limits |
|---|---|
| 1. Boot and crash history | Previous-boot kernel-oops signatures; one day of coredump history. An oops alone does not prove an unclean reset. |
| 2. Wi-Fi | NetworkManager's wlan0 state, without SSIDs or connection profiles. |
| 3. Displays and SteamVR | DSI timeout signatures in the recent kernel journal; no display, compositor or game is launched. |
| 4. Steam and storage | Free space in home and `devkit-game`, including separate mounts; missing Steam install marker. No CRC scan or restart. |
| 5. Idle sleep and keep-awake | Existing keep-awake unit state; never takes ownership or changes idle timers. |
| 6. Remote wake | WoWLAN state, without arming it or testing suspend. Wake with the power button. |
| 7. Lepton | Recent port-clash, binder and data-clearing signatures; no Android/container commands. |
| 8. Chromium XR | Crash history; no browser profile or environment inspection. |
| 9. KDE Connect | Process presence; absence is normal when sharing is off. |
| 10. Streaming and capture | ffmpeg crash history; no capture or encoder is started. |
| 11. Panels | Always `UNKNOWN`: placement requires the user's visual confirmation. |
Journal samples are capped at 2,000 entries; current-boot samples cover the
last ten minutes. This can miss older or high-volume failures. Empty,
inaccessible or permission-limited journals are `UNKNOWN`, never zero errors.
Commands time out individually, and each SSH probe is bounded to 90 seconds.
Exit codes: **0** if all reported checks are OK; **1** for warnings, broken or
unknown checks after connecting; **2** if no authenticated SSH path exists or
local SSH setup is unavailable. Panel verification currently means a connected
run normally exits 1. Automation should read the labelled checks rather than
treating a warning as proof the headset is unusable.
## Read-only repair plan and before/after reports
```sh
scripts/frame-doctor.sh --plan --save-report before.json
# Review the plan with the headset user. Any manual action is separate.
scripts/frame-doctor.sh --plan --compare-before before.json --save-report after.json
```
`--plan` prints advice; it never executes repair commands or prompts for approval.
There is no automatic repair mode. Advice about Wi-Fi, display timeouts, low
storage, a missing Steam install marker or keep-awake requires the exact
recognized section, severity and evidence. Other warnings request local review;
unknown evidence never justifies a repair. A signature or missing marker alone
does not establish a cause. Restart risk, rising counters or unknown counters
block all section-specific plans and direct you to the recovery runbook.
Nothing changes passwords, headset security, pairing, services or power settings.
Reports are optional **local** JSON files containing only fixed diagnostic
summaries, a format version and an opaque SSH-target configuration fingerprint.
The fingerprint binds comparisons to the same alias, identity/routing
configuration and fallback addresses; it does not prove physical headset
identity or that the headset was not replaced behind that configuration. Keep
using trusted SSH host keys. Report creation is exclusive (existing files and
symlinks are refused), with owner-only permissions on POSIX. Protect reports
using your local account permissions on Windows. Choose a fresh output filename
for each run. Doctor never writes a report on the headset.
`--compare-before` validates a bounded baseline before connecting and refuses
changed target configuration. It prints each section's before/after status and
prints both fixed evidence messages when they differ, even if the status
stayed the same. Changes are observations, not proof a repair worked or caused them;
recent journal windows can simply age out. There is no intervening repair by
doctor, freshness guarantee, or authenticated report signature. If either run
skipped deeper checks, comparison is blocked and no improvement is claimed.
Unreachable or invalid probes produce no new baseline or comparison. Malformed
baseline/target mismatch/report-write errors exit **2**; otherwise normal
diagnostic exit codes apply. No saved report is needed for ordinary diagnostics.
## First-time setup and pairing
- **No SSH response:** wake the headset with its power button. Check the cable
or Steam Settings → Internet, then enable Developer Mode. Sleep and network
failure can look alike; a timeout cannot distinguish them.
- **Key rejected:** open **Set Up Connection** in Frame Control. In the headset,
open Steam Settings → Developer → **Pair new host**, then approve setup's
request. The doctor only checks whether devkit port 32000 responds; that
does not prove pairing mode is open or the service is healthy.
- **Unknown or changed identity:** verify the intended headset through setup
or Devices → **Check its key**. Do not delete `known_hosts` to bypass this.
- **No devkit response:** check Developer Mode and the address. See
[SSH setup](ssh.md) for the manual fallback. The doctor cannot turn SSH on.
No password is requested. Enter any password required by the separate setup
flow only in its local terminal; never paste it into an issue or report.
## Restart safety and recovery
Before deeper checks, and again afterwards, the doctor samples Steam and
SteamVR `NRestarts` and health-check tracker sizes two seconds apart. Rising
counters stop diagnostics immediately. Nonempty health-check trackers also stop
deeper checks even when stable, since a short sample could miss a slower loop.
Missing counters or counters that reset stop deeper checks as `UNKNOWN`. Stable
counters with empty trackers still cannot rule out a slower restart loop.
The [historical runbook from PR #33](https://github.com/saphid/frame-control/blob/cce3d940302054ab5246c92d740afaefc4e58d38/docs/frame-doctor.md)
is the source for these section numbers and observed signatures (September
2026 builds). Its section 4 describes freezing and verifying health-check
trackers before prolonged recovery during a live loop. That changes headset
state, so this read-only implementation stops and directs you there instead.
The optional plan and baseline comparison are a guarded next step for issue
#34; automatic fixes and full recovery automation remain unimplemented. Review
build-specific recovery instructions with the person using the headset; a repair can remove data.
Never reload `ath12k`, suspend remotely, kill gamescope to diagnose a loop,
reset or force power off during extraction/repair, or write passwords to disk.
The doctor never restarts services, signals processes, repairs Steam, changes
WoWLAN/standby settings, takes keep-awake ownership, installs anything or writes
a remote report. Only `--save-report` creates a local file. Nothing needs
restoring on the headset after its checks. Manual/device tests must
follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test).
## Privacy and verification
Only fixed diagnostic summaries leave the remote probe. Raw journals, process
arguments, environment variables, SSH errors, account IDs, network addresses,
SSIDs, keys and tokens are not printed. No telemetry or HTTP request is sent;
the only pairing-service probe is a TCP connection. SSH produces normal access
logs. Review summaries before sharing them; feature availability can still be
personal information. Redirecting stdout or using `--save-report` saves a report
only if you choose it.
Treat the target fingerprint as private metadata; it is a hash, not encryption.
Unit tests use mocked boundaries. `scripts/e2e.sh test_doctor` covers asleep,
disk-full, SSH/devkit-off and missing-key faults against the fake Frame. Its
stable restart counters are explicitly simulated, not proof about SteamOS.
On 2026-10-24 the script ran read-only against a real Frame, SteamOS build
`20261015.6141207`. It verified the configured SSH alias and key login,
then reported `WARN Restart safety`: nonempty health-check trackers. It
correctly skipped all eleven deeper sections and exited 1. No raw logs,
addresses or keys were printed, and no repair or restart was attempted.
This verifies the safety stop on that device; the deeper checks are not
claimed as headset-tested.
Follow-up verification on 2026-10-24: `--plan --save-report` and then
`--plan --compare-before` ran read-only on the same real Frame. Both reported
nonempty health-check trackers, skipped deeper checks and exited 1. The plan
was blocked and the comparison reported `risk -> risk` without claiming
improvement. No install, launch, stop or repair was performed; this validates
the guarded report path, not the deeper checks or any manual repair.
The follow-up's 17 focused offline tests passed. All five doctor fake-Frame
tests passed, including a disk-full baseline followed by fault removal and a
fresh comparison. The final evidence-output change also passed that comparison
test again. Because zsh is unavailable on this host, the repository's images
were built and its Compose harness run directly under the isolated project
`frame-doctor-plan-34`; its containers and volumes were removed afterwards.
These storage changes were fake fault controls, not doctor repairs.
+91
View File
@@ -0,0 +1,91 @@
# Game storage
Open **Tools → Game storage** for a read-only snapshot of the headset's user
filesystem and Steam libraries. Refresh after an installation, download or move.
The page never moves, deletes or modifies content, starts Steam, requests root,
or recursively scans game files. It works through the existing SSH connection.
## What the numbers mean
- **Total, free and used** come from `statvfs` on the headset user's home and
each library path. Free is space available to that user; reserved blocks mean
used + free can be less than total. These are filesystem sizes, not advertised
drive capacity, and do not include separate system partitions.
- Libraries come from `~/.local/share/Steam/steamapps/libraryfolders.vdf` plus
the default Steam library. Modern nested and older numbered-path records are
accepted; canonical duplicate paths are shown once. No drive is mounted.
- Two paths can share one filesystem. Their capacity must **not** be added.
An unmounted drive whose mount directory still exists can report the parent
filesystem's capacity: the page labels this as the filesystem containing the
path, rather than claiming the removable drive is connected.
- Game sizes are Steam's `SizeOnDisk` records in `appmanifest_<appid>.acf`, sorted
largest first, with unknown sizes last. A missing, negative or invalid size
is **Unknown**, while an explicit zero remains zero. Records may be stale,
including during downloads or moves; they do not measure allocated blocks.
- Games/apps and runtimes/tools have separate subtotals of **known recorded**
sizes. Runtime classification uses names (Proton, Steam Linux Runtime,
Steamworks Common, FEX and Lepton Development), so it is only a convenience.
Additional apps or runtimes may fall in the game/app group.
- Saves, Proton prefixes, shaders, workshops, downloads, Android apps,
sideloaded games, media and OS files are **unmeasured other usage**. The page
does not subtract manifest totals from filesystem usage to invent a size for
them. Recorded content is not an exhaustive storage breakdown.
Permission denied, missing/disconnected paths, unavailable filesystem reads,
invalid metadata and partial inventories are reported separately. A failed read
is never proof of an empty library. If library discovery fails, other libraries
may be missing from the view. Refresh clears the old results immediately; a
headset switch clears the snapshot and ignores late replies from the old device.
## Moving a game safely
Use Steam's own **Settings → Storage** on the headset (or in desktop Steam):
choose the source drive, select the games and choose **Move**, then pick a
Steam-supported destination. A second Steam library must already be available.
Steam may withhold Move or a destination when it cannot perform the operation.
Let Steam finish, follow Steam's errors/recovery prompts, then Refresh here.
Do not manually delete files to make room or interrupt an in-progress move.
[Valve's moving guide](https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move)
was read in a browser on 2026-10-24 and documents this UI. This is an instructional
handoff: the page does not send a URI or silently open a UI on the headset.
The guide's separate Windows instructions for moving the entire Steam client
are outside this feature's scope.
### Why Frame Control does not automate moving
The repository's Valve devkit tools register and launch developer titles; no
supported game-move operation is provided there. The existing Steam CEF bridge
uses internal client APIs, which are not a verified supported contract for a
move transaction. [Valve's public ISteamApps reference](https://partner.steamgames.com/doc/api/ISteamApps) provides install-directory
and installed-depot queries but no documented move transaction with completion,
cancellation, rollback and error guarantees. The public support guide describes
the user-operated Storage UI, rather than such an automation interface.
Consequently this feature adds no move endpoint or guessed internal Steam call,
manifest rewrite, copy/delete fallback, or custom rollback. Automating a move
requires a supported interface and verified behaviour for full disks, disconnects,
running games, downloads, cancellation and Steam restart first.
## Verification and limits
`tests/test_storage.py` runs the actual probe against temporary Steam metadata
and filesystem/error fixtures. It covers multiple libraries, duplicate paths,
zero/unknown/invalid sizes, corrupt manifests, unreadable directories and files,
missing discovery/drives, reserved capacity and read-only file preservation.
A loopback HTTP test executes the real guarded endpoint with SSH mocked.
`tests/storage.cjs` runs the production renderer, navigation and refresh handlers
with DOM/transport boundaries faked, including escaping, offline errors,
out-of-order refreshes and headset switches. It runs through unittest discovery.
The production storage component was rendered with synthetic data at 390px and
900px widths; both fit without horizontal overflow. This checks component
layout, not the full connected application or headset browser.
On 2026-10-24 the uninstalled storage parser was piped read-only over SSH
to a real Frame running SteamOS build `20261015.6141207`. It reported
710.4 GiB free on the user filesystem, one available Steam library, 29
readable app manifests, zero unknown sizes and zero parse/permission issues.
No game names or raw metadata were printed in the test report. The
connected UI, removable media, Steam's Move UI and an actual game move were
not tested. UI and error-state fixtures remain synthetic.
+1
View File
@@ -96,5 +96,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
- Remote access from anywhere: [tailscale.md](tailscale.md)
- Floating windows in space: [panels.md](panels.md)
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
- Backing up saves and settings, restoring after a reset, leaving Desktop Mode: [backup-and-recovery.md](backup-and-recovery.md)
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
- What's still unverified: [open-questions.md](open-questions.md)
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+3 -2
View File
@@ -102,8 +102,9 @@ app installed opens its App Store page.
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
Sending text to the Frame's clipboard needs the desktop panel open in the
headset, as on the desktop app.
Sending text to the Frame's clipboard, or copying the Frame's to the phone
(**Type on the headset → Copy the headset's clipboard**), needs the desktop
panel open in the headset, as on the desktop app.
Not yet exercised: Android display changes through podman (no Android app was
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
+118
View File
@@ -0,0 +1,118 @@
# Linux Wireless Adapter doctor
![Linux PC doctor before any checks run, with explicit hardware selection and read-only limits](img/linux-wireless-doctor.png)
On the **Linux PC**, open **Settings → Linux Wireless Adapter doctor**, or
**Devices & connection → Linux Wireless Adapter doctor**. It works without a
connected headset. Choose **Read PC device inventory**, select the intended
USB device or wireless interface, then **Check selected device**. Nothing runs
until requested. Re-read the inventory after reconnecting a device.
From a source checkout, the same collector is available without starting the
app or its headset connection workers:
```sh
python3 ui/frame_wireless_doctor.py
python3 ui/frame_wireless_doctor.py --device net:wlan0
python3 ui/frame_wireless_doctor.py --device usb:1-2 --json > adapter-report.json
```
Use a selector actually listed on your PC. No device is selected automatically.
All USB devices are included, including hubs and devices without a network
interface. A product label, observed VID/PID, or a single available interface
**does not identify Valve's adapter**. There is no trusted adapter ID table in
this feature. Compare inventory before and after reconnecting the intended
adapter when safe. For a USB device exposing multiple wireless interfaces,
select the specific interface for radio/manager evidence.
## Evidence and limits
- **Presence and binding:** `/sys/bus/usb/devices` and `/sys/class/net` correlate
USB ancestry, wireless interfaces, driver links and module links. An absent
interface does not establish which driver should be installed. A missing
module link may mean a built-in driver. A bound driver does not prove Steam
streaming support.
- **Permissions and udev:** `udevadm info --query=property` supplies a small
allowlist of driver, ID and tag fields. If available, the report includes USB
node mode, owner/group IDs and the current user's read/write access. Direct
USB access is not necessarily required by kernel networking; a restrictive
node alone is not diagnosed as the cause. No udev rules are installed or run.
- **Radio blocks:** read-only WLAN rfkill sysfs entries are matched to the
selected interface's PHY name. An unrelated block cannot establish that
this adapter is blocked. Unavailable or unmatched entries remain unknown.
- **Channel/band:** `iw dev IFACE info` provides the current channel and
frequency where reported; `iw phy PHY info` supplies enabled frequencies.
An idle interface may have no current channel. These observations do not
establish the channel used by a Steam streaming session, supported regions,
or the correct regulatory setting.
- **Network managers:** visible `/proc/*/comm` process names show
NetworkManager, iwd and wpa_supplicant. `nmcli` reads only the selected
interface's general state, managed flag and driver. NetworkManager can use
iwd or wpa_supplicant as its backend, so coexistence is not itself a conflict.
If both backends are visible, the report suggests checking who owns this
exact interface. It does not claim a confirmed conflict or stop services.
Restricted process visibility and unverified backend ownership are explicit.
Every command uses fixed read-only arguments, no shell, closed stdin and a
three-second timeout. Optional tools are not installed automatically. Missing
tools, denied reads, disappearing devices, and restricted VM/container hardware
visibility remain unknown. A successful report is a snapshot, not a hardware
health certificate. CLI exit 0 means collection is supported, not that the
adapter works; unsupported platforms/headset-local mode return 2, as does an
invalid/stale selector. JSON has schema version 1.
The doctor never uses SSH, scans nearby Wi-Fi networks, opens a connection to
the Frame, escalates privileges, loads drivers, changes security settings,
changes channels, unblocks radios, changes network settings, or restarts
services. Run it as your ordinary user, never with sudo. The app hides the
entry on macOS, Windows and phone sessions; its API refuses collection in
headset-local (`FRAME_LOCAL=1`) mode. The source CLI is intended only for the PC.
Reports stay local unless you explicitly save, copy or share them. Exported
fields include observed USB IDs/product labels, interface names, driver names
and permission metadata. USB serials, Wi-Fi names, MAC/IP address fields,
credentials, process arguments and raw command output are not exported. Review
the report before posting it: hardware labels and local paths can identify
parts of your setup.
## Motivation and shipped comparison
[The 23 October 2026 complaint](https://x.com/ExpressGinga81/status/2113457683143741720)
says the Wi-Fi dongle does not work on Ubuntu/Kubuntu 26.04 while SteamVR works
under Wayland. It supplies **no adapter VID/PID, driver, logs or established
cause**. This feature helps gather that missing evidence; it does not claim to
fix that complaint or certify Ubuntu/Kubuntu 26.04 support.
Shipped **v0.4.18** has local network/gateway discovery in `frame_network.py`
and headset connection management, but no local USB/driver/rfkill adapter
report or adapter diagnostic UI. The existing `frame-doctor.sh` diagnoses the
headset through SSH and is a different tool. This doctor never calls it.
General SteamOS 3.9.5 / Frame OS 0.5.6 compatibility and OS backend transition
notes are covered separately in
[`docs/steamos-2026-10-compat.md`](https://github.com/saphid/frame-control/blob/9c7110c1f48d160b0fb3c3b9f658d4937bd2032d/docs/steamos-2026-10-compat.md)
([PR #164](https://github.com/saphid/frame-control/pull/164)). Existing PC
route/SSID diagnostics use NetworkManager's `nmcli` with an `iwgetid` SSID
fallback, independently of whether NetworkManager uses wpa_supplicant (SteamOS
3.9.5 new installs) or iwd (upgrades). This doctor reads actual PC evidence and
does not infer a conflict, hardware identity or fix from those OS defaults.
There is no adapter-specific device observation in that compatibility work.
## Verification
```sh
python3 -m unittest discover -s tests -p test_wireless_doctor.py -v
python3 -m unittest discover -s tests
```
Tests use an explicitly synthetic USB `1234:5678`, sysfs trees, command replies
and loopback HTTP. They cover an unbound USB device, module binding, multiple
interfaces, PHY-matched and unrelated rfkill entries, channel parsing, manager
coexistence, missing/denied commands, stale/injected selectors, export field
filtering, authentication and refusal to probe in headset-local mode. The
HTTP tests instantiate the handler without starting headset workers.
Linux host collection was exercised on kernel `6.8.0-139-generic`: an existing
PC interface was visible with its `iwlwifi` binding and unblocked PHY; `iw` and
`nmcli` were absent and reported as unavailable. This is **not a physical Steam
Frame Wireless Adapter test**, an Ubuntu/Kubuntu 26.04 test, or a headset test.
No Frame access is part of this work.
+2 -1
View File
@@ -4,7 +4,8 @@ Frame Control can show any Mac window, or a whole Mac screen, as its own panel
in the Steam Frame. You place each panel anywhere in the room with the SteamVR
dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
only).
only). On Windows there's an experimental port,
[PC in the headset](pc-in-headset.md).
The confidence labels are the same as in [ssh.md](ssh.md).
+18 -1
View File
@@ -190,7 +190,24 @@ HTTP focus is not evidence of worn-headset laser input. Follow the
## Saved spatial layouts: blocked on the current panel route
**Verified 2026-09-28**, same build, using a temporary xterm panel with
**Rechecked 2026-10-24**, SteamOS 0.5.6, BUILD_ID `20261015.6141207`,
SteamVR 2.18.2: a read-only probe of the existing Gamescope and Steam main
panels still reports type 5 (`DashboardTab`), and both the absolute and
tracked-device-relative getters return 18 (`WrongTransformType`). Width and
curvature read successfully. **Some pose readback is available:**
`GetTransformForOverlayCoordinates` at the panel center succeeds in standing
space; seated and raw origins return 23 (`RequestFailed`). Both panels and the
dashboard were hidden. That matrix snapshot does not identify docking, prove
visible placement, or establish a restore operation. The shipped dashboard
stores dock location and relative transforms internally; no supported external
snapshot/restore pair was established. No setters or docking commands were run
in this recheck. See the [exact probe, results, cleanup and remaining
gaps](evidence/panel-layout-2026-10-24.md). Saved layouts remain unavailable.
### Earlier mutation evidence (2026-09-28)
**Verified 2026-09-28**, SteamOS 0.4.1, BUILD_ID `20260925.6191901`,
SteamVR 2.18.1, using a temporary xterm panel with
`STEAM_GAME=2000999031` and `FnTable:IVROverlay_028` from
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`:
+165
View File
@@ -0,0 +1,165 @@
# PC in the headset (Experimental)
For Steam's own Linux Wayland desktop-sharing flow, see the separate
[Steam desktop sharing helper](steam-pipewire.md). It does not enable this
feature on Linux.
**Tools → PC in the headset**, on Windows only. It's the Windows version of
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
becomes its own SteamVR panel in the Frame. Individual windows are available only
with streaming libraries built with Windows Graphics Capture (WGC); the shipped
MSYS2 libraries disable WGC, so this build explains that limitation and offers
whole screens. Place the panel with the dashboard's Float
in World, Move and Size, point and click with the laser, scroll with the
thumbstick, and type on the PC's keyboard.
**It's experimental.** A Windows 11 VM has captured its real desktop with
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
hardware encoding and physical laser input remain unverified. See
[the dated evidence](evidence/windows-vm-2026-10-24.md) for results and limits.
Reports are welcome through the
[feedback form](https://frame-control.pages.dev/feedback/).
## Turning it on
It's off by default. Until you tick **Turn on PC in the headset** on the card,
Frame Control starts no helper, loads none of the streaming libraries and lists
no windows. The choice is remembered on this computer. Turning it off ends
every stream, closes the viewers in the headset and stops the helper.
The card and its Tools link only appear in the Windows app. On macOS the card
is Mac in the headset, as before; on Linux and the iPhone app there's no card.
## How it works
The ported pieces are from [PR #38](https://github.com/saphid/frame-control/pull/38).
Frame Control owns the host helper, input, authentication, streaming protocol,
panel launcher and adaptation. It doesn't need Sunshine, OBS or any other
streaming app. GStreamer is bundled as ordinary libraries.
| | Windows |
|---|---|
| Capture | GStreamer's `d3d11screencapturesrc`: Desktop Duplication for whole screens in the shipped build; Windows.Graphics.Capture by window (HWND) or screen (HMONITOR) when that implementation is present |
| Encoding | Media Foundation's H.264 encoder (`mfh264enc`), low latency, no B-frames. Whether it uses the GPU depends on the PC; **not verified** |
| Input | `SendInput`, mapped through the source's bounds, with per-monitor DPI awareness |
API references:
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html).
It needs Windows 10 or 11 with Desktop Duplication and a Media Foundation
H.264 encoder (Windows "N" editions need the Media Feature Pack). WGC-enabled
builds can also capture individual windows. Clicks and
keys can't reach administrator apps or the sign-in screen, which an ordinary
process can't drive. If Windows won't bring the chosen window to the front,
input is refused rather than sent to the app covering it: bring the window
forward, then Stop and Show again. Minimised or closed windows stop sending
pictures. Protected video isn't supported. Mac in the headset's "own display
per window" is Mac-only.
The pieces it shares with Mac in the headset:
- `ui/frame_macview.py` owns the SSH tunnel, reconnects, quality presets and
panel launch. `ui/frame_pcview.py` picks the Windows helper
(`ui/frame_pc_agent.py`, `ui/frame_pc_capture.py`, `desktop/capture.c`) and
holds the on/off switch. `/api/macview` is the endpoint for both.
- `ui/mac-view.html` is the one viewer in the headset. Its frame header, H.264
and JPEG payloads, reconnect handshake, clock sync, timing reports and input
messages are unchanged.
- `desktop/controller.c` is a C port of the Mac helper's Swift rate controller,
for the Windows helper. The Mac keeps its Swift original;
`tests/test_pc_controller.py` checks the port against a frozen 600-state
trace from it. Capture is gated before encoding, so encoded frames are never
dropped, and an idle window's last picture still goes out once the link
clears.
- Stream records use the Mac helper's JSON schema (`ui/frame_stream_stats.py`),
so the benchmark's analysis is shared.
## Packaging
The Windows installer carries `resources/desktop/bundle`: `pc-host.dll` (our
adapter and the controller), the GStreamer plugins it uses (`coreelements`,
`videotestsrc`, `videoconvertscale`, `app`, `jpeg`, `videoparsersbad`,
`d3d11`, `mediafoundation`) and the DLLs they depend on, all from MSYS2's
UCRT64 packages. `libraries.json` records the GStreamer version, the plugins,
each copied DLL and the exact package versions; licence texts are in
`licenses/`.
MSYS2's `gst-plugins-bad` build disables `d3d11-wgc`. The presence of
`d3d11screencapturesrc` alone does not prove WGC support: its `capture-api`,
`window-handle` and `show-border` properties are conditional. The helper queries
those properties on the instantiated element, advertises only supported
sources, and omits all three properties for the DXGI whole-screen pipeline.
It never substitutes a screen crop for an unavailable window capture.
The x264 encoder is **not** in the installer. It's GPL, and Windows encodes
with Media Foundation, so only CI's test builds include it
(`desktop/build.py --with-x264`). The macOS and Linux installers are unchanged.
From a source checkout on Windows, build the bundle from an MSYS2 UCRT64 shell
with the packages listed in `.github/workflows/pc-host.yml`:
```sh
python desktop/build.py # what the installer ships
python desktop/build.py --with-x264 # for the native tests
```
A missing bundle is reported on the card; nothing is downloaded on first use.
The Linux adapter from #38 (desktop portal, PipeWire, VA-API) is in the tree,
because it shares `capture.c` and the controller, and CI still builds and tests
it. The app never offers it and the Linux installers don't include it.
The [2026-10-24 lxso2 feasibility check](evidence/linux-pc-host-lxso2-2026-10-24.md)
found no running or activatable desktop portal, no PipeWire socket and missing
native build dependencies. Linux support remains disabled. The evidence records
the build failure and prerequisites for a screen-only, view-only x264 slice;
Linux capture, input and window modes have not been verified there.
## Measuring
The Mac benchmark accepts the PC host:
```sh
python scripts/macview-bench.py run --pc --scenario test --label pc-test
python scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
```
`capture` needs a window or screen id from the helper's `/windows` or
`/displays`. CPU sampling isn't available on PC hosts. See
[Mac in the headset](mac-in-headset.md) for the targets and shaping options.
## What's verified
- **Verified in CI, this PR:** on `windows-latest`, the native adapter builds
and a real x264 synthetic stream passes the ticket, reconnect, timing and
input-echo tests through the helper. The Windows installer builds with the
bundle, and its bundled Python loads the bundle, starts the helper and
confirms x264 isn't in it. Unit tests check the Windows-only, off-by-default
gating, the card and the Tools link on macOS, Windows and Linux.
- **Verified, #38, 2026-09-28, real Frame on SteamOS BUILD_ID 20260925.6191901:**
the PC helper (ARM64, on the Frame itself, x264 test pattern) streamed
through a Mac SSH relay into the shared viewer: 400 frames at 39.3 fps,
content latency p50/p95 15.1/28.6 ms, synthetic input p50 76.6 ms, at
revision `cd20243`. The frame-rate and late-frame targets failed. This
proves the helper and the viewer work together; it isn't Windows capture or
a real PC link.
[Raw result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json),
[earlier probe](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
- **Verified, #38, on a Mac:** the C controller matched all 600 states of the
Swift controller's congestion and recovery trace.
- **Verified, 2026-10-24:** Windows 11 VM build 26200.8037 on lxso2, using
the v0.4.10 installer with this fix, captured its actual desktop and streamed
H.264 to Frame BUILD_ID `20261015.6141207`: 219 sent frame records, 215 decoded
and drawn before the snapshot. The own-panel screenshot shows the Windows
desktop. This short, mostly idle run does not establish performance targets.
[Evidence, raw traces and cleanup](evidence/windows-vm-2026-10-24.md).
- **Not verified:** Windows.Graphics.Capture, physical-PC capture, hardware
Media Foundation encoding, encoder restarts when the bitrate
changes, mixed-DPI and multi-monitor input alignment, window resize and
minimise, non-US keyboard layouts, physical laser input, and how it performs
in a worn headset. No numbers are claimed for these. Not tested with a
worn headset. See the dated VM evidence for the separate automated checks.
- **Not reviewed independently.** #38 tried an independent review that never
completed.
+224
View File
@@ -0,0 +1,224 @@
# Read-only power and heat measurements
Issue [#52](https://github.com/saphid/frame-control/issues/52) starts with
measurement. `scripts/frame-power-sample.sh` is a bounded, on-demand sampler
over the existing `ssh frame` alias. It needs Python 3 on both computers and
an already paired, awake, reachable Frame. It uses strict SSH host-key checking
and batch mode; it never pairs, prompts for a password or changes SSH security.
```sh
# Twelve samples, five seconds apart (about 55 seconds), JSON report:
scripts/frame-power-sample.sh > power.json
# Five-minute observation: samples at 0, 5, ... 300 seconds:
scripts/frame-power-sample.sh --count 61 --interval 5 > baseline.json
# Long-form CSV samples; JSON summary goes separately to stderr:
scripts/frame-power-sample.sh --count 12 --interval 5 --csv > power.csv 2> summary.json
```
Count is limited to 1–720; interval to finite values from 1–60 seconds. These
are target start-to-start intervals, not guarantees: read latency can delay a
sample. Wall-clock `time` and monotonic `elapsedSeconds` record actual timing.
The first sample has no CPU utilization: two valid `/proc/stat` observations
are required. CPU utilization then covers the interval between observations,
averaged across the machine, rather than the utilization of one process.
## Start, stop and open an accessible report
From a source checkout with Python 3 and your already trusted `ssh frame`
connection, start a local observation:
```sh
scripts/frame-power-sample.sh --count 12 --interval 5 --html power-report.html
```
Progress is printed on your computer as each sample arrives. Wait for completion,
or press **Ctrl+C once** to stop local collection and save the samples already
received. Open `power-report.html` in your browser using its Open File command.
The file is self-contained and works offline: no server, scripts, external
resources, account or upload. It includes a table with named metrics, source
paths, units, minimum/mean/maximum/last values, and available/unknown counts.
Keyboard users can focus the table region to scroll through its columns.
Charging states and entirely unavailable sensor families remain explicit.
Use **Download JSON samples and summary** or **Download CSV samples** to save
raw measurements. The HTML also embeds those measurements; review it before
sharing. No automatic browser launch occurs.
HTML sessions are limited to `count × interval <= 600` seconds, with a 30-second
transport allowance and at most twelve seconds of local child/pipe cleanup. The default
12 samples at five-second intervals take about 55 seconds. Existing output files
are never overwritten; choose a new filename for each run. `--html` cannot be
combined with `--csv`. The desktop flow below uses the same sampler and renderer.
Stop, timeout, SSH failure or a smoke-test lock marks the report incomplete;
earlier received samples remain downloadable. Exit status is 75 for incomplete
reports, 0 for completion. A connection that cannot start may instead return 1
without an HTML file. Empty reports clearly show zero samples, not a successful
measurement. If saving the file fails, received JSON is printed on stdout as a
recovery path. Do not treat a partial report as a full baseline.
Stopping terminates only this command's local SSH child. It sends no remote
process-control command. The bounded remote Python reader exits when it next
writes to the closed connection (normally at the next sample, up to one interval
later); it can still read sensors before discovering the disconnect. Stop is
not an immediate headset-side cancellation guarantee. The reader still checks
the smoke-test lock before every sample. No lock is acquired or removed by this
flow, and no power, thermal, service, password or security controls are exposed.
## Desktop Tools → Power & heat report
In the packaged desktop app, connect to an already paired Frame, open **Tools →
Power & heat report**, choose **1 minute** (12 samples) or **5 minutes** (60 samples),
and select **Start observation**. Samples arrive every five seconds, starting at
zero (about 55 or 295 seconds total). Progress shows received samples and the
original headset alias. There is at most one observation per local app server;
viewing progress never starts another SSH connection. No terminal, checkout,
extra dependency or password is needed.
Use **Stop and keep samples** to end local collection. Disconnect, timeout,
app shutdown or switching to another headset also ends collection. Switching
network addresses for the same headset does not retarget an existing observation:
it keeps its captured SSH route and may finish incomplete if that route fails.
As with the CLI, only the owned local SSH child is terminated. The remote reader
is bounded and discovers a closed connection on its next write. Desktop sessions
have a 30-second transport allowance and bounded child cleanup. Stream lines are
limited to 262,144 characters, sample events to the requested count, and the receive queue
to count + 2 events; desktop SSH stderr is discarded rather than stored unbounded.
The existing smoke lock is checked locally before start and remotely before each
sample; this feature never creates, bypasses or removes that lock.
**Download HTML report**, **Download JSON** and **Download CSV** save local
snapshots through the browser/app download mechanism. Open the downloaded HTML
in your browser to view the offline table and its embedded JSON/CSV downloads.
Snapshots taken before completion explicitly say incomplete. Desktop CSV includes
`collection.state`, `collection.requestedSamples` and `collection.receivedSamples`
metadata rows with the original headset alias in the source column, even when no
sensor sample arrived. CSV text fields, including the alias, are escaped if they
begin with a spreadsheet formula prefix. JSON records the alias and device ID;
HTML names the alias.
Only the latest observation is retained in server memory, including partial
samples after stop or disconnect. **Download before starting another observation,
restarting the server or quitting the app**: those discard the previous report.
Page reloads retain server state. Download requests include an observation ID, so
an old page cannot silently download or stop a replacement observation. Reports
are not uploaded or automatically opened, and may contain device identifiers,
OS builds and sensor paths. Review before sharing. The phone-hosted UI does not
support starting observations.
## What it reads
- Battery percentage, driver charging state, signed battery watts and battery
temperature, using the same parser as Frame Control's battery details.
Positive battery watts mean charging on the Frame fuel gauge; negative means
discharge. `Not charging` alone does not prove a charger is connected.
- CPU utilization from `/proc/stat`; GPU clock from the known Frame devfreq path.
- Named thermal zones in °C, with zone names retained. A thermal zone is not
automatically a CPU sensor; there is no inferred “CPU temperature” or safe
temperature threshold.
- hwmon power channels, labels and driver names in watts. `vph` is the measured
system rail, not power at the wall. CPU `apc*` and GPU `gfx` rails are separate
readings; do not sum all channels as if they were independent power consumers.
- Fan RPM and the Frame fan driver's raw `pwm1`. No generic percentage conversion
is assumed. The historical Frame driver reported a percent-like value of 41.
- Named IIO board temperatures, PM8550B input/charge current in amps, and raw
VCNL4000 proximity if the account can read it. Raw proximity has no inferred
worn/unworn threshold.
Each metric includes its unit, source path and availability state. Missing,
unreadable or invalid values are JSON `null`, not zero. A missing sensor family
is also listed in each sample's `unavailableGroups`; CSV adds unavailable group
rows. Numeric zero remains a valid reading. The OS build is recorded per sample
when `/etc/os-release` is readable; hostnames, addresses, machine IDs, process
lists, logs, screenshots and application inventories are not collected.
The JSON report contains raw samples and a summary per metric: available and
unavailable counts, numeric minimum/maximum/arithmetic mean/first/last, or the
observed charging states. Means exclude unavailable readings. They are sample
means, not time-weighted energy or battery-runtime estimates. Different sample
availability and intervals must be considered when comparing sessions.
Numeric validity does not establish sensor accuracy. Historical readings
already showed an apparently absent/broken right-display thermistor near
−16 °C. This firmware also reports one charger-lite die sensor at −60 °C.
Those raw numbers remain visible for diagnosis; neither should be used to
infer cooling performance or safety. This tool does not diagnose heat, explain
a spike, predict battery health, or promise power savings.
## Safety and shared-device use
Only file reads under `/proc`, `/sys` and `/etc/os-release` run on the Frame.
The Python sources are sent on SSH stdin and not installed on the headset.
There is no sudo, OpenVR initialization, service call, settings write, process
control, journal query, wake, suspend or reboot. No daemon, telemetry upload or
automatic measurement is added. Redirects above write only to your computer.
The observer itself uses a little CPU and SSH traffic, so label baseline
conditions and avoid interpreting very small changes as savings.
The sampler checks `/tmp/frame-test.lock` on the computer before SSH, and on
the Frame before any probe and before each sample. If a smoke session is active,
it exits 75. A lock appearing mid-run stops the next sample, retains earlier
samples, sets `stopped`, and exits 75. It never removes or acquires another
session's lock. This is an advisory check, not an atomic lease: a smoke session
can start during a file read. Coordinate sessions and defer measurement when
timing overlaps. It does not block someone else from starting a smoke test.
SSH failures/timeouts return a nonzero exit and an explanation, without
presenting a zero or an old report as a successful measurement. Fully missing
sensors can still produce a successful report with explicit unavailable states.
On a connection failure, partial samples buffered on the headset can be lost.
## Evidence and remaining work
Read-only checks on **2026-10-24**, SteamOS **0.5.6**, build
**20261015.6141207**, confirmed battery, CPU ticks, thermal zones, rail power,
fan, GPU clock, board temperatures and charger current through the final
sampler. Proximity was unavailable. Both JSON and CSV paths were exercised;
see [the recorded results](evidence/power-sampler-2026-10-24.json).
No smoke lock was present at the preflight or sampler checks. No settings,
services, application state or device files were changed. This short run is
a parser/transport check, not a controlled baseline or a worn-headset test.
The original planning document is in commit `fe132e3` (2026-09-29). It recorded
an off-head, charging idle baseline of about 4.71 W, CPU zones around 40–45 °C,
and about 8,350 fan RPM on build 20260925.6191901. Those conditions and firmware
differ from the current check; comparisons do not establish a regression.
This change completes only the sampler portion of “Plan: leave it on, but
cheaply.” Controlled worn/home-space, streaming/game load and unplugged
baselines, and the cause of the 2026-09-28 95 °C spike, remain uninvestigated.
Quiet mode, compositor changes, CPU/fan/charging changes and remote-wake tests
are separate work. No controls are offered here. Follow the shared-device
procedure in [testing.md](testing.md#headset-smoke-test) for future experiments.
The accessible HTML export was checked on the same build on **2026-10-24**:
three streamed samples, 91 metric summaries and 276 CSV data rows. Chromium
preview checks at 1280 px and 390 px confirmed the table stays in its scroll
region; both browser downloads matched the original samples. See
[export evidence](evidence/power-export-2026-10-24.json). Ctrl+C retention and
local process cleanup were tested with local subprocess fixtures, not on the
real headset. Screen-reader, Windows and macOS checks were not performed.
Offline validation:
```sh
python3 -m unittest discover -s tests -p 'test_power*.py' -v
bash -n scripts/frame-power-sample.sh
python3 -m py_compile scripts/frame_power_sample.py scripts/frame_power_report.py
```
Fixtures cover units, signed and zero values, missing/malformed/permission-denied
readings, named/discovered channels, unknown charging state, CPU warmup,
summary counts, CSV quoting, strict SSH transport and smoke-lock interruption.
Export fixtures also cover HTML escaping, download round trips, zero versus unknown,
stream completion/failure, smoke-lock stop, timeout, Ctrl+C retention, local child
cleanup, duration bounds and refusing to overwrite reports.
Desktop validation on **2026-10-24** covered API start/stop, a synthetic browser
observation with partial exports, shared-module resource staging under isolated
Python and missing-resource build guards. A real Frame probe under this task's
owned smoke lock correctly returned zero samples with an explicit incomplete
reason; no successful live sensor baseline or native installer run was performed.
See [desktop evidence and limitations](evidence/power-desktop-2026-10-24.json).
+14 -3
View File
@@ -203,9 +203,11 @@ and display name of the headset being tried (whole, whatever their case), and
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
in some other wording can still get through, so check the log lines in **Show
exactly what's included** before sending. A failure that repeats on every
included) becomes `<user>@<host>:`; then the scrubbing below. Whole display names
are hidden even when they contain a scrubber token such as `<host>` or overlap
ssh's wording. Those overlaps also preserve redaction of URLs, home folders,
and keys. A host name ssh mentions in some other wording can still get through,
so check the log lines in **Show exactly what's included** before sending. A failure that repeats on every
retry is written at most once every 5 minutes.
Everything is scrubbed like error details and limited to what fits in the
@@ -287,3 +289,12 @@ then every 6 hours: the latest release's `update.json` on GitHub, or
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
Those requests carry no id. To stop it, set
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
## Screenshots and clips
Copying screenshots and Steam clips to your computer, by hand or with
**Settings → Copy to this computer**, goes straight from the headset to your
computer over the same SSH connection as everything else. Nothing is uploaded
anywhere. What automatic copying has copied is listed, by file id, in
`auto-sync.json` in Frame Control's data folder; see
[recording.md](recording.md#copying-new-ones-automatically).
+226
View File
@@ -0,0 +1,226 @@
# Recording and Steam clips
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
an `.mp4` on your computer. It can also export the clips and background
recordings from Steam's own game recording, at any length, as one `.mp4` each.
Both are on **Tools → Capture & Live**. Everything is saved to
`~/Videos/SteamFrame`, next to the screenshots in `~/Pictures/SteamFrame`.
Nothing needs installing on the computer or on the Frame: the work is done by
the `ffmpeg` that SteamOS ships.
**Recording and Steam clip export have each been tested on a headset.** On a Frame
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
second, and nothing was left running or stored on the Frame. A later test on
that build created a saved clip with Steam's own recorder and exported it with
Frame Control's production export function. The parts we're least sure of are
listed under [What hasn't been checked](#what-hasnt-been-checked).
## Record
Press **Record** next to **Live**. The button turns into **Stop**, and a line
under the viewer counts the time. Press **Stop**: the recording is finished on
the Frame, converted to `.mp4` there (without re-encoding) and copied to
`~/Videos/SteamFrame/rec-<date>-<time>.mp4`. The line says when it's saved.
- **What's in it**: the same picture as **Live**, SteamVR's copy of the headset
view (one undistorted 1920×1080 eye, from `/dev/video99`), at 30 fps and
8 Mbit/s, encoded with x264 (the Frame's hardware encoder crashes `ffmpeg`).
The sound is what the headset plays: the monitor of PipeWire's default
output, read through its PulseAudio server (`-f pulse -i @DEFAULT_MONITOR@`).
The microphone isn't recorded.
- **Without sound**: before each recording Frame Control checks for a fraction
of a second that `ffmpeg` can read that audio. If it can't, the recording goes
ahead as video only, and the page and the saved message say so.
- **Limits**: a recording stops by itself after 20 minutes and is saved as if
you'd pressed Stop. It needs about 2.9 GB free on the Frame to start (room for
a full-length recording twice, while it's converted), and checks the space on
your computer before copying. One recording runs at a time.
- **Where it waits on the Frame**: `~/.cache/frame-control/video/`, not `/tmp`,
which is a RAM disk on SteamOS. It's deleted once copied.
- **If the connection drops or the app quits** while recording, the Frame
stops `ffmpeg` cleanly when the SSH channel closes: it types `q` into
`ffmpeg`'s input, as at its keyboard, and kills it if it's still there 10
seconds later. Not a signal: SteamOS's `ffmpeg` 7.0 catches SIGTERM and
SIGINT and carries on recording. A recording that was never copied is
deleted when the next one starts.
Recording and **Live** can run together; both read the same device.
### Does Record include passthrough?
**Record captures SteamVR's `system.HeadsetView` mirror from `/dev/video99`.**
Frame Control does not open the headset's passthrough cameras or add a separate
camera layer to the MP4. If SteamVR puts passthrough into that mirror, it will
appear in the recording; Frame Control cannot force or promise that result.
Steam's own **Steam clips** use a separate game recording path and may differ.
On 25 October 2026, the Frame was in standby. A frame captured from
`/dev/video99` was black, as expected in standby, so this check **did not
establish whether active passthrough appears in the mirror**. A wearer-visible
passthrough session and its simultaneous mirror frame are still needed for a
yes-or-no answer about passthrough pixels. See the
[live compatibility evidence](evidence/steamos-compat-live-2026-10-25.md).
## Steam clips
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
clips and background recordings, newest first, with the game, length and size.
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
Steam's own export is reported to stop at about 60 seconds. Exports run in the
background; ones already on your computer are marked.
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
recording session.
```
~/.local/share/Steam/userdata/<account>/gamerecordings/
video/bg_<appid>_<YYYYMMDD>_<HHMMSS>/ background recording
clips/clip_<appid>_<YYYYMMDD>_<HHMMSS>/video/bg_<...>/ a saved clip
session.mpd
init-stream0.m4s chunk-stream0-00001.m4s chunk-stream0-00002.m4s ...
init-stream1.m4s chunk-stream1-00001.m4s ...
```
Each stream's `init-stream` segment followed by its chunks, in number order, is
a playable fragmented MP4 of that stream. On the Frame, `ui/frame_clips.py`
builds those files and has `ffmpeg` put the video and audio into one `.mp4`
with `-c copy`, so nothing is re-encoded and it takes seconds. The result is
copied to the computer and deleted from the Frame. The export needs free space
on the Frame of about twice the recording's size while it runs.
The listing doesn't rely on the folder names: any folder under `clips/` or
`video/` (up to four levels down) holding `init-stream*.m4s` counts. A stream
missing its init segment or its chunks is left out. Missing chunk numbers are
shown as "pieces missing"; the rest still exports.
## Copying new ones automatically
**Settings → Copy to this computer** copies new screenshots and Steam clips
here on their own. It's off until you turn it on, with a switch each for
screenshots and Steam clips. While a headset is connected, Frame Control looks
about once a minute and copies what's new with the same code as **Save** and
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
`~/Videos/SteamFrame`.
- **Only what's new.** It copies what you take after turning it on, so the
shots and clips already on the headset don't all arrive at once. **Copy
everything now** copies everything that isn't on this computer yet, once.
- **Deleted stays deleted.** What it has copied is remembered, per headset, in
`auto-sync.json` in Frame Control's data folder, so a file you delete here
isn't copied again (**Copy everything now** would bring it back).
- **Saved clips only.** Steam's background recording is a rolling buffer, not
something you chose to keep, so it isn't copied; export it by hand. A clip
Steam wrote to in the last 30 seconds is still being saved and waits for the
next look.
- **It gives way.** It waits while an install, an export, a recording or
anything else is running on the headset, and stops between files if one
starts. While a copy is running, switching headsets waits for it, as it does
for an export. When the headset can't be reached it waits longer each time,
up to 15 minutes, and starts over once it answers.
- **When this computer is full** (under 512 MB free, or a copy runs out of
space) it pauses and says so in Settings and above the lists. Free some space
and press **Resume**.
- **With several headsets** it copies from the one you're connected to.
Settings shows when it last checked, how many files it last copied and when,
and the last problem it had. The screenshot and clip lists say when it's on.
## For developers
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
copying, in `ui/frame_autosync.py`, does):
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
computer's side; the server passes how to reach the Frame.
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
run as `python3 - list` / `python3 - export ID` with the file on stdin.
- `Recorder` runs one recording from Start to the copy.
- `parse_id` checks a clip id (`<account>/<clips|video>/<folder>...`, plain
names only, no `.` or `..`) before it reaches a path or a shell.
The API, with the same guards as every other request:
| Request | Does |
|---|---|
| `GET /api/record` | The recording's state: `idle`, `starting`, `recording` (with `elapsed`, `audio`, `limit`), `stopping`, `saving`, `saved` (with `file`) or `failed` (with `error`) |
| `POST /api/record {"action": "start"\|"stop"}` | Start or stop |
| `GET /api/clips` | Steam's recordings on the Frame, each with `saved` |
| `POST /api/clips/export {"id"}` | Starts an export; poll `/api/job` |
| `POST /api/open {"what": "videos"}` | Opens `~/Videos/SteamFrame` |
| `GET /api/autosync` | Automatic copying: `screenshots`, `clips`, `since` (the watermark), `checked`, `copied`, `copied_time`, `error`, `paused`, `waiting` (clips still being written) |
| `POST /api/autosync {"action": "settings", "screenshots", "clips"}` | Turn either on or off; turning it on sets `since` to now |
| `POST /api/autosync {"action": "now"\|"resume"}` | Copy everything not here yet, once; or resume after running out of space |
They aren't available when the server runs on the Frame itself (the iPhone
app), since they save to a computer.
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
copying (with a fake headset). They run the recording's shell under bash with a
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
recording with Steam's file names and export it with the real `ffmpeg`.
## Real Steam clip verification and remaining checks
On 2026-10-24, v0.4.15 on build `20261015.6141207` recorded a ten-second
reserved test session through Steam's own game recorder, saved it as a clip,
then exported it with `frame_clips.export_clip`, the same implementation used
by the desktop API. The 159,829-byte MP4 decoded completely: 10.29 seconds,
1920×1080 H.264 video and stereo AAC audio (SHA-256
`f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1`).
All 616 decoded video frames and the decoded audio matched Steam's source
segments. Recording mode was restored
to off; the reserved title and its processes were removed, the shared test lock
was released, and the battery remained at 99%. The Steam source clip was kept.
The inspected frame is nearly black in both Steam's source and the export, so
this establishes file creation and faithful export, **not visible gameplay
capture**. On v0.4.16, the literal desktop **Export** button was also clicked
against that retained real Steam clip in the source UI on lxso2. The job
completed and saved `~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4`;
its 159,829 bytes and SHA-256 matched the previously decoded export exactly.
The temporary server stopped, the shared device lock was released, and the
headset remained in Game Mode at 99% battery. See
[`docs/evidence/clip-gui-export-2026-10-24.json`](evidence/clip-gui-export-2026-10-24.json)
for the narrow proof. This did not exercise the packaged desktop installer.
On 2026-10-24, the exact v0.4.17 tag exported a second real Steam-saved clip
through `frame_clips.export_clip`, the same production path used by **Export**.
Steam listed the clip at **100.801 seconds**, with 68 chunks and no gaps. The
copied MP4 was **100.833 seconds** by `ffprobe` and decoded completely with
`ffmpeg`; it was not cut at 60 seconds. The test restored Steam's recording
mode, removed its reserved title and processes, and left Game Mode active at
100% battery. See [the long-clip evidence](evidence/clip-long-2026-10-24.json).
That code-path test did not click **Export** in the packaged desktop app or
verify visible gameplay quality or perceptual audio/video sync.
Later that day, the published v0.4.17 x86_64 Linux AppImage also passed the
packaged desktop **Export** check against the retained 100.801-second clip.
The asset SHA-256 was verified before launching its unchanged extracted payload
in an isolated Electron profile on a virtual X desktop. An automated pointer
click on the visible **Tools → Capture & Live → Steam clips → Export** button
saved the MP4 and changed the row to **ON PC / Export again**. The saved file
was 100.833335 seconds, decoded completely, and matched the prior production
export by size and SHA-256. The app and test display were closed,
the Frame export cache was empty, the source clip was retained, and the shared
lock was released at 99% battery with Game Mode and SteamVR still running.
See [the packaged export evidence](evidence/clip-packaged-export-2026-10-24.json)
and its cropped before/after screenshots. This verifies the packaged Electron
button and bundled export path; AppImage FUSE mounting, desktop integration,
physical display/GPU rendering, gameplay quality and perceptual sync remain
unverified. The virtual-display launch used `--no-sandbox --disable-gpu`.
Remaining checks include:
- Perceptual audio/video sync and audible content. Frame Control's separate
**Record** path stamps camera and audio inputs with the wall clock because
they otherwise count from different clocks.
- Clips longer than the 100-second test and trims starting partway through a
Steam recording. Two real saved clips' directory layouts were checked; the
parser remains deliberately loose
for other layouts.
- How much x264 at 1080p30 costs the Frame while a game is running.
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
finished saving a clip, and how a once-a-minute listing feels on the Frame.
+3
View File
@@ -4,6 +4,9 @@ Where to get the Steam Frame's operating system, what's inside it, and how to
run it for testing without the headset. For recovering a Frame that won't boot,
see the boot menu and boot-loop entries in
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
To keep your game saves, settings and Android app data through a factory reset
(`Erase User Data`) or a re-image, and to get out of Desktop Mode, see
[backup-and-recovery.md](backup-and-recovery.md).
## Downloads
+4 -1
View File
@@ -14,7 +14,10 @@ count. So a build reaches people only when you publish it, after testing it.
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
attaches everything to a **draft** release for that tag. Nobody is
offered a draft.
offered a draft. Editing a draft while installers are uploading can make
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
draft by its exact version title, restores the tag association and retries
the affected upload; it refuses ambiguous matches.
2. Download the draft's installers and test them. An installed copy of the
previous version won't offer the draft, so install it directly.
+4
View File
@@ -7,6 +7,10 @@ run on Linux. On Windows, use the app.
## Minimum typing on the headset
If setup or pairing fails, run `scripts/frame-doctor.sh` from this checkout.
It checks existing SSH access without requesting passwords, pairing or applying
repairs. See the [Frame doctor guide](frame-doctor.md) for results and next steps.
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
thing you type on the headset is a password you choose.
+2 -1
View File
@@ -30,7 +30,8 @@ it.) A dialog shows:
Install copies it to the Frame and registers it with Steam; progress shows in
the bar and the activity log. **Sideloaded titles** lists what's installed,
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
with Launch, Remove and, for a game that has stopped responding, **Force
quit…** in its ⋯ menu ([how](steam-games.md#force-quitting-a-running-game)). **Copy to ~/Downloads instead** keeps the old
behaviour for a zip that isn't a game.
From a terminal:
+6 -2
View File
@@ -46,14 +46,14 @@ unless your router's DNS registers DHCP client names.
```sh
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_frame -N '' -C "mac->steam-frame"
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@frame.local
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@192.168.1.40
```
`~/.ssh/config` block (managed between marker lines by the script):
```
Host frame
HostName frame.local
HostName 192.168.1.40
User steamos
IdentityFile ~/.ssh/id_ed25519_frame
IdentityFile ~/.ssh/id_rsa_frame_devkit
@@ -62,6 +62,10 @@ Host frame
```
The script only asks for the password if the pairing below doesn't work.
Replace the example IP with the one in the Frame's Quick Settings. SteamOS
0.5.6 stopped publishing `.local` names; an existing SSH alias or the headset's
current IP is more reliable. Frame Control remembers authenticated IPs per
network and checks the saved SSH identity on reconnect.
## Password-free pairing (SteamOS devkit service)
+56
View File
@@ -101,6 +101,62 @@ Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
returns nothing without `cc`, so Frame Control takes the country from
`SteamClient.User.GetIPCountry()` on the Frame.
## Force quitting a running game
**Running now**, at the top of Games, lists what's running on the Frame: Steam
games, sideloaded titles, non-Steam shortcuts and Android apps. A game's library
tile, a sideloaded title's ⋯ menu and the Control Center's **Force quit a game**
card have **Force quit…** too. Each asks first ("Unsaved progress will be
lost"); from a game's own menu it checks that the game is running and says so if
it isn't. SteamVR (app 250820, and its beta 1009850) is never listed or quit:
on the Frame it shows the headset view, so quitting it isn't a way out of a game.
`ui/frame_running.py` does the work on the Frame, piped over SSH with
`frame_steam.py` as its DevTools client:
```sh
scp ui/frame_steam.py ui/frame_running.py frame:/tmp/
ssh frame 'python3 /tmp/frame_running.py list' # {"running": [{appid, gameid, name, kind, ...}]}
ssh frame 'python3 /tmp/frame_running.py quit 620' # {"name", "method", "message"}
```
1. **What's running.** Steam starts every game, shortcut and devkit title under
its `reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper
stays the parent of the game's processes. So a running game is one of your
own processes whose program is named `reaper`, with `SteamLaunch` and an
`AppId=` before `--`; the game is that reaper's process tree. Steam's own list
(`SteamUIStore.RunningApps`) and Android apps whose Lepton container is up
(`podman ps`) are added. Names come from `appStore`, else the app manifest,
the Android app's `meta.json` or the devkit title's folder.
2. **Steam first.** `SteamClient.Apps.TerminateApp(<game id>, false)`, the
call Steam's own Stop makes for Android apps ([vr-apks.md](vr-apks.md)). The
game id is the appid, or `(appid << 32) | 0x02000000` for a non-Steam
shortcut. An Android app's container is also stopped, as the Apps tab's Stop
does (`podman stop -t 5 lepton-steamlaunch-<instance>`).
3. **Then signals.** If the game's processes are still there after 5 seconds,
they get SIGTERM, deepest first; whatever is left 3 seconds later gets
SIGKILL, the reaper last, so nothing it holds is orphaned unsignalled. A pid
is only signalled while its start time still matches, so a reused pid is safe.
Nothing is found by name. These are never signalled, even inside a game's tree:
init, the helper itself and every process above it (the SSH session), other
users' processes, and anything named `steam`, `steamwebhelper`, `gamescope`,
`gamescope-wl`, `gamescopereaper`, `gamescope-session`, `sshd`, `sshd-session`,
`ssh-agent`, `systemd`, `dbus-daemon`, `dbus-broker`, `pipewire`,
`pipewire-pulse`, `wireplumber`, `plasmashell`, `kwin_wayland`, `kwin_x11`,
`Xwayland`, `sddm`, `vrserver`, `vrcompositor`, `vrmonitor`, `vrdashboard`,
`vrwebhelper` or `vrstartup`, with everything under them.
**Not tested on a headset.** The reaper command line is how Steam launches games
on Linux desktops and the Steam Deck, **inferred** for the Frame.
`SteamUIStore.RunningApps` comes from Steam's client code as plugin loaders use
it and hasn't been seen on the Frame's build; without it, the reapers alone
decide. `TerminateApp` with a shortcut's game id is the call Steam's Stop was
seen making for an Android app (docs/vr-apks.md); for Steam games it is
**inferred**. Unit tests (`tests/test_force_quit.py`) cover the parsing, the
process selection and the escalation; the fake Frame launches its games under a
stand-in `reaper` so `tests/e2e/test_running.py` force quits a real process tree.
## Not yet checked
- Free-to-play games: `steam://install` should stop at state 3 (free license)
+92
View File
@@ -0,0 +1,92 @@
# Steam desktop sharing on a Linux PC
**Tools → Steam desktop sharing** helps with Steam's own `steam -pipewire`
desktop-in-headset flow on a local Wayland PC. It needs no Frame connection.
It is separate from Frame Control's [PC in the headset](pc-in-headset.md),
which remains disabled on Linux. Steam and the desktop portal own the capture,
screen picker and headset flow; this helper does not provide a stream.
## Launch Steam with PipeWire
Save your game and use **Steam → Exit** first. Then click **Launch Steam with
PipeWire**. Frame Control checks this user's local processes, finds `steam` on
PATH, and requests `steam -pipewire`. This flag applies to that launch only.
No Steam settings, login files or startup entries are changed. An existing
Steam session is never stopped, signalled or silently switched.
If Steam or a Steam launcher/helper is running, the button explains that Steam
must be exited first. If the process inventory cannot be read, it refuses to
launch. It also refuses root, SSH, headless, X11 and Flatpak-sandboxed Frame
Control sessions. Run Frame Control as your normal user in the local Wayland
desktop with its session bus.
For a Flatpak-only Steam install without a `steam` command on PATH, the helper
provides a manual command instead of choosing an installation for you. After
exiting Steam, run `flatpak run com.valvesoftware.Steam -pipewire` in a local
terminal. Flatpak Steam processes are included in the existing-session check.
A successful request is **not confirmation of a picker or stream**. Steam may
exit or fail after the initial check, and another launcher can race the local
process check. Do not start Steam elsewhere at the same time. If nothing opens,
run the command yourself in a terminal to see Steam's error.
## Reset screen-share choice
Click **Reset screen-share choice…** to request KDE's **Application Permissions**
page. This button does **not** revoke anything or report a completed reset.
Frame Control cannot reliably map a Steam runtime display name to a
Steam-specific portal grant or restore token across backends. It never reads,
edits or clears the portal permission store or Steam tokens.
On KDE:
1. Open **System Settings → Applications → Application Permissions** if the
button cannot open it.
2. Find the entry for Steam. The Ubuntu 26.04 KDE report showed **Steam Linux
Runtime 3.0**. That display name alone is not proof that every session there
is safe to remove; verify the session belongs to your Steam desktop share.
3. Under **Screen sharing**, choose **Manage 1 Session** (the count may differ).
4. Choose **Revoke** for that verified session only. **Do not use Revoke All**
or change another application's grants. If ownership is ambiguous, stop.
5. Retry Steam's desktop sharing flow. The reporter saw the portal picker again
after revoking that session. Results can differ by Steam and portal version.
The reported entry sometimes appeared intermittently. If it is missing, do not
clear other entries or the whole portal store. Check again when Steam's sharing
session is present. If **Application Permissions** itself is missing on
Ubuntu/Kubuntu, check your distribution's `kde-config-flatpak` package: the
reporter needed that package and a reboot before the page appeared. Frame
Control does not install packages, reboot, request root or change security
settings. Older KDE modules may lack this page; other desktops receive guidance
only, with no automatic reset or guessed permission target.
The launcher uses `kcmshell6 --list` to verify the exact module, then
`kcmshell6 kcm_app-permissions`. It does not substitute the older Flatpak-only
page. A request to open the page does not prove that a window became visible.
## Evidence and limits
- User-supplied report and screenshot description:
[ExpressGinga81's thread and replies](https://x.com/ExpressGinga81/status/2113242533530911058).
The report describes Ubuntu 26.04 KDE, the initial picker appearing once,
the Steam Linux Runtime 3.0 entry, and Revoke making the picker reappear.
This is reported behavior, not a live test performed for this feature.
- KDE upstream [`flatpak-kcm`](https://github.com/KDE/flatpak-kcm):
`CMakeLists.txt` and `kcm_app-permissions.json` identify Application
Permissions; `ui/SessionList.qml` distinguishes Revoke from Revoke All.
- Automated tests mock Steam, processes and KDE commands. HTTP tests verify
request guards, no actions on GET, and no headset work. Existing CI discovers
these tests on Linux, macOS and Windows; they do not need a desktop portal.
- lxso2 has no interactive Wayland/PipeWire portal for a chooser test.
**No live chooser, revoke, Steam launch or headset display is verified here.**
No Frame access was performed for this task, including during the hold before
2026-10-24 13:00 UTC. The helper never invokes SSH or Frame controls.
## Announcement
Linux Wayland users can now find **Steam desktop sharing** under Tools to
request Steam with `-pipewire` after exiting Steam, or open KDE's Application
Permissions to revoke only a verified Steam screen-sharing session and try the
picker again. Frame Control does not clear permissions automatically. This is
help for Steam's own desktop-sharing flow; Frame Control's Linux capture
feature remains disabled. Live picker behavior still needs desktop validation.
+67
View File
@@ -0,0 +1,67 @@
# SteamOS October 2026 compatibility
SteamOS 3.9.5 is the **PC host / Steam Machine** update. It is not the Frame
headset OS. The headset release discussed here is **SteamOS 0.5.6 beta**.
## Finding and connecting to the headset
SteamOS 0.5.6 stopped publishing mDNS names, including `frame.local`. Existing
addresses in Frame Control still work if they resolve, and the app tries all
saved addresses. After an authenticated connection through a `.local` name,
Frame Control also remembers the numeric IP on that network. If the name stops
resolving, it tries that IP and checks the same pinned SSH identity before using
it. The IP is never reused automatically on a different network.
In **Devices**, **Find on this network** checks the SteamOS devkit advertisement,
known IPs and the local IPv4 `/24`. It offers scan results only when their SSH
key matches the headset's saved key. The scan is bounded to a private subnet;
it cannot find a headset across a router or on another subnet. Enter the IP
shown in the headset's Quick Settings in the address field in those cases.
For initial pairing, **Set Up Connection** accepts a manual IP and reuses an
existing `~/.ssh/config` `HostName` before trying mDNS. A configured SSH alias
continues to work. Frame doctor no longer assumes `frame.local`: supply
`--lan <IP>` when a separate LAN route is needed.
## SteamOS script names
Session switching already prefers `holo-session-select` over
`steamos-session-select`, after the Frame's `steamosctl` control. Desktop
process detection accepts `holo-nested-desktop` and its old name. The sideload
helper prefers `holo-prepare-upload` and `holo-delete` if a future devkit
provides them, falling back to the vendored `steamos-*` scripts. The devkit
service name `_steamos-devkit._tcp` is a protocol identifier, so it is not
renamed.
## SteamOS 3.9.5 Linux PC hosts
NetworkManager's `nmcli` supplies the active SSID and route diagnostics; the
SSID reader falls back to `iwgetid`. Neither depends on whether NetworkManager
uses wpa_supplicant (new installs) or iwd (upgraded installs). The current code
does not manage either backend or the Steam Frame Wireless Adapter. An adapter
doctor belongs in its own PC host feature.
## Android APKs and telemetry
The 0.5.6 Desktop Mode **Add to Steam** action for `.apk` files is another way
to make a Steam library entry. Frame Control already creates a non-Steam
shortcut for every installed APK through the running Steam client's API, with
its own persistent Lepton instance and artwork. It does not edit
`shortcuts.vdf` or require a Steam restart, so no extra shortcut option is
needed in its install flow.
The battery reader selects a system battery by power-supply `type` and `scope`
and reads standard sysfs fields. The memory reader uses `/proc/meminfo`.
Neither relies on the performance overlay's labels or hard-coded SteamOS
paths. SteamOS 0.5.6's improved overlay reporting does not establish a sysfs
path change. A live check on build `20261022.6153909` confirmed that Frame
Control's existing readers return battery and memory values. The dominant-eye
setting is not read yet; recording metadata must not claim an eye until its
source and effect on `/dev/video99` are verified. The same check found the
`beta` update branch, but the normal user could not query update availability
through Atomupd; no update was applied. See the
[live evidence](evidence/steamos-compat-live-2026-10-25.md).
Sources: [SteamOS 3.9 news](https://store.steampowered.com/news/app/1675200),
[Frame OS news](https://steamcommunity.com/app/4165890/allnews/), and the
Frame Control implementation in `ui/frame_network.py`, `ui/frame_status.py`,
`ui/frame_android.py`, `ui/frame_link.py` and `frame/android/steam_shortcuts.py`.
+40 -5
View File
@@ -7,6 +7,9 @@ This covers three directions, plus input:
- **C. iPhone → Frame**: mirror the phone inside the headset.
- **PC VR from Linux**: [feasibility and options](linux-vr-streaming.md),
including Valve's streaming and USB support. No Linux host tested yet.
- **Linux desktop → Frame panels**: [PC-host feasibility on lxso2](evidence/linux-pc-host-lxso2-2026-10-24.md).
Blocked on missing desktop portal/PipeWire and native build dependencies;
Linux desktop capture remains disabled and unverified.
- **Input**: type and point in the Frame from the Mac or iPhone.
- **Live view and Control**: watch a panel flat and tap on it to use it.
@@ -144,17 +147,38 @@ capture it.
## Input: type and point in the Frame from the Mac or iPhone
**Built: Home → Keyboard and trackpad**, in every version of Frame Control
**Built: Home → Type on the headset** (or press `T`), in every version of Frame Control
(Mac, Windows, Linux, iPhone and iPad), with nothing to install on the device
you're holding. On a phone the panel is a trackpad (drag to move, tap to click,
two fingers to scroll, two-finger tap to right-click) plus a text field that
types on the Frame. On a computer, clicking the pad passes your mouse and
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
It goes through **KDE Connect**, the first-party route (KDE makes the Frame's
desktop): Frame Control's server runs [`ui/frame_input_agent.py`](../ui/frame_input_agent.py)
on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
`kdeconnectd` as if it were a phone. KDE Connect does the typing and clicking.
Since Control (below), the pad goes through **gamescope's own input socket**
([`ui/frame_touch.py`](../ui/frame_touch.py)), so it reaches whichever panel has
focus, Steam's own menus included, and needs nothing installed on the Frame.
Only characters that aren't on a US keyboard (accents, emoji) go through
**KDE Connect** as below; it starts the first time you type one. (Before
Control, everything went through KDE Connect, which is why the notes below
describe it in detail.)
The server keyboard queue holds at most 200 events, including text being sent.
A batch with an accent sends its whole keyboard part through KDE Connect, and
later keys follow it there while typing is pending. If the batch will not fit,
the server rejects it before sending any clicks or keys; the page keeps it and
retries after polling. Accepted events are never evicted to make room. A timeout
or helper failure retains unsent text and shows **Try again**; that button resumes
the retained queue. The queue lives in memory and is lost if the server exits.
Switching headsets cancels queued text and stops both input helpers before the
new route is published. Both controls validate the intended headset before they
start. Ordering between the two input services uses a typing-time estimate;
neither service acknowledges when characters reach the application.
[PR #66 verification on 2026-10-24](evidence/pad-gamescope-2026-10-24.md)
records an owned focused X11 panel receiving a click and `Frame66` plus Enter
on a real Frame with SteamOS 0.5.6, after the shared headset became available.
The helper, window and owner-marked lock were cleaned up. This is ASCII input
proof, not wearer-visible, Unicode or packaged Electron GUI verification.
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
@@ -285,6 +309,17 @@ How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
[#73](https://github.com/saphid/frame-control/issues/73) (SteamOS 0.4.5, desktop
open but not found). The likely cause there was the page itself: a second
`loadPanels` (the panel switcher's) replaced the Desktop view's, fixed in #84.
If the `GAMESCOPE_FOCUSABLE_WINDOWS` property cannot be read, discovery now
reports that the panel list is unreadable and whether a panel is open is
unknown. A failed X connection or missing property is not evidence that the
desktop is closed. Check that SteamVR is running, retry or use Headset view;
if it persists, report the exact message and SteamOS version. The confirmed
empty-list advice is retained when the property is present but empty.
[2026-10-24 investigation and verification limits](evidence/panel-discovery-2026-10-24.md).
A later [packaged v0.4.17 test on the real Frame](evidence/desktop-capture-packaged-2026-10-24.md)
captured a mapped, focused nested KDE compositor on SteamOS 0.5.6. SteamVR
still reported its overlay hidden, so that test does not establish the
wearer's visible-desktop condition in #73 or justify closing it.
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
text box in the panel and the text field typed into it; a trackpad move went
exactly (+40, +25).
+192 -7
View File
@@ -12,6 +12,22 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
## Unit tests
The gamescope keyboard/trackpad route has focused suites in `test_input.py`,
`test_touch.py` and `test_service_regressions.py`. They cover pad translation,
ASCII/modifier keys, Unicode queuing/retries, stale panel targets, release
handling, route cancellation, and intended-headset start/status guards.
`tests/page/pad_queue.mjs` and `tests/page/ctrl_gestures.mjs` execute the page's
real queue and gesture functions in Node. These are offline tests; their
acknowledgements do not prove that a real application received input.
See [the 2026-10-24 PR #66 evidence](evidence/pad-gamescope-2026-10-24.md)
for the real focused-panel click/key observation, cleanup and remaining limits.
The read-only power/heat sampler has fixture and transport tests in
`tests/test_power_sample.py`. Run
`python3 -m unittest discover -s tests -p test_power_sample.py -v`.
See [power-and-heat.md](power-and-heat.md) for sampling, smoke-lock behavior and
real-device verification limits.
```sh
python3 -m unittest discover -s tests
```
@@ -44,6 +60,23 @@ them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
## The fake Frame
Frame doctor has focused offline tests in `tests/test_frame_doctor.py` and
fake-Frame fault tests in `tests/e2e/test_doctor.py`:
```sh
python3 -m unittest discover -s tests -p test_frame_doctor.py -v
scripts/e2e.sh test_doctor
```
They cover privacy filtering, host-key preservation, restart safety and missing
journal evidence, plus asleep, full-title-storage, SSH/devkit-off and missing-key
faults. The plan/report tests cover exact-evidence advice, blocked plans,
private exclusive baselines, malformed input, target mismatches and comparisons
without repair claims. The fake-Frame storage test records a full-disk baseline,
clears only the fake fault and compares a fresh run. The fake's doctor-only
`systemctl show` responses assume stable restart counters and inactive keep-awake; unsupported queries fail. These tests do not
exercise a real SteamOS user manager or touch a headset.
`tests/fakeframe/` builds a container that stands in for the headset, and a
second one for the computer Frame Control runs on. `scripts/e2e.sh` builds
both, starts them with `docker compose`, runs `tests/e2e` in the host
@@ -76,8 +109,8 @@ its guards want.
| `sshd` with key and password logins, `rsync`, `python3` | Real |
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
| Valve's devkit-utils, copied over by Frame Control itself | Real |
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs. Games it really runs start under a stand-in `reaper SteamLaunch AppId=<id> -- <command>` (bash with that command line), so force quit finds a real process tree | Fake |
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF. `SteamUIStore.RunningApps` lists launches that haven't exited; `TerminateApp` ends only launches that never ran a process, so a running program needs Frame Control's signals | The JS engine is real; the objects are fake |
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
@@ -142,6 +175,14 @@ Record the build, actual interaction results, cleanup and any unworn-headset
limits alongside screenshots or logs. These are caller responsibilities; the
smoke script below does not acquire this shared lock itself.
[Packaged Desktop Capture evidence, 2026-10-24](evidence/desktop-capture-packaged-2026-10-24.md)
records v0.4.17 GUI button captures of a real mapped nested desktop on SteamOS
0.5.6, a bounded owned user unit, and cleanup back to the original Game Mode
state. Check X window mapping/focus and SteamVR overlay visibility separately:
successful capture of a mapped window does not prove the desktop is visible
to a wearer. That run's overlay stayed `not_visible`; #73 remains unverified
for its reported visible-desktop/0.4.5 condition. Only state/log metadata is public.
```sh
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
@@ -164,6 +205,43 @@ does). A cleanup that fails counts as a failed step. Results go to
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
isn't reachable.
Smoke process cleanup runs before and after title removal and during the
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
an exact title directory in `/proc` cwd/exe or an individual absolute command
argument; similarly named folders and shell command substrings do not count.
Deleted cwd/exe paths still count, so a process left after file removal can be
found. It sends SIGTERM, waits up to three seconds, then uses SIGKILL with
another three-second bound, rescanning for survivors. PID handles prevent PID
reuse from signalling another process. Linux Python 3.9+ with pidfd support
is required; missing support, signal errors and survivors fail the
cleanup step. Stopped PIDs are recorded in the results JSON.
If the initial leftover cleanup fails, the run skips installs and pairing and
records the failure instead of overwriting an active test title.
This deliberately does not stop shared Wine/Proton services by name or prefix,
Steam, SteamVR, process groups or arbitrary descendants. A process that drops
all title-path evidence cannot safely be attributed and is outside this
check. Unreadable `/proc` fields provide no ownership evidence; other readable
fields are still checked, and a wholly unattributable process is left alone.
Real Frame verification, 2026-10-24, SteamOS build `20261015.6141207`: the
fixed Python smoke entry point passed all 13 steps under the shared device lock.
The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
remove step stopped five test-owned wrapper/Proton PIDs before removing the
title; the following process sweep found none. The final cleanup found no
test titles or processes, the test desktop was closed, and the battery
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
the same entry point instead.
The local regression suite runs real Linux processes in temporary fake title
directories (including deleted folders and a TERM-resistant wrapper); it checks
exit, escalation and survival of unrelated processes without SSH or a headset:
```sh
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
```
`--pair` asks the devkit service to pair a new RSA key, which needs someone
in the headset to open **Settings → Developer → Pair new host** and approve
it; the key is checked and then taken out of `authorized_keys` again.
@@ -187,11 +265,36 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
accepted. The fake now refuses them the same way, and Frame Control makes ids
Steam accepts.
## PC panel host tests
PC in the headset (Experimental, Windows only). `tests/test_pcview.py` checks
that only Windows gets the PC host and that it starts nothing until turned on,
the Tools link and card wording, source-bound tickets, reconnect/Stop
revocation, input release, source-coordinate mapping and use of the existing
panel launcher. `tests/test_pc_controller.py` checks the C rate controller
against a frozen trace of the Mac's Swift one.
The `PC host libraries` workflow builds the native adapter on Windows (and
the unshipped Linux adapter on Ubuntu x64 and ARM64), then runs a real x264
synthetic stream through the HTTP/WebSocket agent. x264 is GPL, so only these
CI builds include it (`desktop/build.py --with-x264`); the release workflow
tests the installer's own bundle with its bundled Python and checks x264 isn't
in it. `FRAME_PC_REQUIRE_NATIVE=1` and `FRAME_PC_REQUIRE_X264=1` make a missing
bundle or encoder fail CI instead of skipping.
These automated CI checks do not capture a desktop, exercise Media Foundation
or a GPU, or check laser alignment. A [Windows test VM](#windows-test-vm) run
on 2026-10-24 captured its actual desktop with Desktop Duplication, encoded
with Media Foundation and streamed to a real Frame. It found MSYS2 disables
WGC: an element name alone does not prove its optional capture properties
exist. Native property-probe tests and source-selection regressions now cover
that boundary. [Dated evidence and limits](evidence/windows-vm-2026-10-24.md);
[what's verified](pc-in-headset.md#whats-verified).
## Windows test VM
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
depends on the Windows desktop (Remote Desktop, the installer, the bundled
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
Windows CI runs the unit tests and checks installer contents. Desktop capture,
installer launch and interactive behavior need a Windows machine. This is a Windows 11 VM in a
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
@@ -211,7 +314,14 @@ Setting it up, once, on the Linux machine:
`~/.ssh/id_ed25519_winvm` on the Mac) in
`C:\ProgramData\ssh\administrators_authorized_keys`.
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
and the tests.
and the tests. Inspect free memory/disk, KVM access and existing VMs before
starting; leave other VMs alone. The first install can take tens of minutes
(the 2026-10-24 run took about 16 minutes to reach the desktop), rather than
the 1–2 minute startup of an already installed guest.
- For PC streaming, check the actual bundled capture properties. MSYS2's
GStreamer build disables WGC: this app falls back to Desktop Duplication for
whole screens and explains why individual windows are unavailable. A VM
capture test does not prove physical GPU encoding or laser alignment.
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
@@ -267,11 +377,26 @@ connect it to the Frame and follow Remote Desktop through to the Frame's
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
`frame-key` left `authorized_keys` byte-for-byte as it was.
## Force quit
`tests/test_force_quit.py` feeds `ui/frame_running.py` made-up process tables:
the `reaper SteamLaunch AppId=` parsing, naming from Steam, manifests, Android
`meta.json` and devkit folders, and a Frame's worth of Steam, gamescope, SteamVR,
sshd and session processes that must never be signalled. Processes that ignore
SIGTERM, survive SIGKILL or exit and have their pid reused check the escalation.
The Control Center's confirmation, duplicate and headset-switch handling run in
`tests/control_center.cjs`. `tests/e2e/test_running.py` force quits real process
trees on the fake Frame. None of it has run on a headset.
## Owned media player
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
hardware-decoder command construction, malformed splats, stereo parallax and
fake-Frame library/process ownership. `tests/e2e/test_media_transfer.py` checks the real
fake-Frame library/process ownership. It also covers optional-surround refusal
at creation, setup, first upload and retry after standby, fatal screen refusal,
and best-effort overlay teardown with shutdown.
Run `python3 -m unittest discover -s tests -p test_media.py -v`.
`tests/e2e/test_media_transfer.py` checks the real
HTTP/SSH upload and library listing without pretending the fake renders VR.
Real decode timings and captured stereo output are recorded in
[vr-video.md](vr-video.md). Generated media only; no external player required.
@@ -314,3 +439,63 @@ Chromium rendering and the same helper through HTTP. Close the switcher after
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
## Backup and recovery kit
`tests/test_backup.py` runs the headset-side script `ui/frame_backup_remote.py`
for real against a temporary folder laid out like the Frame's home, with only
SSH faked: path selection and the secrets it must never copy, the manifest and
checksums, restore checks after a reset, restore writing only its own files,
unsafe archives and backup names, and the exact Game Mode command.
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
A full backup and an identical-file Proton save restore were checked on a
real Frame running build `20261015.6141207`; an after-reset restore and
Android data restore remain unverified. See the linked device evidence.
## Game storage
`python3 -m unittest discover -s tests -p test_storage.py -v` exercises the
read-only metadata probe, guarded HTTP endpoint and Node renderer/navigation
regressions. All headset reads are temporary fixtures or mocked SSH. See
[game storage](game-storage.md#verification-and-limits) for coverage and device
verification limits. This suite does not move games or contact a headset.
## Battery monitoring
`tests/test_battery.py` covers real file parsing with temporary sysfs-like
files, source preference, missing/invalid sensors and permission errors.
`tests/control_center.cjs` covers charging states and optional health fields,
alongside existing stale/offline handling. No headset is used. See
[battery monitoring](battery-health.md) for sources, units and limitations.
## Host-key recovery presentation
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
connection/banner/selection functions in Node and checks the recovery CSS guard.
`test_link.py` covers connection failures and short probe reasons with fake SSH
and local sockets. Neither suite uses a real headset.
Browser verification on 2026-10-24 used the current page's markup, styles and
recovery renderers with synthetic changed-key state and two headsets at
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
rule, which the original source-only assertion missed. Check computed wrapping,
the complete explanation, tooltip punctuation, Refresh bounds and selection
after **Use this headset** when changing these styles. This fixture does not
exercise Electron integration or a real SSH identity change.
## PC-only Windows wireless diagnosis
```sh
python3 -m unittest discover -s tests -p 'test_wireless.py' -v
node --test tests/wireless.cjs
```
The checks workflow runs this targeted suite on Windows, including the real
read-only PowerShell query, and the normal server suite includes it on all
platforms. HTTP tests instantiate only a handler, never the app connector.
Fixtures cover duplicate instance identity, unknown states, GUID matching,
localised/denied channel data and 6 GHz ambiguity. No tests contact a real Frame.
A Windows CI runner/VM cannot prove physical Steam dongle behaviour or audio RF
interference. See [sources and limits](windows-wireless-doctor.md).
+8
View File
@@ -101,6 +101,14 @@ Stills and the theatre surround are re-sent after waking. Five minutes
without an accepted frame is reported as an error. Headset-view captures
taken during standby show a flat dark frame, not our screen.
**Optional surround:** if SteamVR refuses the theatre dark surround at startup
or on a retry after standby, playback continues without it and the player log
records `Theatre surround unavailable`. Screen errors still fail playback.
On exit, the player attempts to destroy every owned overlay and calls OpenVR
shutdown even if an overlay's teardown raises an exception. Refusal and
teardown errors are covered by fake-OpenVR tests; a real surround refusal has
not been induced safely. See the [current hardening checks](evidence/media-surround-2026-10-24.md).
![Media panel in Frame Control while a photo plays on the Frame](img/media-ui-panel.png)
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
+101
View File
@@ -0,0 +1,101 @@
# Windows Wireless Adapter doctor
Open **Tools → This PC → Wireless Adapter doctor**, then choose **Read PC
adapters**. It works without a headset connection. It only queries this Windows
PC, with no automatic polling, changes, elevation or UAC prompt. It never
contacts the Frame. A direct `#wireless` link on another platform explains that
Windows is required.
The snapshot shows:
- Exact network PnP instance IDs, including non-present devices, with separate
presence, PnP status/problem code, operational state and media connection state.
- Microsoft Wi-Fi Direct Virtual Adapter candidates and their distinct instance
count. `SWD\WIFIDIRECT\` is enumerator evidence; an English device-name match
is explicitly labelled **Name only**. A repeated row for one ID is counted once.
- PC Wi-Fi interfaces, hardware-interface status and whether Windows has a
default route configured on an interface. Neither a route nor **Up** proves
that Steam uses that adapter. Multiple configured defaults are possible.
- Reported channel and band, when `netsh wlan show interfaces` supplies recognised
English fields and an exact matching interface GUID. Missing, localised,
disconnected or denied readings remain **Unknown**. There is no name-based
channel association. A PC's router channel is not the Steam dongle's channel.
- Possible spectrum overlap when an interface reports **2.4 GHz**, without
claiming interference. With no such reading, overlap stays **Unknown**:
unreported virtual/proprietary radios and audio dongles may still use 2.4 GHz.
Channel 1–14 alone does not identify a band: 6 GHz reuses low channel numbers.
## Why there is no disable button
[夕凪's report](https://x.com/Oo_yuunagi_oO/status/2113904070352343135)
(24 October 2026) says extra Microsoft Wi-Fi Direct Virtual Adapter instances
appear when their wireless adapter becomes unavailable, and disabling the newly
appeared one *seems* to stop recurrence. This is useful owner evidence, but does
not establish which instance would be safe to disable on another PC.
Windows can legitimately create several Wi-Fi Direct virtual interfaces.
A `#2` suffix is not a creation timestamp; non-present, down, or disconnected
status is not proof of an unused instance. The inventory does not reliably map
Steam's active radio/session to an expendable PnP instance. Disabling the wrong
one could interrupt streaming, PC Wi-Fi, Mobile Hotspot or wireless displays.
Frame Control therefore supplies evidence and guidance, with no disable,
re-enable, driver removal or other write endpoint. It never changes security or
location settings, requests administrator access, or bypasses execution policy.
Before any manual changes, retain the exact instance IDs and compare snapshots
before/after a drop with Steam Support. Do not disable a device based only on
this report. A future disable feature needs a verified Steam adapter/session
mapping, fresh protection for primary/active instances, explicit confirmation
for one exact instance, and a tested recovery path that re-enables that same ID.
## Checking a 2.4 GHz audio conflict
[Deko's report](https://x.com/DekoDrak/status/2113412044644897023)
(22 October 2026) describes Razer wireless-headset interference and temporarily
disabling the Steam Frame dongle. It also mentions a PC/Wi-Fi shutdown during
Windows Update and asks whether the dongle caused it. The post does not prove
that connection or specify a measured audio radio channel. Treat the suggested
2.4 GHz conflict as a hypothesis, not a diagnosis of the shutdown.
Try wired audio briefly, separate radio dongles with a USB extension lead, or
compare PC Ethernet / an existing 5 or 6 GHz network. Change one thing at a time
and record whether symptoms recur. The doctor cannot measure RF congestion,
read an audio dongle's channel, identify a faulty driver, or prove a dropout fix.
## Sources, privacy and limits
The single local PowerShell process uses `Get-PnpDevice -Class Net`, its
`-PresentOnly` variant, `Get-NetAdapter -IncludeHidden`, `Get-NetRoute` for
IPv4/IPv6 defaults, and `netsh wlan show interfaces`. Each source reports
availability separately. Access denial, unavailable cmdlets/services and
unsupported features produce partial/unknown results; there is no permission
workaround. Queries run sequentially, so even one snapshot can span a state
change. One probe runs at a time, with a 20-second process timeout.
SSIDs, MAC addresses and raw command/error output are omitted from the API/UI.
PnP IDs and adapter/interface names can identify this PC; review before sharing.
The tool does not send or save reports, and does not add these readings to
telemetry or problem-report diagnostics. Copy relevant text manually if needed.
The shipped basic network-identity probe (`frame_network.py`) finds a gateway,
network name and Tailscale state. It does not inventory virtual adapters or
check channel overlap. The new doctor is a separate, explicit PC query.
## Verification
`tests/test_wireless.py` checks duplicate identity, presence/disabled/link
separation, ambiguous joins, partial sources, privacy filtering, 6 GHz/channel
ambiguity, localisation, missing band and denied output. HTTP tests require the
UI key, reject headset-local mode, and assert no SSH call. `tests/wireless.cjs`
executes the production UI with boundary fixtures: navigation starts no query,
clicks query once, unknown values and failure/retry remain visible, and PnP IDs
and names are escaped as text.
Windows CI also executes the real read-only PowerShell probe against the
runner's network devices. That proves Windows query execution, not a physical
Steam dongle, Wi-Fi Direct session or Razer headset. See the PR for exact CI
results. An existing local Windows 11 VM passed 15 targeted tests (one UI test skipped
because Node is absent), including the live query. PnP, presence, adapters and
routes were available; WLAN was unavailable in this VM. See
[VM evidence](evidence/windows-wireless-2026-10-24.md). No Frame was accessed; no physical
adapter/dropout fix or RF measurements are claimed.
+5
View File
@@ -55,6 +55,7 @@ struct WebShell: UIViewRepresentable {
},
notify: (message, request) => call("notify", { message, request }),
readClipboard: () => call("readClipboard"),
writeClipboard: (text) => call("writeClipboard", text),
setUpConnection: () => call("setUpConnection"),
open: (what) => call("open", what),
saveImages: (images) => call("saveImages", images),
@@ -143,6 +144,10 @@ struct WebShell: UIViewRepresentable {
replyHandler(true, nil)
case "readClipboard":
replyHandler(UIPasteboard.general.string ?? "", nil)
case "writeClipboard":
guard let text = arg as? String else { return replyHandler(nil, "Expected text") }
UIPasteboard.general.string = text
replyHandler(true, nil)
case "setUpConnection":
model.showSetup()
replyHandler(nil, nil)
+6 -1
View File
@@ -73,7 +73,11 @@ discover_devkit() {
pick_host() {
local candidates=()
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
[[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
if [[ -z "$host_arg" ]]; then
local saved=$(ssh -G "$FRAME_ALIAS" 2>/dev/null | awk '$1 == "hostname" {print $2; exit}')
[[ -n "$saved" && "$saved" != "$FRAME_ALIAS" && "$saved" =~ $HOST_RE ]] && candidates+=("$saved")
candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
fi
local h
for h in "${candidates[@]}"; do
if reachable "$h"; then
@@ -224,6 +228,7 @@ pair_with_devkit() {
print "==> Looking for the Steam Frame"
if ! HOST=$(pick_host); then
print -u2 "Could not reach the Frame on port 22 or $DEVKIT_PORT."
print -u2 "SteamOS 0.5.6 stopped publishing .local names; use its IP from Quick Settings."
print -u2 "Check: Developer Mode on + user password set; same Wi-Fi; no client isolation."
print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y"
exit 1
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Read-only diagnostics, repair plans and explicit local before/after reports; see docs/frame-doctor.md.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
exec python3 "$root/scripts/frame_doctor.py" "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Read-only, bounded power/heat sampler. See docs/power-and-heat.md.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
exec python3 "$root/scripts/frame_power_sample.py" "$@"
+404
View File
@@ -0,0 +1,404 @@
"""Read-only doctor. Also sent over SSH on stdin; never installed on the Frame.
Only fixed summaries leave the probe: no raw journals, addresses, account IDs,
process arguments, environment, key material or HTTP response bodies.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import socket
import subprocess
import sys
import time
SECTIONS = ('Boot and crash history', 'Wi-Fi', 'Displays and SteamVR',
'Steam and storage', 'Idle sleep and keep-awake', 'Remote wake',
'Lepton (Android)', 'Chromium XR', 'KDE Connect',
'Streaming and capture', 'Panels')
def run(args, timeout=4, **kwargs):
try:
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, **kwargs)
except (OSError, UnicodeError, subprocess.TimeoutExpired):
return None
def command(*args):
r = run(list(args))
return r.stdout if r is not None and r.returncode == 0 else None
def journal(*args):
# Even exit 0 can mean the account cannot see the system journal.
r = run(['journalctl', '--no-pager', '-q', '-n', '2000', *args])
if r is None or r.returncode or r.stderr.strip() or not r.stdout.strip():
return None
return r.stdout
def snapshot():
values = []
for unit in ('steam', 'steamvr'):
s = command('systemctl', '--user', 'show', unit + '.service',
'-p', 'NRestarts', '--value')
if s is None or not s.strip().isdigit():
return None
values.append(int(s.strip()))
p = Path('/run/user') / str(os.getuid()) / (unit + '-short-session-tracker')
try:
values.append(p.stat().st_size)
except FileNotFoundError:
values.append(0)
except OSError:
return None
return values
def guard():
first = snapshot()
time.sleep(2)
second = snapshot()
if first is None or second is None:
return 'unknown'
if any(b > a for a, b in zip(first, second)):
return 'loop'
if first != second:
return 'unknown' # counters reset or tracker replaced during the sample
if second[1] or second[3]:
return 'risk' # avoid a long run even if a short sample missed the loop
return 'stable'
def signal_check(text, pattern, problem, healthy):
if text is None:
return ['UNKNOWN', 'Evidence unavailable; consult the runbook locally.']
return ['WARN', problem] if re.search(pattern, text, re.I) else ['OK', healthy]
def storage():
# devkit-game may be a separate mount (including in the fake Frame).
try:
paths = [Path.home()]
games = Path.home() / 'devkit-game'
if games.exists():
paths.append(games)
free = min(shutil.disk_usage(p).free for p in paths)
if free < 16 * 1024**2:
return ['BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.']
if free < 256 * 1024**2:
return ['WARN', 'Less than 256 MiB free; review storage before large installs.']
return ['OK', 'Home and title storage have at least 256 MiB free.']
except OSError:
return ['UNKNOWN', 'Storage could not be read.']
def probe():
state = guard()
if state != 'stable':
return {'guard': state, 'checks': []}
kernel = journal('-b', '-k', '--since=-10min')
recent = journal('-b', '--since=-10min')
previous = journal('-b', '-1', '-k')
crashes = command('coredumpctl', 'list', '--no-pager', '--since=-1d')
checks = []
boot = signal_check(previous, r'Unable to handle kernel|Internal error|Kernel panic',
'Previous boot has a kernel oops; this alone does not prove an unclean reset.',
'No kernel-oops signature in the sampled previous boot.')
if crashes is None and boot[0] != 'WARN':
boot = ['UNKNOWN', 'Crash history unavailable; inspect boot history locally.']
elif crashes is not None and boot[0] != 'WARN' and re.search(r'SIG(SEGV|ABRT)|\b(SEGV|ABRT)\b', crashes):
boot = ['WARN', 'Crash records exist in the last day; inspect locally using the runbook.']
checks.append(boot)
wifi = command('nmcli', '-t', '-f', 'DEVICE,TYPE,STATE', 'device')
checks.append(signal_check(wifi if wifi and 'wlan0:wifi:' in wifi else None, r'wlan0:wifi:(unavailable|disconnected|failed)',
'Wi-Fi is unavailable or disconnected; check Steam Settings > Internet. Never reload ath12k.',
'No unavailable/disconnected wlan0 reported; other interfaces may differ.'))
checks.append(signal_check(kernel, r'wait for video done',
'Display timeouts observed; follow display recovery before touching Steam.',
'No display-timeout signature in recent kernel sample.'))
if checks[-1][0] != 'WARN' and recent is not None and re.search(r'left-over process|steamvr.service: Failed|!modifiers.empty', recent):
checks[-1] = ['WARN', 'SteamVR/session failure signature observed; inspect locally before restarting anything.']
disk = storage()
if disk[0] == 'OK' and not (Path.home() / '.local/share/Steam/.install-complete').exists():
disk = ['WARN', 'Steam install marker is missing; do not restart Steam for diagnosis. Consult the runbook.']
checks.append(disk)
awake = command('systemctl', '--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value')
checks.append(['UNKNOWN', 'Keep-awake state unavailable.'] if awake is None or not awake.strip() else
['WARN', 'Keep-awake is active; leave it with its owner.'] if awake.strip() == 'active' else
['OK', 'Keep-awake is inactive; diagnostics do not inhibit sleep.'])
wow = command('iw', 'phy', 'phy0', 'wowlan', 'show')
checks.append(['UNKNOWN', 'WoWLAN state unavailable; remote wake is not verified.'] if wow is None else
['OK', 'WoWLAN is disabled; wake the headset with its power button.'] if 'WoWLAN is disabled' in wow else
['WARN', 'WoWLAN may be armed; review the runbook. Remote wake is not reliable.'])
checks.append(signal_check(recent, r'Listen failed for HOST TCP|binder_user_error|Clearing baked app data',
'Android failure signature observed; inspect Lepton locally before retrying.',
'No known Android failure signature in recent journal sample.'))
checks.append(signal_check(crashes, r'chromium|/chrome\b',
'Chromium crash history found; update/relaunch the panel through its normal UI.',
'No Chromium crash signature in available crash history.'))
kde = run(['pgrep', '-f', 'frame-control/kdeconnect/root/usr/lib/kdeconnectd'])
checks.append(['UNKNOWN', 'KDE Connect process check unavailable.'] if kde is None or kde.returncode not in (0, 1) else
['OK', 'KDE Connect process found.'] if kde.returncode == 0 else
['WARN', 'KDE Connect is not running; normal if keyboard/trackpad sharing is off.'])
checks.append(signal_check(crashes, r'ffmpeg',
'Capture crash history found; consult software-encoder guidance in the runbook.',
'No ffmpeg crash signature in available crash history.'))
checks.append(['UNKNOWN', 'Panel placement needs visual confirmation; open one panel at a time.'])
# A short sample is not proof that Steam is healthy. Recheck after collecting.
after = guard()
return {'guard': after, 'checks': checks if after == 'stable' else []}
SSH_OPTIONS = ['BatchMode=yes', 'NumberOfPasswordPrompts=0', 'StrictHostKeyChecking=yes',
'UpdateHostKeys=no', 'CheckHostIP=no', 'ConnectTimeout=3', 'ConnectionAttempts=1',
'ControlMaster=no', 'ControlPath=none', 'ClearAllForwardings=yes',
'PermitLocalCommand=no', 'RemoteCommand=none', 'RequestTTY=no',
'ProxyCommand=none', 'ProxyJump=none', 'ForwardAgent=no', 'ForwardX11=no']
def ssh_args(alias, host=None, identity=None):
args = ['ssh']
for opt in SSH_OPTIONS:
args += ['-o', opt]
if host:
args += ['-o', 'HostName=' + host, '-o', 'HostKeyAlias=' + identity]
return args + [alias]
def failure(text):
# Never echo SSH stderr: it can contain addresses, paths or remote text.
if 'REMOTE HOST IDENTIFICATION HAS CHANGED' in text:
return 'SSH identity changed; use Devices > Check its key after verifying the headset. Do not delete known_hosts.'
if 'Host key verification failed' in text:
return 'SSH identity is untrusted; verify it through Set Up Connection or Devices > Check its key.'
if 'Permission denied' in text:
return "SSH key rejected; use Set Up Connection, then Developer > Pair new host in the headset."
return 'No SSH response; wake the headset, check the network/cable and enable Developer Mode.'
def port_open(host, port):
try:
with socket.create_connection((host, port), timeout=2):
return True
except OSError:
return False
def report(data):
state = data.get('guard')
if state != 'stable':
status = 'BROKEN' if state == 'loop' else 'WARN' if state == 'risk' else 'UNKNOWN'
message = {'loop': 'Counters are rising. Stop diagnostics; use the runbook health-check recovery procedure.',
'risk': 'Health-check trackers contain failures. Deeper checks skipped; review the runbook before recovery.'}
print(status + ' Restart safety: ' + message.get(state, 'Cannot verify restart counters; deeper checks skipped for safety.'))
for i, label in enumerate(SECTIONS, 1):
print(f'UNKNOWN {i}. {label}: Skipped until restart safety is verified.')
return 1
checks = data.get('checks')
# Treat the remote endpoint as untrusted. Only accept pairs from our own
# fixed vocabulary by matching against the production probe's messages.
if not isinstance(checks, list) or len(checks) != len(SECTIONS):
print('UNKNOWN Probe: Incomplete diagnostic response; no raw output displayed.')
return 1
for check in checks:
if (not isinstance(check, list) or len(check) != 2 or check[0] not in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')
or not isinstance(check[1], str) or check[1] not in MESSAGES):
print('UNKNOWN Probe: Unexpected diagnostic response; no raw output displayed.')
return 1
print('OK Restart safety: No rising counters during short samples; this is not a health guarantee.')
for i, (label, (status, message)) in enumerate(zip(SECTIONS, checks), 1):
print(f'{status} {i}. {label}: {message}')
return int(any(c[0] != 'OK' for c in checks))
def valid_data(data):
"""Validate before saving, comparing or deriving any recommendation."""
if (not isinstance(data, dict) or set(data) != {'guard', 'checks'}
or data.get('guard') not in ('stable', 'loop', 'risk', 'unknown')):
return False
checks = data.get('checks')
if data['guard'] != 'stable':
return checks == []
return (isinstance(checks, list) and len(checks) == len(SECTIONS)
and all(isinstance(c, list) and len(c) == 2
and c[0] in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')
and isinstance(c[1], str) and c[1] in MESSAGES for c in checks))
def repair_plan(data):
print('Repair plan (READ-ONLY): no commands executed; no automatic repair is available.')
if data['guard'] != 'stable':
print('BLOCKED: Restart safety is unverified or unsafe. Needs your hands: review the runbook health-check recovery with the headset user; do not restart services.')
return
# Each action needs the exact section, severity AND recognized evidence.
actions = {
(1, 'WARN', 'Wi-Fi is unavailable or disconnected; check Steam Settings > Internet. Never reload ath12k.'):
'Needs your hands: check Steam Settings > Internet. Do not reload ath12k.',
(2, 'WARN', 'Display timeouts observed; follow display recovery before touching Steam.'):
'Needs your hands: inspect the display in-headset and review display recovery before any Steam action.',
(3, 'BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.'):
'Needs your hands: review Game storage and use Steam storage management after choosing what to keep; no files are deleted by doctor.',
(3, 'WARN', 'Less than 256 MiB free; review storage before large installs.'):
'Needs your hands: review Game storage before installing; choose any storage changes yourself.',
(3, 'WARN', 'Steam install marker is missing; do not restart Steam for diagnosis. Consult the runbook.'):
'Needs your hands: inspect Steam installation/extraction locally with the runbook. A missing marker does not authorize repair or restart.',
(4, 'WARN', 'Keep-awake is active; leave it with its owner.'):
'Leave keep-awake with its owner; do not take ownership or change idle settings.',
}
for i, (status, message) in enumerate(data['checks']):
if status == 'OK':
continue
action = actions.get((i, status, message))
if action is None:
action = ('Collect missing evidence locally; no repair is justified.' if status == 'UNKNOWN'
else 'Review this signal locally with the runbook; it does not establish a safe automatic fix.')
print(f'PLAN {i + 1}. {SECTIONS[i]}: {action}')
def compare(before, after):
print('Before/after diagnostics: observations only; changes do not prove repair or causation.')
print('Restart safety: ' + before['guard'] + ' -> ' + after['guard'])
if before['guard'] != 'stable' or after['guard'] != 'stable':
print('Comparison blocked: deeper checks were skipped in at least one sample; no improvement claimed.')
return
for i, label in enumerate(SECTIONS):
a, b = before['checks'][i], after['checks'][i]
detail = 'unchanged' if a == b else 'evidence changed'
print(f'{i + 1}. {label}: {a[0]} -> {b[0]} ({detail})')
if a != b:
print(' Before evidence: ' + a[1])
print(' After evidence: ' + b[1])
def load_baseline(path):
# Local reports are untrusted too; bound input and never echo file contents.
with open(path, 'rb') as stream:
raw = stream.read(65537)
if len(raw) > 65536:
raise ValueError()
saved = json.loads(raw)
if (not isinstance(saved, dict) or saved.get('version') != 1
or not isinstance(saved.get('target'), str)
or not re.fullmatch('[a-f0-9]{64}', saved['target'])
or not valid_data(saved.get('diagnostics'))):
raise ValueError()
return saved
def main(argv=None):
parser = argparse.ArgumentParser(description='Read-only Frame setup diagnostics. Never pairs, repairs or asks for a password.')
parser.add_argument('--alias', default='frame', help='Existing trusted SSH alias (default: frame)')
parser.add_argument('--lan', help='Optional LAN IP or host name; SteamOS 0.5.6 no longer publishes .local names')
parser.add_argument('--usb', default='10.86.200.233', help='USB fallback IP; use only with the intended headset connected')
parser.add_argument('--plan', action='store_true', help='Print a read-only repair plan from recognized evidence')
parser.add_argument('--save-report', metavar='FILE', help='Create a local JSON baseline (never overwrite an existing file)')
parser.add_argument('--compare-before', metavar='FILE', help='Compare a saved baseline with this fresh read-only run')
args = parser.parse_args(argv)
baseline = None
if args.compare_before:
try:
baseline = load_baseline(args.compare_before)
except (OSError, ValueError, TypeError, UnicodeError, RecursionError):
print('UNKNOWN Baseline: Cannot read a valid doctor report; no comparison performed.')
return 2
for value in (args.alias, args.lan, args.usb):
if value is None:
continue
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,252}', value):
parser.error('Use a plain alias, hostname or IP address.')
print('Frame doctor: read-only; no passwords, pairing, repairs or raw logs. Reports are saved only with --save-report.')
if not shutil.which('ssh'):
print('BROKEN Setup: Install an OpenSSH client, then run Set Up Connection.')
return 2
config = run(ssh_args(args.alias)[:-1] + ['-G', args.alias])
if config is None or config.returncode:
print('UNKNOWN Setup: SSH configuration could not be read; check it locally.')
return 2
settings = dict(line.split(' ', 1) for line in config.stdout.splitlines() if ' ' in line)
identity = settings.get('hostkeyalias') or settings.get('hostname', args.alias)
# Bind reports to the configured logical SSH target, across LAN/USB fallback.
# This is configuration continuity, not proof of physical headset identity.
target = hashlib.sha256(json.dumps([args.alias, identity, settings.get('hostname'),
settings.get('user'), settings.get('port'), args.lan, args.usb]).encode()).hexdigest()
if baseline is not None and baseline['target'] != target:
print('UNKNOWN Baseline: SSH target configuration differs; comparison refused.')
return 2
source = Path(__file__).read_text()
routes = [('configured alias (Tailscale if configured)', None)]
if args.lan:
routes.append(('LAN', args.lan))
routes.append(('USB-C', args.usb))
for label, host in routes:
result = run(ssh_args(args.alias, host, identity) + ['python3 -B - --probe'], timeout=90, input=source)
if result is not None and result.returncode == 0:
try:
data = json.loads(result.stdout)
if not valid_data(data):
raise ValueError()
except (ValueError, RecursionError):
print('UNKNOWN Probe: Invalid response; check Python 3 availability on the headset. No raw output displayed.')
return 1
print('OK Connection: ' + label + '; verified SSH identity and key login.')
code = report(data)
if args.plan:
repair_plan(data)
if baseline is not None:
compare(baseline['diagnostics'], data)
if args.save_report:
try:
# Exclusive create prevents clobbering a baseline or following a symlink.
fd = os.open(args.save_report, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as stream:
json.dump({'version': 1, 'target': target, 'diagnostics': data}, stream)
stream.write('\n')
print('OK Report: Local baseline created; review it before sharing.')
except OSError:
print('UNKNOWN Report: Could not create local baseline; existing files are never overwritten.')
return 2
return code
if result is not None and result.returncode != 255:
print('UNKNOWN Probe: SSH connected but diagnostics failed; check Python 3 on the headset.')
return 1
reason = failure(result.stderr if result is not None else '')
print('WARN Connection (' + label + '): ' + reason)
# No /register call: an open devkit port cannot prove pairing mode is enabled.
hosts = (settings.get('hostname', args.alias), args.lan, args.usb)
if any(port_open(host, 32000) for host in hosts):
print('WARN Pairing: Devkit port responds. Use Set Up Connection and Developer > Pair new host; no pairing request was sent.')
else:
print('UNKNOWN Pairing: Devkit port did not respond. Needs your hands: wake headset, enable Developer Mode and check its address.')
for i, label in enumerate(SECTIONS, 1):
print(f'UNKNOWN {i}. {label}: No authenticated SSH path.')
return 2
# Derive the output allowlist from literal strings, not remote data. This module
# has no dynamic diagnostic messages. AST stays stdlib and runs only locally.
def messages():
import ast
tree = ast.parse(Path(__file__).read_text())
allowed = set()
for function in tree.body:
if not isinstance(function, ast.FunctionDef) or function.name not in ('probe', 'storage', 'signal_check'):
continue
for node in ast.walk(function):
if (isinstance(node, ast.List) and len(node.elts) == 2
and all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in node.elts)
and node.elts[0].value in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')):
allowed.add(node.elts[1].value)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == 'signal_check':
allowed.update(arg.value for arg in node.args[2:4] if isinstance(arg, ast.Constant))
return allowed
if __name__ == '__main__':
if sys.argv[1:] == ['--probe']:
print(json.dumps(probe()))
else:
MESSAGES = messages()
sys.exit(main())
+54
View File
@@ -0,0 +1,54 @@
"""Offline, accessible power report. No network requests or external resources."""
import base64
import html
import json
def render(report, csv_text):
def esc(value):
return html.escape(str(value), quote=True)
def download(text, mime, filename, label):
data = base64.b64encode(text.encode('utf-8')).decode('ascii')
return f'<a download="{filename}" href="data:{mime};base64,{data}">{label}</a>'
rows = []
for metric, item in report['summary'].items():
numeric = 'mean' in item
values = [item.get(key) for key in ('min', 'mean', 'max', 'last')]
cells = ''.join(f'<td>{esc(value) if value is not None else "Unknown"}</td>' for value in values)
states = ', '.join(item.get('states', [])) or ('Numeric' if numeric else 'Unknown')
rows.append(f'<tr><th scope="row">{esc(metric)}</th><td>{esc(item["unit"])}</td>'
f'{cells}<td>{item["available"]} / {item["unavailable"]}</td>'
f'<td>{esc(states)}</td><td>{esc(", ".join(item["sources"]))}</td></tr>')
missing = sorted({g for sample in report['samples'] for g in sample['unavailableGroups']})
json_text = json.dumps(report, allow_nan=False, indent=2)
links = download(json_text, 'application/json', 'frame-power.json', 'Download JSON samples and summary')
links += ' · ' + download(csv_text, 'text/csv', 'frame-power.csv', 'Download CSV samples')
return f'''<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>Frame power and heat report</title>
<style>body{{font:16px system-ui,sans-serif;line-height:1.5;margin:24px;color:#18212b;background:#fff}}
a{{color:#0645ad}}a:focus-visible{{outline:3px solid #0645ad;outline-offset:4px}}
table{{border-collapse:collapse;width:100%;min-width:1100px}}th,td{{padding:8px;text-align:left;border:1px solid #adb5bd}}
thead th,td{{white-space:nowrap}}th[scope=row]{{min-width:240px;max-width:300px;overflow-wrap:anywhere}}
td:last-child{{min-width:220px;max-width:300px;white-space:normal;overflow-wrap:anywhere}}
thead{{background:#edf1f5}}.scroll{{overflow:auto}}h1{{line-height:1.2}}</style>
<main><h1>Frame power and heat report</h1>
<p>Headset: {esc(report.get("headset", {}).get("alias", "frame"))}</p>
<p>{len(report['samples'])} of {report['requestedSamples']} requested samples received; target interval {esc(report['intervalSeconds'])} seconds.</p>
<p>Collection: {esc(report['stopped'] or 'Completed')}. Results describe only the samples received.</p>
<p>{links}</p>
<p>Unknown means missing, unreadable or invalid, never zero. Means exclude unknown values and are not time-weighted.
Battery watts: positive charging, negative discharge. C means °C, W watts, A amps, MHz megahertz, RPM revolutions per minute.
Raw readings have no inferred percentage or worn threshold. Rails overlap: do not sum them.</p>
<p>Unavailable sensor groups in one or more samples: {esc(', '.join(missing) or 'None reported')}.</p>
<div class="scroll" role="region" aria-label="Metric summary, scroll horizontally for all columns" tabindex="0">
<table><caption>Received measurements, with units and availability counts</caption>
<thead><tr><th scope="col">Metric</th><th scope="col">Unit</th><th scope="col">Min</th><th scope="col">Mean</th><th scope="col">Max</th><th scope="col">Last</th><th scope="col">Available / unknown</th><th scope="col">States</th><th scope="col">Sources</th></tr></thead>
<tbody>{''.join(rows)}</tbody></table></div>
<p>Sensor accuracy is unverified. Suspect negative thermistors remain visible. No temperature safety limits,
energy use or battery-runtime estimates are inferred. JSON includes actual timing, OS builds and per-sample availability.</p>
<p>This file stays on your computer and works offline. Downloaded files contain sensor source paths and OS builds;
review them before sharing. No settings, services, security or passwords were changed.</p></main></html>'''
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more