Compare commits

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 22:43:51 +11:00
saphid c305daae15 Merge remote-tracking branch 'origin/main' into tmp/rdp61
# Conflicts:
#	ui/frame_host.py
#	ui/server.py
2026-10-05 22:41:12 +11:00
Alex Southwell a4031db052 Merge pull request #60 from saphid/fix/windows-test-suite
Windows: stop ssh/scp/ssh-keygen hanging when stderr is captured
2026-10-05 22:39:14 +11:00
saphidandClaude Opus 5.5 049d50f43a Merge main into fix/contact-email-review-followups
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:38:54 +10:00
saphidandClaude Opus 5.5 3a95d3b638 privacy.md: a report saves the address first; sending it may wait
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:36:46 +10:00
saphidandClaude Opus 5.5 989962aecc Contact email: a report's rev is its own change; another address starts fresh
- from_report applies its change and reads the id and rev together, so a
  removal made while that change is sending is newer than the report; the
  report's redaction window now starts before the address is saved.
- A report with a different address replaces the saved one with follow-up
  questions only: update notices aren't carried over to an address nobody
  agreed them for, and the form says so before sending.
- Settings refreshes after every report send, whatever the box shows by then.
- privacy.md: a report with follow-up ticked also saves and sends the address.

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:51:24 +10:00
saphid f73efe414c Merge main into fix/server-stdin-abort 2026-09-30 22:39:14 +10:00
Alex Southwell 704e5d7780 Merge pull request #59 from saphid/feat/contact-email-opt-in
Optional contact email with separate update and follow-up consent
2026-09-30 22:09:42 +10:00
Alex Southwell edbe8d4109 Merge pull request #63 from saphid/screenshot-copy
Screenshots: Copy, right-click menu, and new shots appear on their own
2026-09-30 20:43:22 +10:00
saphidandClaude Opus 5.5 f2c8466ba9 Screenshots: Refresh retries every failed preview, even if a background check lands meanwhile
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:32:28 +10:00
saphidandClaude Opus 5.5 6cf01729e2 Screenshots: fixes from review
- A right-click menu open when the headset changes closes, so it can't act on the other headset's shot.
- A preview being retried by Refresh is no longer dropped when a background check lands first.
- A late failure from a headset switched away from no longer drops the new headset's preview.
- Tab and Escape close the menu and give focus back to where it was.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:08:36 +10:00
Alex Southwell fa6d4fd81b Merge pull request #47 from saphid/linux-vr-streaming
docs: repeat Frame streaming client feasibility under test lock
2026-09-29 12:54:50 +10:00
Alex Southwell 7cc4abaffa Merge pull request #45 from saphid/devices
Several headsets, several addresses each, a Devices tab, and live connection status
2026-09-29 12:53:28 +10:00
saphidandClaude Opus 5.5 02b9413f78 Merge main into devices: several headsets alongside the app store, comfort, panels and media
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:44:30 +10:00
Alex Southwell 1a08258e3d Merge pull request #49 from saphid/theatre-media-device-fixes
Media player: keep playing through headset standby (real-Frame test fixes)
2026-09-29 12:40:35 +10:00
Alex Southwell a84d6b912b Merge pull request #44 from saphid/apk-store-fixes
Android game store: every source in one search, and proper Steam library entries
2026-09-29 12:39:16 +10:00
saphidandClaude Opus 5.5 1cd56740a6 Media player: keep retrying the theatre surround after a still is shown
Review (GPT-6 Astra, P2): the still-image loop stopped calling show() once
the screen took its first frame, so a surround refused during standby was
never retried and stayed missing for PNG and splat playback until restart.
hold() now keeps draining pending uploads after the screen is shown, until
both are up.

Also: stills and the surround wait out standby without counting as dropped
video frames or tripping the five-minute limit (video only); teardown
errors no longer overwrite a finished status; Stop is ignored once the
outcome is decided.

Tests: PNG and splat where the screen is accepted before the surround
recovers (fail on the old loop); fake-clock coverage of the five-minute
limit and its reset; status keeps filename/metadata layout sources and
explicit layouts stay explicit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:32:12 +10:00
saphidandClaude Opus 5.5 b685a601f7 Mac view: checking the headset and publishing a tunnel happen under one short lock with retarget
Astra review: a switch landing between the check and the assignment could still
install the old headset's tunnel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:31:47 +10:00
Alex Southwell 31de17aab8 Merge pull request #48 from saphid/frame-mcp-verified
Record real-Frame MCP end-to-end results
2026-09-29 12:27:26 +10:00
saphidandClaude Opus 5.5 ffef4d897a Devices: the assistant's keep-awake and panel tools reach the chosen headset; a Mac view tunnel opened during a switch is dropped
Integration review findings: the scripts they run ssh'd to whatever 'frame' means
in ~/.ssh/config. They now take FRAME_ALIAS and FRAME_SSH_OPTS from the server's route.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:16:25 +10:00
saphidandClaude Opus 5.5 094c12c6d8 Keep media playing through headset standby
Real-Frame testing (2026-09-29) found an unworn headset enters standby
within seconds; SetOverlayRaw then returns RequestFailed (23) and the
movie died. The player now drops frames during standby, keeps audio
and pacing, re-sends stills and the theatre surround after waking, and
only errors after five minutes without an accepted frame.

A Stop arriving while the player is already shutting down is ignored,
so a finished video stays 'ended' instead of 'error: Stopped'. The
status now reports the layout's real source (filename/metadata).

Docs record the end-to-end device matrix (API, web UI, CLI).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:54:05 +10:00
saphidandClaude Opus 5.5 c46bf68dd0 Record real-Frame MCP end-to-end results, including approved mutations
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:15:07 +10:00
saphid dc4a64a9c3 docs: repeat streaming client checks under Frame test lock 2026-09-29 11:07:02 +10:00
saphidandClaude Opus 5.5 08fbe730c6 Merge main into devices: switching headset stops the keyboard agent and retargets the Mac view
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:37:45 +10:00
saphidandClaude Opus 5.5 1cf353f419 Tests: give Windows time to refuse a closed loopback port
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:34:11 +10:00
saphidandClaude Opus 5.5 c0183ca8b2 Tests: the private ControlPath is per headset too
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:22:33 +10:00
saphidandClaude Opus 5.5 bf8a478063 Devices with the Mac view and the MCP adapter: the tunnel follows the headset, a private server runs alongside
The Mac view's tunnel is its own ssh, so it now takes the headset's route (and its
pinned identity, which also checks the USB-C address), and closes when the app
switches headset. The MCP adapter's private server (FRAME_PRIVATE_SSH=1) skips the
one-server lock and can't add, remove or switch headsets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:19:48 +10:00
saphid 215bc357ef Merge remote-tracking branch 'origin/main' into devices 2026-09-29 10:14:51 +10:00
saphidandClaude Opus 5.5 7d6ff7f919 Merge main into devices: MCP, analytics, Mac view alongside several headsets
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:14:50 +10:00
saphidandClaude Opus 5.5 1343900aa1 Devices: placeholder IPv6 in a validation test
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:42:15 +10:00
saphidandClaude Opus 5.5 d2a5db7caf Devices: no real tailnet addresses in tests or screenshots
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:42:04 +10:00
saphidandClaude Opus 5.5 a08ba6f65b Docs: screenshots of the Devices tab and the connection pill
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:51:44 +10:00
saphidandClaude Opus 5.5 53c51e1888 Devices: restarting during startup starts afresh; a headset capture keeps its headset through clean-up (review round 33)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:42:19 +10:00
saphidandClaude Opus 5.5 72e4ccf080 App: overlapping restarts and server starts share one (review round 32)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:35:19 +10:00
saphidandClaude Opus 5.5 a9740ad6f2 Devices: the app waits for its old server before starting the new one; clipboard sends keep their headset (review round 31)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:27:52 +10:00
saphidandClaude Opus 5.5 cb6f294299 Devices: one Frame Control server per user
Two servers each connected, reconnected and edited the headsets on their own,
and several review findings were ways one could move the other's install to a
different headset. A lock file in the data folder now refuses a second server
with a plain message; FRAME_CONTROL_DATA_DIR still gives a separate one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:17:43 +10:00
saphidandClaude Opus 5.5 b87be6866b Devices: while an install runs, nothing elsewhere moves it to another headset (review round 29)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:08:04 +10:00
saphidandClaude Opus 5.5 747dbcf72f Devices: route to the saved headset before serving; a headset removed elsewhere mid-install reaches nothing (review round 28)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:01:32 +10:00
saphidandClaude Opus 5.5 409e328880 Devices: each headset its own SSH connection, and each server keeps its own headset (review round 27)
ssh's %C hashes only address, user and port, so two headsets reached at one
address shared a ControlMaster and one's commands could run on the other: the
ControlPath now names the headset. Another Frame Control server choosing a
different headset no longer moves this one's commands mid-install.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:52:28 +10:00
saphidandClaude Opus 5.5 c5a614d989 Devices: two servers sharing devices.json can't save over each other's changes (review round 26)
Every change now takes a lock file shared across processes and starts from
what's on disk; reads pick up a newer file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:42:46 +10:00
saphidandClaude Opus 5.5 0f33b4f094 Devices: saving port 22 overrides a port inherited from a later Host entry (review round 25)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:35:07 +10:00
saphidandClaude Opus 5.5 49378b2c80 Devices: fixes from review round 24
- While the connector is taking a queued reconnect off its list, the old
  connection no longer counts as live, so no install starts on it.
- Importing a block without a Port takes the port ssh would really use
  (ssh -F <config> -G), e.g. one a later Host * sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:13:15 +10:00
saphidandClaude Opus 5.5 22863f2f87 Devices: match the host in ssh's login line case-insensitively (review round 23)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:01:29 +10:00
saphidandClaude Opus 5.5 b779376f5b Devices: fixes from review round 22
- An upload's answer arriving after a switch opens nothing; the APK
  alternatives dialog installs on the headset the APK was checked for.
- A handshake that goes silent (e.g. a jump host's forward hanging) moves on
  to the next address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:53:18 +10:00
saphidandClaude Opus 5.5 8bd8d63546 Devices: fixes from review round 21
- Behind a jump host, a forward it couldn't open moves on to the next address;
  only a refused key stops (judged by ssh's words, not the step).
- Add a headset suggests an alias no Host in ~/.ssh/config already uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:44:18 +10:00
saphidandClaude Opus 5.5 42b51afc5a Devices: fixes from review round 20
- A jump host's own "Authenticated to" line no longer counts as the headset's,
  and a master that logs in but doesn't start lets the next address be tried.
- Devices tab changes refresh through the ordered list load, so a late answer
  can't undo a newer selection.
- A probe's time out starts after the name lookup: macOS can take 5 s to look
  up a .local name (found on the real Frame once its USB link went away).
- An attempt's ending is published from a method, not a return in finally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:35:29 +10:00
saphidandClaude Opus 5.5 6597a90059 Devices: fixes from review round 19
- A switch the server refuses no longer drops answers the page is waiting for
  (an install's job id): only an actual change of headset does.
- Test now goes through a jump host when the alias uses one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:25:10 +10:00
saphidandClaude Opus 5.5 c3e3f2629c Devices: fixes from review round 18
- A set-up headset whose alias goes through a jump host (ProxyJump or
  ProxyCommand in ~/.ssh/config) is reached through it, address by address,
  still pinned per headset.
- A refused switch puts the header's switcher back on the headset in use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:17:29 +10:00
saphidandClaude Opus 5.5 90fd2684ba Devices: fixes from review round 17
- A command that fails after a switch doesn't make the connector drop the new
  headset's connection.
- On first import, the app keeps using the `frame` headset even when Set Up
  Connection put another block above it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:08:36 +10:00
saphidandClaude Opus 5.5 fa05a4b310 Devices: fixes from review round 16
- Terminals, power and a reconnect's probes use the route commands have now
  (a pinned bare-alias destination, a login change still deferred).
- Saving port 22 keeps an explicit Port line where there was one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:00:00 +10:00
saphidandClaude Opus 5.5 93ebd34f2c Devices: fixes from review round 15
- A bare alias's route is pinned to where ~/.ssh/config sent it when it was
  routed (HostName, Port, User), so editing that file can't move an install.
- Renaming during an install is allowed: only a real user or port change waits.
- The Devices tab follows a network change even while the headset is offline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:51:12 +10:00
saphidandClaude Opus 5.5 34e91988b1 Devices: fixes from review round 14
- Retry now (while connected) and Forget identity wait for running installs.
- Terminal windows get the headset's address by name, so a link-local IPv6
  zone never has to pass through Windows' console.
- Renaming the headset in use shows at once in the header.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:40:10 +10:00
saphidandClaude Opus 5.5 5874fe33f6 Devices: fixes from review round 13
- Every command to a set-up headset checks its pinned key
  (StrictHostKeyChecking=yes, whatever ~/.ssh/config says); only the
  connector's first handshake may save one.
- A reconnect during an install keeps the whole route it started with, also
  when a bare alias is set up meanwhile.
- Removing the headset FRAME_ALIAS named doesn't bring it back as a bare alias.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:31:22 +10:00
saphidandClaude Opus 5.5 98ef6944c9 Devices: fixes from review round 12
- A reconnect while an install runs keeps the login it started with; a new
  one from ~/.ssh/config applies after.
- Frame > Open SSH goes through the server, so it uses the same headset and
  address as the app and refuses when there's none.
- A bare frame alias in use when a headset is set up stays selectable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:21:19 +10:00
saphidandClaude Opus 5.5 2e9bc8624b Devices: fixes from review round 11
- A headset set up while a bare alias is in use doesn't take over by itself;
  a login change from ~/.ssh/config waits for running installs.
- Saving a headset writes only the login fields that changed, and only if the
  block still holds the old ones.
- SSH, SFTP, power and remote desktop open with the same headset and address
  as every other command, and refuse when there's no address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:12:41 +10:00
saphidandClaude Opus 5.5 c69ea6266f Devices: fixes from review round 10
- Removing or moving the active headset's address waits for running installs,
  like switching.
- A volume change still waiting to be sent goes to the headset whose slider
  it was, and a switch cancels it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:03:27 +10:00
saphidandClaude Opus 5.5 43e19d17f6 Devices: fixes from review round 9
- A title waiting its turn to be read stays with the headset it was dropped
  on, and is dropped if the app switches meanwhile.
- Probes still finishing from an earlier attempt can't overwrite the rows of
  a newer one.
- The FRAME_ALIAS the server started with stays on the list after switching
  away, so it can be picked again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:55:14 +10:00
saphidandClaude Opus 5.5 175c39a2b0 Devices: fixes from review round 8
- A batch of dropped files stays with the headset it was dropped on, and
  stops if the app switches.
- Removing or moving the address in use reroutes at once; a headset with no
  addresses reaches nothing rather than whatever ~/.ssh/config says.
- A late answer to an older device-list request is ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:45:34 +10:00
saphidandClaude Opus 5.5 51ef5d8283 Devices: fixes from review round 7
- Removing every headset leaves none in use (commands fail at once) instead of
  falling back to the `frame` alias.
- ssh goes to the IP that answered for IPv6 too, with a link-local address's
  interface (verified: frame.local over fe80::…%en9 on the real Frame).
- Find results only show in the panel of the headset they were for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:35:43 +10:00
saphidandClaude Opus 5.5 ba33d2ff40 Devices: fixes from review round 6
- The headset a change is meant for is checked and the work counted in one
  step, so a switch can't slip in between (uploads too).
- A sideloaded title read on one headset can't be installed on another; open
  confirmations close on a switch.
- The only headset can't be removed while its ssh alias stays behind.
- Answers about the previous headset are dropped without touching panels; the
  catalogue's Installed tags are rebuilt for the new headset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:24:46 +10:00
saphidandClaude Opus 5.5 41ac28248a Devices: fixes from review round 5
- A switch publishes the new headset at once, so the page clears the old one's
  panels and the lists behind them (games, store, Android apps, screenshots).
- The page names the headset its changes are for (X-Frame-Device); the server
  refuses one meant for a headset it has switched away from (409).
- A rejected address edit changes nothing.
- Test now goes to the IPv4 address that answered, like the connection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:14:25 +10:00
saphidandClaude Opus 5.5 9dd57cde4e Devices: connector tests follow ssh to the IPv4 address that answered
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:01:49 +10:00
saphidandClaude Opus 5.5 d383a26746 Devices: fixes from review round 4
- A switch clears every headset-specific list and its buttons at once.
- SSH goes to the IPv4 address that answered the probe, not the name again.
- A rejected headset edit changes nothing.
- The SteamOS/Lepton builds recorded in reports are read again per headset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:00:09 +10:00
saphidandClaude Opus 5.5 318bc3b84f Devices: make the pin folder before ssh saves a first-seen key into it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:49:43 +10:00
saphidandClaude Opus 5.5 cb182dbce5 Devices: fixes from review round 3
- Attempts carry a generation: one overtaken by a switch, a removal or a login
  change routes nothing back to the old headset and can't report connected.
- Removing the active headset or changing its user/port reroutes at once,
  before anything that can fail.
- One known_hosts file per headset (~/.ssh/frame-control-hosts/<id>):
  forgetting one headset's key can't drop another's, whoever else writes.
- learn() checks, under the config lock, that the block is still what the
  attempt started from before writing to it.
- A switch stops live video and drops captures from the previous headset.
- A probe shares its time between the addresses a name resolves to.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:48:39 +10:00
saphidandClaude Opus 5.5 18334b5989 Devices: fixes from review round 2
- Switching headsets is serialized with the start of any install; background
  work counts as running from before its thread starts. use() reroutes every
  command at once and makes ensure() wait for the new headset.
- A new user or port reroutes commands even if the attempt then fails.
- ~/.ssh/config edits take a lock file shared with Set Up Connection
  (frame_connect.py and connect.sh, which now also writes atomically).
- A finished attempt no longer writes its older settings over a change Set
  Up Connection made meanwhile.
- Pin edits are locked and swapped atomically.
- A bare alias behind ProxyJump/ProxyCommand is left to ssh to reach.
- The page drops answers about the previous headset after a switch; the
  header switcher takes clicks in the macOS title bar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:38:41 +10:00
saphidandClaude Opus 5.5 13eb65603b Devices: fixes from review round 1
- No switching headsets (or changing the active one's user/port, or removing
  it) while installs run: they read the ssh settings step by step.
- Switching reroutes every command to the new headset at once, even if it
  never answers.
- ~/.ssh/config edits are serialized, use unique temp files, and back off if
  another program wrote the file meanwhile.
- stop() ends a handshake in progress and joins the connector.
- Pinned keys are written unhashed (HashKnownHosts=no); hashed ones are still
  found and forgotten via ssh-keygen.
- Set Up Connection changing a headset's user or port updates the registry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:25:41 +10:00
saphidandClaude Opus 5.5 a954fc83c9 Devices: several headsets, several addresses each, and live connection status
Frame Control can now manage more than one Steam Frame, and reach each at any
of several addresses (LAN IPs per network, its .local name, Tailscale). A
connector in the server tries them all at once, picks the best one that
answers, follows ssh -v through each stage (network, finding, SSH, identity,
login) and streams that to the page. The header shows it live; a new Devices
tab (key 5) manages headsets, addresses and network names.

- ui/frame_devices.py: registry in devices.json, imported from the managed
  ~/.ssh/config blocks; per-headset host key pinning; config block updates.
- ui/frame_network.py: gateway IP+MAC fingerprint, Wi-Fi name, Tailscale.
- ui/frame_link.py: the connector, Test now, Tailscale/mDNS discovery, API.
- server.py: ensure_master delegates to the connector; /api/connection,
  /api/connection/events (SSE), /api/devices.
- Electron: headset switcher and Devices item in the Frame menu.
- frame_connect.py --alias; FRAME_CONTROL_DATA_DIR / FRAME_CONTROL_SSH_DIR
  keep tests off real data.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:13:58 +10:00
49 changed files with 7318 additions and 225 deletions

No files matched your search

+108 -22
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -149,7 +149,7 @@ async function startServer() {
const target = `http://127.0.0.1:${port}/`;
for (let i = 0; i < 100; i++) {
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
if (await ping(target)) { url = target; return; }
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
@@ -159,51 +159,76 @@ async function startServer() {
// Closing stdin lets server.py close its SSH connections and exit (the only clean
// way on Windows); SIGTERM does the same elsewhere.
// Resolves once it has exited (or after 20 s), so a replacement can take the server
// lock: server.py allows one per user.
function endServer(child) {
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
: new Promise((resolve) => child.once("exit", resolve));
try { child.stdin.end(); } catch {}
if (!IS_WIN) child.kill("SIGTERM");
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
}
function stopServer() {
if (server) endServer(server);
}
function errorPage(message) {
function errorPage(message, title = "Frame Control couldn't start") {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
<p>${esc(message)}</p>
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
// A server that had been running starts again by itself (something stopped it: a
// signal, a crash). One that stops again within a minute shows the error instead,
// so a server that can't stay up doesn't restart forever.
let serverStarted = 0;
function serverDied(why) {
url = null;
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
if (!win) return;
if (Date.now() - serverStarted > 60000) restartServer();
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
}
async function restartServer() {
const old = server;
server = null;
url = null;
if (old) endServer(old);
await load();
// Restarts that overlap share one: two could each start a server, and the one
// that lost the lock would leave the app pointing at nothing.
let restarting = null;
function restartServer() {
if (!restarting) {
restarting = (async () => {
const old = server;
server = null;
url = null;
if (old) await endServer(old);
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
await load();
})().finally(() => { restarting = null; });
}
return restarting;
}
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
`;
// Restart Server can start a new load while an older one is still waiting for
// its server; only the newest load may touch the window.
let loadGen = 0;
let starting = null; // loads that overlap share one server start
async function load() {
const gen = ++loadGen;
try {
if (!url) await startServer();
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
} catch (e) {
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
@@ -247,13 +272,66 @@ function fromUi(e) {
} catch { return false; }
}
// The error page's Try Again button. The error page is the only data: page the window
// shows (`url` can still be set then: the server answered but the page failed to load).
ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
if (img.isEmpty()) throw new Error("not an image");
clipboard.writeImage(img);
return true;
});
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// The page reports the headsets it knows (the server's Devices tab), so the Frame
// menu can switch between them. Only plain names and ids go into the menu.
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
let devices = [];
ipcMain.on("devices:changed", (e, list) => {
if (!fromUi(e) || !Array.isArray(list)) return;
const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
if (JSON.stringify(next) === JSON.stringify(devices)) return;
devices = next;
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
// SSH through the server, so it goes to the headset and address the app is using
// (with its own pinned identity), and refuses when there's none.
function openSsh() {
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
const body = JSON.stringify({ what: "terminal" });
const req = http.request(new URL("/api/open", url), {
method: "POST", timeout: 15000,
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
}, (res) => {
let data = "";
res.on("data", (c) => { data += c; });
res.on("end", () => {
if (res.statusCode === 200) return;
let why = `HTTP ${res.statusCode}`;
try { why = JSON.parse(data).error || why; } catch {}
dialog.showErrorBox("Couldn't open SSH", why);
});
});
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
req.end(body);
}
function showDevices() {
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
}
ipcMain.handle("comfort:notify", (e, message) => {
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
@@ -425,13 +503,14 @@ async function runInTerminal(argv) {
}
}
async function setUpConnection() {
const alias = `FRAME_ALIAS=${FRAME}`;
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
async function setUpConnection(name = activeAlias()) {
if (!ALIAS_RE.test(name)) return;
const alias = `FRAME_ALIAS=${name}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
// A new console inherits our environment on Windows; Linux terminals may not.
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
}
function buildMenu() {
@@ -446,8 +525,15 @@ function buildMenu() {
{
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: setUpConnection },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
{ label: "Set Up Connection…", click: () => setUpConnection() },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
{ type: "separator" },
...(devices.length > 1 ? [{
label: "Headset",
submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
click: () => { if (win) win.webContents.send("use-device", d.id); } })),
}] : []),
{ label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
{ type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.0",
"version": "0.4.1",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.0",
"version": "0.4.1",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+10 -1
View File
@@ -2,7 +2,8 @@
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
// It can open Set Up Connection when the headset can't be reached.
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
// It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first.
// And it passes update state both ways: see app/updater.js.
@@ -11,7 +12,15 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
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
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
ipcRenderer.removeAllListeners("use-device");
ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
},
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
+37
View File
@@ -168,6 +168,43 @@ on this branch (run 36421345682).
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
browser profile and SSH tunnel and remove the profile and panel log.
**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
backend, no API key, no prestarted server) and a human approved or rejected
each change in the approval page in a real Chrome window:
| Tool | Result on the Frame |
|---|---|
| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
| `uninstall` | Approved; app and locale removed |
| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
A separate Claude Code CLI session, with only this server configured, read
status, `computer_state` and a headset capture, and requested an install. It
received an approval URL and did not execute anything.
The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
Against a loopback stub model, a send without consent made zero requests. With
consent it made exactly one, carrying the text and a fresh Frame screenshot.
Consent unticked itself after sending. SIGTERM removed the panel, profile, log
and tunnel.
**Not verified while unworn:** every headset capture was a uniform dark frame,
so SteamVR's rendered view of panels and the game could not be checked; window
captures (`xwd`) were used instead. MCP can launch a game or panel but has no
tool to stop one: the tester stopped them over SSH. Removing an app leaves any
runtime it pulled in; Flatpak may also remove related extensions when that
runtime is removed by hand.
![Approval page showing the exact install action](img/mcp-approval-install.png)
![The installed Flatpak rendering in its own gamescope panel window](img/mcp-panel-binary.png)
![Assistant in Frame Chromium, after an opted-in request to the local test endpoint](img/assistant-panel.png)
## Computer-use coverage
+180
View File
@@ -0,0 +1,180 @@
# Headsets, addresses and the connection
Frame Control can manage more than one Steam Frame, and each headset can be
reached at more than one address: a LAN IP at home, another at the office, its
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
tab (key 5) lists them, and the connection pill in the header shows what the
app is doing to reach the one in use, step by step, as it happens.
The code is in three modules, all stdlib-only Python on your computer:
| Module | What it does |
|---|---|
| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
## Headsets
Each headset keeps its own SSH alias, as Set Up Connection has always written
it: the first is `frame`, the next `frame-2`, and so on. Terminal's
`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
work for each one.
- **Nothing to migrate by hand.** On first start, the app imports every
`# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
the block's HostName as its first address. It also copies the host key your
`known_hosts` already trusts for that address into the headset's own
known_hosts file, `~/.ssh/frame-control-hosts/<id>`, so nobody is asked to trust it again.
- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
`ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
When it writes its block, the app picks the headset up by itself. If Set Up
Connection runs again and finds a headset somewhere new, that address is added
at the top of its list.
- **Use this headset** (or the switcher in the header, or the app's
**Frame → Headset** menu) moves the whole app to another headset; every panel
reloads from it. From that moment no command goes to the previous headset, even
if the new one never answers. It waits while an install is running, since an
install reads the SSH settings step by step.
- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
the box; either way it isn't imported again unless Set Up Connection changes it.
- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
app shows it as not set up and lets ssh's own config decide where it goes.
## Addresses
Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
and changeable), an optional label, the networks it has worked on, and when it
last worked with its round-trip time.
When connecting, the app **tries all addresses at once** (TCP to the SSH port)
and ranks them:
1. addresses that worked on the network this computer is on now;
2. mDNS names;
3. Tailscale addresses, if Tailscale is running here;
4. addresses not tried on this network yet;
5. addresses that only ever worked on other networks;
6. Tailscale addresses while Tailscale is off.
Your order on the Devices tab breaks ties. The best-ranked address that answers
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
still trying. If SSH to the winner fails in a way another address could fix
(a different device answered there, or the link dropped), the next one that
answered is tried. Every success records the network on that address, so next
time on that network it's tried first.
**Test now** probes every address and tries SSH on each one that answers, without
disturbing the connection in use: "SSH works", "answered as a different
headset", "refused this computer's key", or why it didn't answer. **Find on
Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
status --json`, including the Mac app's own CLI) with buttons to add their
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
services and checks `ALIAS.local` and `frame.local`.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
on macOS 14 and later, which hides the Wi-Fi name from apps without Location
permission. Where the system does share the Wi-Fi name, it's shown, and you can
name any network yourself ("Home Wi-Fi") on the Devices tab.
The app rereads the gateway every 5 seconds and Tailscale's state every
30 seconds. Changing networks reconnects.
## The connection, stage by stage
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.
2. **Finding the headset**: each address resolving, trying, answered in N ms,
no answer, refused, or can't be found.
3. **Opening SSH** to the address that answered.
4. **Checking the headset's identity**: the host key must match the one pinned
for this headset.
5. **Logging in** as the headset's user.
6. **Connected** via network N, address A, round trip T; or **failed** at a stage
with the reason in plain words and a countdown to the next try (5, 10, 20,
then every 30 seconds). Retry now skips the wait.
Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
connection is an SSH ControlMaster that every command shares; when it dies (the
headset slept or left the network) the connector notices and starts again. On
Windows, where OpenSSH can't share a connection, the same handshake runs once
and each command then connects on its own; a command that can't reach the
headset makes the connector start again.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -o HashKnownHosts=no -o User=… -o Port=…`. The
alias's block in `~/.ssh/config` is also updated to the last address that
worked (and to the user and port you set), so Terminal's `ssh frame` and the
scripts follow. Edits to `~/.ssh/config` take a lock file
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
write over a change someone else made since the app last read the file.
**Host keys are pinned per headset, not per address.** Your own `known_hosts`
is keyed by address, so a different device answering at a remembered IP (a DHCP
lease that moved) would look like a new host there. The app keeps one known_hosts
file per headset instead, so saving or forgetting one headset's key never touches
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
headset has a new key; **Forget identity** on the Devices tab lets the next
connection save the new one.
## One server at a time
Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
data folder). Two would each connect, reconnect and edit the headsets on their own, and
one could move the other's install to a different headset. A second one, say
`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
## API
All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
| Request | Returns |
|---|---|
| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) |
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
| `GET /api/devices/mdns?id=` | Headsets found on this network |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
Every host, alias and user is checked against strict patterns before it's
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
through a shell.
## The registry file
`devices.json` in the app's data folder (`~/Library/Application Support/Frame
Control` on macOS, `%APPDATA%\Frame Control` on Windows,
`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
share the format later (it still connects to one host; see
[iphone.md](iphone.md)):
```json
{"version": 1, "active": "f67f8b7e",
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
"identity_files": ["~/.ssh/id_ed25519_frame"],
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
"gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
```
A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
## Tests
`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
computer for the addresses, and temporary folders for `~/.ssh`
(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
never touch yours.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+29
View File
@@ -0,0 +1,29 @@
Locked real-Frame repeat, 2026-09-29
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
+24 -9
View File
@@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has four tabs: **Home** (headset view, status, screenshots),
The window has five tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports) and **Tools** (sending files and text,
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
anywhere in the window. When the Frame can't be reached, one banner says why in
plain words and the app retries every few seconds, filling everything in once it
answers. Flatpak and Android installs run in the background; the bottom bar
the catalogue, display settings, reports), **Tools** (sending files and text,
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset, which network this
computer is on, the address in use or being tried, and each step of connecting
as it happens; click it for the whole timeline. When the Frame can't be
reached, a banner says why in plain words, what was tried, and counts down to
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
@@ -78,10 +81,20 @@ counts them while they run.
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
Remmina).
- **Devices**: several headsets, each with several addresses (LAN IPs per
network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app
tries them all at once and learns which worked on which network. Add, edit,
reorder and test addresses, find a headset on Tailscale or on this network,
name your networks, and switch headsets. See [devices.md](devices.md).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Sleep, restart and shut down open a terminal window because SteamOS
asks for the sudo password over SSH.
Linux). Remote desktop first checks that the Frame's xrdp answers on port
3389 (Developer Mode turns it on). On Windows it opens a connection file for
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
xrdp turns away. Accept the warning about the Frame's own certificate, then
sign in with the Developer Mode password. Sleep, restart and
shut down open a terminal window because SteamOS asks for the sudo password
over SSH.
## How it works
@@ -101,7 +114,9 @@ Frame for the keyboard and trackpad.
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
with a non-local `Host` header, and any `/api/` request without a custom
header, so other websites can't drive it or read captures. Everything reaches
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
the headset through its SSH alias (`frame` for the first one), pointed at the
address that answered with `-o HostName=` (`ui/frame_link.py`, described in
[devices.md](devices.md)). On macOS and Linux it keeps one
multiplexed SSH connection open, so status and each capture take about 0.3 s.
Windows' OpenSSH can't share a connection, so there each request connects on
its own and the app is a little slower. What differs between the three
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+27
View File
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
All test app files, compatdata, shortcuts and containers were removed afterwards.
SteamVR's original process remained running. No global settings changed.
### Locked repeat, 2026-09-29 (verified)
Acquired `/tmp/frame-test.lock` before installing or launching anything and
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
Reinstalled and launched both original APKs in immersive Lepton instances.
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
unmodified-client test, not the newer installer's automatic compatibility-layer
path. Neither reached a session that could be paired or exercised further.
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
record the failures.
SteamVR's screenshot API returned a 1920×1080 headset capture after each
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
rendering or a working client. The headset was unworn; visibility, controllers,
frame rate and motion-to-photon latency could not be judged. The explicit
OpenXR errors, rather than the dark captures, establish the client blocker.
Both test installs, app data, shader caches, shortcuts, containers and temporary
capture files were removed. The original Steam and SteamVR process IDs were
unchanged. No reboot, power action or global setting change was used. Native
clients remain untested, and no Linux gaming host was available for Valve's
streaming path. The recommendation below is unchanged.
### Relation to the VR APK branch
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
+69 -4
View File
@@ -103,9 +103,18 @@ privately to Frame Control's PostHog project as a `problem_report` event, the
same way as the analytics above, so only the maintainer can read it and
nothing is published. It works whatever the analytics settings are, because
the person sends it deliberately. The report has the kind, title and text you
wrote, how to reach you if you gave it, a short reference shown after sending,
and the diagnostics below. It has its own random id, so it isn't linked to
your analytics events.
wrote, a short reference shown after sending, and the diagnostics below. Your
email address goes with it only if you tick **The maintainer may contact me
with follow-up questions** (the report then carries `contact_followup: true`);
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
your analytics events. With that box ticked, the address also becomes your
**Contact email** below with follow-up questions ticked, so you remove it there
like any other. If it's a different address from the one saved there, it
replaces it, and update notices stop until you turn them on again (they were
agreed for the old address); the form says so before you send. The report then also
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
see below), so removing or changing the address later takes back the
follow-up permission given with the report too.
With **Include diagnostics** ticked (the default), the report adds:
@@ -128,11 +137,67 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
API key as `frame_compat_db.py sync`.
## Contact email (optional)
Frame Control never needs an email address. If you'd like to leave one, there
are two separate choices, both off until you tick them:
| Choice | What it's for |
|---|---|
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
You're asked once, in a bar at the top of the page, after the Frame has
connected for the first time, and never in the same visit as the first-run
privacy notice. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates**
is where you add, change or remove the address and either choice at any time.
**What's sent, and where.** The address and the two choices go privately to
Frame Control's PostHog project, the same place as problem reports, as a
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
or `withdraw`) and the common properties above. Only the maintainer can read
that project, and nothing in it is published or shared. It's sent only when
you save, or when you send a problem report with follow-up questions ticked,
whatever the analytics settings are, because you chose to. With a report, the
address and choices are saved before the report is sent and stay saved if it
fails; like any change, they're sent as soon as PostHog can be reached. It
carries its own random contact id, not the analytics id, so it isn't linked
to your usage events, and a `rev` number that goes up with each change, so
the newest choice always wins. Like everything else sent, it's listed under
**Show what's been sent**. On this computer the address and choices are kept in
`contact/contact.json` in Frame Control's data folder. An address is only
kept with at least one choice ticked.
**Removing it.** **Remove my email** (or clearing the address and saving)
deletes it from this computer, including from the **Show what's been sent**
log (in earlier contact events and problem reports), and sends a `withdraw`
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
then on the address isn't listed for either choice. Unticking one choice
works the same way for that choice. This also covers problem reports you sent
from this copy with follow-up questions ticked: if your newest choice since the
report (by change number, not the clock) no longer agrees to follow-up
questions at that address, the maintainer's inbox shows the permission as
withdrawn and leaves the address out. If you're offline, the change waits on
this computer and is sent when PostHog can be reached. The earlier event
stays in PostHog until its data retention removes it; to have it deleted
sooner, ask the maintainer (for example in a problem report).
Nothing sends email yet: this only records who agreed to what. The
maintainer lists the addresses with
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
personal API key as `inbox`.
## Turning it all off
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
the environment that starts Frame Control. A copy run from a source checkout
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
These switches cover the analytics above. A problem report or a contact email
is sent only because you pressed its Send or Save button, so those still go
when you choose to send them (a contact change saved while offline is sent
by itself once PostHog can be reached); if you don't, nothing is sent.
## Update checks
+14
View File
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
Mac with keyboard, mouse, and clipboard.
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
Mode password opens a Plasma (X11) desktop within about 6 seconds.
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
fills in `steamos` there on Windows, Remmina and FreeRDP.
- The desktop is a separate login session (Xorg on display `:10`), not the
headset's view. It uses about 1.3 GB of the Frame's memory.
- Closing the client leaves the session running, and the next login
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
gamescope or SteamVR session.
## B. Show the Mac's desktop inside the Frame
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
+28
View File
@@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved.
![Our Gaussian-splat stereo preview on the Frame](img/media-splat-proof.png)
**Verified end to end, 2026-09-29** (same build; headset unworn): uploads
through the HTTP API, the web UI and `scripts/push-vr-video.sh`, all played by
the owned player. Our generated test files:
| Case | Result |
|---|---|
| H.264 half-SBS 1920×1080 with AAC, theatre | 240/240 frames in 8.09 s; audio stream "Frame Control Media" in PulseAudio |
| H.265 half-OU 1920×1080 | 180/180 frames in 6.03 s; red left eye, cyan right |
| H.264 full-SBS 3840×1080, `stereo_mode=left_right` only | Detected from metadata; 150/150 frames in 5.03 s |
| H.264 1280×720, explicit 2D | 150/150 frames in 5.02 s |
| SBS PNG, OU JPEG (theatre) | Correct eye in each capture |
| 3,000-Gaussian `.splat` | Rendered in about 5 s, then held until Stop |
| 2D file on Auto, `_SBS_OU` file, HEIC, VP9 | Refused with the documented message |
| Second Play while one runs | Refused: "Stop the current media…" |
Stop always left the unit inactive, and no player process remained.
**Standby (verified):** an unworn Frame turns its displays off a few seconds
after it wakes. `SetOverlayRaw` then returns `RequestFailed` (23). The
first run's movie died there. The player now drops frames while the headset
is in standby, keeps the audio and its clock going, and resumes the picture
when the headset wakes. The 8 s movie above dropped 40 frames and finished.
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.
![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
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
in the basic appsink probe and the OpenVR probe. We do not ship that route.
+16 -3
View File
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
fi
}
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a
# running app and this script never write over each other's change.
write_config() {
local lockfd="" rc
zmodload zsh/system 2>/dev/null
touch "$CONFIG.frame-control.lock" 2>/dev/null
zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd=""
write_config_locked; rc=$?
[[ -n "$lockfd" ]] && zsystem flock -u "$lockfd"
return $rc
}
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
write_config_locked() {
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
local tmp
local tmp new="$CONFIG.frame-control.$$"
tmp=$(mktemp) || return 1
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
# value it sees per option, so this block must precede any other "Host frame"
@@ -126,7 +138,8 @@ write_config() {
print -r -- "Host *"
print -r -- "$END_MARK"
cat "$tmp"
} > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
} > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \
|| { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
rm -f "$tmp"
}
+4 -2
View File
@@ -15,11 +15,13 @@
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
# Frame Control passes the headset it has chosen: its address and pinned identity.
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
HERE=${0:A:h}
cmd=${1:-status}
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
@@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
# written the way Steam's settings page does (steamui module exporting the
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
# ("Interactive authentication required") but allows one from a user unit.
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
import json, os, subprocess, sys
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
from frame_steam import Page
+3 -1
View File
@@ -21,6 +21,8 @@
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
# Frame Control passes the headset it has chosen: its address and pinned identity.
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
id="" name=""
@@ -109,4 +111,4 @@ EOF
)
b64=$(print -rn -- "$remote" | base64)
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""A stand-in for OpenSSH's ssh, for tests/test_link.py: prints what `ssh -v` prints at
each step of a connection, and plays a ControlMaster. What each HostName does comes
from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key), "denied" or
"slow" (hangs after connecting);
every call is appended to $FAKESSH_LOG as a JSON line. POSIX only."""
import json
import os
import signal
import sys
import time
args = sys.argv[1:]
with open(os.environ["FAKESSH_LOG"], "a") as f:
f.write(json.dumps(args) + "\n")
hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
opts = {}
i = 0
while i < len(args) and args[i].startswith("-"):
if args[i] in ("-o", "-O", "-p", "-l"):
key = args[i]
val = args[i + 1]
if key == "-o":
k, _, v = val.partition("=")
opts[k.lower()] = v
else:
opts[key] = val
i += 2
else:
opts[args[i]] = True
i += 1
alias = args[i] if i < len(args) else ""
host = opts.get("hostname", alias).replace("%%", "%")
marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
if "-G" in opts:
print(f"hostname {alias}\nport 22\nuser tester")
sys.exit(0)
if opts.get("-O") == "check":
sys.exit(0 if os.path.exists(marker) else 255)
if opts.get("-O") == "exit":
if os.path.exists(marker):
os.unlink(marker)
sys.exit(0)
what = hosts.get(host)
if what is None:
say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
sys.exit(255)
say(f"debug1: Connecting to {host} [127.0.0.1] port {opts.get('port', 22)}.")
say("debug1: Connection established.")
if what == "slow":
time.sleep(30)
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
if what == "wrong":
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
say("Host key verification failed.")
sys.exit(255)
say(f"debug1: Host '{opts.get('hostkeyalias', host)}' is known and matches the ED25519 host key.")
say("debug1: Next authentication method: publickey")
if what == "denied":
say(f"tester@{host}: Permission denied (publickey).")
sys.exit(255)
say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
if opts.get("controlmaster") == "yes":
open(marker, "w").close()
def bye(*_):
if os.path.exists(marker):
os.unlink(marker)
sys.exit(0)
signal.signal(signal.SIGTERM, bye)
while True:
time.sleep(0.2)
if not os.path.exists(marker):
sys.exit(0)
sys.exit(0)
+23 -2
View File
@@ -213,8 +213,9 @@ class ManagedBackend(unittest.TestCase):
with mock.patch.object(server.frame_host, 'MUX', True), \
mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
# Per headset (its tag), and per process for a private server.
self.assertEqual(server.frame_host.control_path('a1b2', private=False), '/tmp/frame-ui-501-a1b2-%C')
self.assertEqual(server.frame_host.control_path('a1b2', private=True), '/tmp/frame-ui-501-123-a1b2-%C')
class ComputerState(unittest.TestCase):
@@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class ScriptsFollowTheHeadset(unittest.TestCase):
"""keep_awake and panel tools run scripts that ssh on their own: they must reach the
headset the server is routed to, not whatever `frame` means in ~/.ssh/config."""
@unittest.skipUnless(shutil.which('zsh'), 'needs zsh')
def test_scripts_get_the_routed_alias_and_options(self):
import shlex
fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui',
SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab'])
with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run:
agent.run_script(fake, 'keep-awake.sh', ['status'])
env = run.call_args.kwargs['env']
self.assertEqual(env['FRAME_ALIAS'], 'frame-2')
self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:])
# and the script turns that back into the same argv
out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'],
env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True)
self.assertEqual(out.stdout.splitlines(), fake.SSH[1:])
+412
View File
@@ -0,0 +1,412 @@
"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import sys
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_compat_db as db # noqa: E402
import frame_contact as fc # noqa: E402
import frame_report as fr # noqa: E402
import frame_telemetry as tm # noqa: E402
from test_telemetry import Base, ReportProblem # noqa: E402
REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
class Contact(Base):
"""Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
serve = ReportProblem.serve
def setUp(self):
super().setUp()
self.addCleanup(fc._removed.clear)
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
p = mock.patch.object(fc, name, value)
p.start()
self.addCleanup(p.stop)
self.got = self.serve()
def events(self):
return [body["batch"][0] for _, body in self.got]
def offline(self):
return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
# ---- storage and consent flags
def test_nothing_is_kept_or_sent_until_chosen(self):
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
self.assertFalse(fc.FILE.exists())
self.assertEqual(self.got, [])
def test_an_address_needs_a_choice_and_a_real_address(self):
with self.assertRaisesRegex(ValueError, "tick"):
fc.save({"email": "me@example.com"})
with self.assertRaisesRegex(ValueError, "email address"):
fc.save({"email": "not an address", "updates": True})
self.assertEqual(fc.load()["email"], "")
self.assertEqual(self.got, [])
def test_only_a_real_true_counts_as_consent(self):
for wrong in ("false", "true", 1, 0, [], {}):
with self.assertRaisesRegex(ValueError, "true or false"):
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
with self.assertRaisesRegex(ValueError, "true or false"):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
self.assertEqual((fc.load()["email"], self.got), ("", []))
fc.save({"email": "me@example.com", "updates": True}) # left out is no
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
def test_each_choice_is_sent_privately_on_its_own(self):
fc.save({"email": " me@example.com ", "updates": True})
fc.save({"email": "me@example.com", "updates": False, "followup": True})
first, second = self.events()
self.assertEqual(first["event"], "contact_consent")
self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
{"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
(False, True))
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
def test_sent_whatever_the_analytics_settings(self):
tm.update_settings({"usage": False})
fc.save({"email": "me@example.com", "followup": True})
self.assertEqual(len(self.got), 1)
def test_saving_the_same_choice_again_sends_nothing(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "me@example.com", "updates": True})
self.assertEqual(len(self.got), 1)
# ---- withdrawal
def test_removing_the_address_sends_a_withdrawal_without_it(self):
fc.save({"email": "me@example.com", "updates": True, "followup": True})
s = fc.save({"email": "", "updates": True, "followup": True})
self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
withdrawal = self.events()[-1]["properties"]
self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
("withdraw", "", False, False))
self.assertNotIn("me@example.com", fc.FILE.read_text())
def test_an_address_still_waiting_is_withdrawn_too(self):
with self.offline():
fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
with mock.patch.object(tm, "post") as post:
fc.save({"email": ""})
self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
self.assertFalse(fc.state()["waiting"])
def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
fc.save({"email": "me@example.com", "updates": True})
with self.offline():
s = fc.save({"email": ""})
self.assertTrue(s["waiting"])
self.assertFalse(fc._send_pending())
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
self.assertTrue(fc._send_pending())
self.assertFalse(fc.state()["waiting"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
fc.save({"email": "me@example.com", "followup": True})
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text())
fc.save({"email": ""})
self.assertNotIn("me@example.com", tm.SENT.read_text())
self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
if e["event"] == "contact_consent"], ["set", "withdraw"])
def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "new@example.com", "updates": True})
fc.save({"email": ""})
self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
def test_a_withdrawal_during_a_send_goes_after_it(self):
started, release, order = threading.Event(), threading.Event(), []
real = tm.post
def slow(batch, timeout=20):
order.append(batch[0]["properties"]["action"])
if len(order) == 1:
started.set()
release.wait(5)
real(batch, timeout)
with mock.patch.object(tm, "post", side_effect=slow):
t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
t.start()
self.assertTrue(started.wait(5))
w = threading.Thread(target=fc.save, args=({"email": ""},))
w.start()
for _ in range(500): # the withdrawal is saved while the first send is still out
if fc.load()["rev"] == 2:
break
time.sleep(0.01)
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
release.set()
t.join(5)
w.join(5)
self.assertEqual(order, ["set", "withdraw"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
self.assertFalse(fc.state()["waiting"])
self.assertNotIn("me@example.com", tm.SENT.read_text())
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
fc.save({"email": "me@example.com", "followup": True})
real = tm.post
def remove_meanwhile(batch, timeout=20):
real(batch, timeout)
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertNotIn("me@example.com", tm.SENT.read_text())
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
fc._removed["me@example.com"] = 1790000000.3
event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
before, after = event(), event() # the same whole second as the removal
fc.redact_removed(before, 1790000000.1)
fc.redact_removed(after, 1790000000.6)
self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
("<removed>", "me@example.com"))
def test_saving_during_a_slow_send_returns_at_once(self):
busy = fc._send_lock
busy.acquire()
try:
s = fc.save({"email": "me@example.com", "updates": True})
finally:
busy.release()
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
self.assertEqual(self.got, [])
self.assertTrue(fc._send_pending())
self.assertEqual(len(self.got), 1)
def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
real = fc._send_lock
class Lock: # a Save lands after the sender found nothing waiting, before it lets go
saved = False
def acquire(self, blocking=True):
return real.acquire(blocking)
def release(self):
if not Lock.saved:
Lock.saved = True
s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
s.start()
s.join(5)
assert not s.is_alive() # the change is saved while the sender still holds the lock
real.release()
with mock.patch.object(fc, "_send_lock", Lock()):
self.assertTrue(fc._send_pending())
self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
self.assertFalse(fc.state()["waiting"])
# ---- the one-time prompt
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
tm.frame_seen("20260901.1", "3.8")
self.assertTrue(fc.state()["showPrompt"])
fc.prompt({"prompt": "dismissed"})
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
self.assertEqual(fc.load()["prompt"], "dismissed")
self.assertFalse(fc.state()["showPrompt"])
self.assertEqual(self.got, []) # No thanks sends nothing
with self.assertRaises(ValueError):
fc.prompt({"prompt": "reset"})
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
tm.frame_seen("20260901.1", "3.8")
fc.prompt({"prompt": "shown"})
self.assertFalse(fc.state()["showPrompt"])
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
self.assertEqual(fc.load()["prompt"], "answered")
# ---- reports and the maintainer's list
def reports(self):
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
fr.send({**REPORT, "contact": "me@example.com"})
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
without, with_ = self.reports()
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
with self.assertRaisesRegex(ValueError, "email address"):
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
consent = [e for e in self.events() if e["event"] == "contact_consent"]
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
self.assertEqual(self.reports()[1]["contact_rev"], 1)
fc.save({"email": ""}) # Remove my email
last = self.events()[-1]
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
("withdraw", "", 2))
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
self.assertEqual(logged, ["<removed>", "<removed>"])
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
"""Update notices were agreed for the old address, not the new one (the form says so)."""
fc.save({"email": "old@example.com", "updates": True})
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
self.assertEqual(self.reports()[0]["contact_rev"], 2)
fc.save({"email": "new@example.com", "updates": True, "followup": False})
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
"""Removed while the report's own consent is on its way: the report keeps that consent's
rev (so the removal is newer) and is logged without the address."""
post, removed = tm.post, []
def slow_post(events, **kw):
post(events, **kw)
if not removed and events[0]["event"] == "contact_consent":
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
with mock.patch.object(tm, "post", side_effect=slow_post):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
report = self.reports()[0]
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
for e in self.events() if e["event"] == "contact_consent"]
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
fr.mark_withdrawn([row], consents)
self.assertEqual(row[10], "withdrawn")
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
self.assertEqual(logged, ["<removed>"])
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
"0.4.0", "Windows", "", "", followup, cid, rev]
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
reports = [self.report_row(), # removed later
self.report_row(cid="other"), # another copy, still agrees
self.report_row(rev=3), # sent after the removal
self.report_row(cid="moved"), # address changed later
self.report_row(cid="news-only"), # follow-up unticked later
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
self.report_row(cid="", followup=True), # no contact id: left alone
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
fr.mark_withdrawn(reports, consents)
self.assertEqual([r[10] for r in reports],
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
def test_the_change_number_decides_not_the_clock(self):
"""The clock went back between the report and the removal: the removal still counts."""
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
fc.save({"email": ""})
report = self.reports()[0]
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
for e in self.events() if e["event"] == "contact_consent"]
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
fr.mark_withdrawn([row], consents)
self.assertEqual(row[10], "withdrawn")
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
reports = [self.report_row(), ["short"]]
consents = [["copy", "", False, 2]]
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
mock.patch("builtins.print") as out:
fr.main()
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("follow-up permission since withdrawn", printed)
self.assertNotIn("me@example.com", printed)
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
fr.inbox()
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
found = fr.contacts()
self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
q.call_args.args[0])
self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
"followup": [("both@example.com", "2026-09-01")]})
with mock.patch.object(fr, "contacts", return_value=found), \
mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
mock.patch("builtins.print") as out:
fr.main()
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("both@example.com", printed)
self.assertNotIn("news@example.com", printed)
def test_the_page_can_reach_it(self):
import server
self.assertIs(server.POST["/api/contact"], fc.save)
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
def test_saving_is_not_headset_work(self):
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
import io
import server
seen = []
for path in ("/api/contact", "/api/contact/prompt"):
h = server.Handler.__new__(server.Handler)
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
h.path, h.rfile = path, io.BytesIO(body)
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
h.local_request = lambda: True
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
h.do_POST()
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
# Run these once, in test_telemetry, not again through the import above.
del Base, ReportProblem
if __name__ == "__main__":
unittest.main()
+403
View File
@@ -0,0 +1,403 @@
"""frame_devices: the headset registry, importing ~/.ssh/config, address order, pinned
host keys and input checks. Everything works in temporary folders.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_devices as fd # noqa: E402
import frame_host # noqa: E402
CONFIG = """Host lxso1
HostName 192.168.1.109
# >>> steam-frame (frame) >>>
Host frame
HostName frame.tail1234.ts.net
User steamos
IdentityFile ~/.ssh/id_ed25519_frame
IdentityFile ~/.ssh/id_rsa_frame_devkit
IdentitiesOnly yes
ServerAliveInterval 30
Host *
# <<< steam-frame (frame) <<<
# >>> steam-frame (frame-2) >>>
Host frame-2
HostName 192.168.1.60
Port 2222
User deck
IdentityFile ~/.ssh/id_ed25519_frame
Host *
# <<< steam-frame (frame-2) <<<
Host *
ServerAliveInterval 60
"""
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIID6kdLfZZmdTqS1snKfTESTKEYTESTKEYTESTKEYTESTKE"
class Base(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="frame-devices-"))
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.ssh = self.dir / "ssh"
self.ssh.mkdir()
(self.ssh / "config").write_text(CONFIG)
old = os.environ.get("FRAME_CONTROL_SSH_DIR")
os.environ["FRAME_CONTROL_SSH_DIR"] = str(self.ssh)
self.addCleanup(lambda: os.environ.__setitem__("FRAME_CONTROL_SSH_DIR", old) if old
else os.environ.pop("FRAME_CONTROL_SSH_DIR", None))
self.reg = fd.Registry(self.dir / "devices.json")
class Validation(unittest.TestCase):
def test_hosts(self):
for good in ("frame.local", "192.168.1.40", "fd7a:115c:a1e0::1234:5678", "fe80::1%en0", "frame-2.tail1234.ts.net"):
self.assertEqual(fd.check_host(good), good)
for bad in ("", " ", "-oProxyCommand=sh", "a b", "frame;id", "frame\nHost *", "frame..local", "$(id)",
"frame%en0", "x" * 300, None, 5, "frame/../x"):
with self.assertRaises(fd.DeviceError, msg=repr(bad)):
fd.check_host(bad)
def test_names(self):
self.assertEqual(fd.check_alias("frame-2"), "frame-2")
for bad in ("", "-F", "frame 2", "frame\n", "a" * 65, None):
with self.assertRaises(fd.DeviceError):
fd.check_alias(bad)
with self.assertRaises(fd.DeviceError):
fd.check_user(bad)
for bad in ("0", "65536", "x", None, "22; id"):
with self.assertRaises(fd.DeviceError):
fd.check_port(bad)
self.assertEqual(fd.check_port("2222"), 2222)
with self.assertRaises(fd.DeviceError):
fd.check_text("line\nbreak", "label")
with self.assertRaises(fd.DeviceError):
fd.check_kind("wifi")
def test_ipv6_zone_is_escaped_for_ssh(self):
self.assertEqual(fd.ssh_host("fe80::1%en0"), "fe80::1%%en0")
class Migration(Base):
def test_blocks_are_parsed(self):
blocks = fd.parse_blocks(CONFIG)
self.assertEqual([b["alias"] for b in blocks], ["frame", "frame-2"])
self.assertEqual(blocks[0]["hostname"], "frame.tail1234.ts.net")
self.assertEqual(blocks[0]["identity_files"], ["~/.ssh/id_ed25519_frame", "~/.ssh/id_rsa_frame_devkit"])
self.assertEqual((blocks[1]["port"], blocks[1]["user"]), (2222, "deck"))
def test_existing_headsets_are_imported_once(self):
self.assertTrue(self.reg.sync_from_config(seed=False))
devices = self.reg.devices()
self.assertEqual([d["alias"] for d in devices], ["frame", "frame-2"])
frame, second = devices
self.assertEqual(frame["name"], "Steam Frame")
self.assertEqual(frame["addresses"][0]["host"], "frame.tail1234.ts.net")
self.assertEqual(frame["addresses"][0]["kind"], "tailscale")
self.assertEqual((second["user"], second["port"]), ("deck", 2222))
self.assertEqual(self.reg.active(), frame["id"])
self.assertFalse(self.reg.sync_from_config(seed=False)) # nothing new
# It's all on disk, in the documented shape.
data = json.loads((self.dir / "devices.json").read_text())
self.assertEqual(data["version"], 1)
self.assertEqual(len(data["devices"]), 2)
self.assertEqual(fd.Registry(self.dir / "devices.json").devices(), self.reg.devices())
def test_first_import_keeps_using_frame(self):
# Set Up Connection puts each new block first; the app used `frame` before.
blocks = CONFIG.split("# >>> steam-frame (frame-2) >>>")
head, first = blocks[0].split("# >>> steam-frame (frame) >>>")
second, tail = blocks[1].split("# <<< steam-frame (frame-2) <<<")
(self.ssh / "config").write_text(head + "# >>> steam-frame (frame-2) >>>" + second + "# <<< steam-frame (frame-2) <<<\n"
+ "# >>> steam-frame (frame) >>>" + first + tail)
self.reg.sync_from_config(seed=False)
self.assertEqual([d["alias"] for d in self.reg.devices()], ["frame-2", "frame"])
self.assertEqual(self.reg.active(), self.reg.by_alias("frame")["id"])
@unittest.skipUnless(shutil.which("ssh"), "needs ssh")
def test_a_port_inherited_from_another_host_entry_is_kept(self):
(self.ssh / "config").write_text(CONFIG.replace("Host *\n ServerAliveInterval 60", "Host *\n Port 2222"))
self.reg.sync_from_config(seed=False)
self.assertEqual(self.reg.by_alias("frame")["port"], 2222) # what ssh itself would use
self.assertEqual(self.reg.by_alias("frame-2")["port"], 2222) # its own Port line
# Saving 22 must then say so in the block, or ssh would go on inheriting 2222.
self.assertTrue(fd.rewrite_block("frame", port=22))
self.assertEqual(fd.effective_port("frame", self.ssh / "config"), 22)
self.assertFalse(fd.rewrite_block("frame", port=22)) # and only once
def test_setup_finding_a_new_address_adds_it(self):
self.reg.sync_from_config(seed=False)
(self.ssh / "config").write_text(CONFIG.replace("HostName frame.tail1234.ts.net", "HostName 192.168.1.237"))
self.assertTrue(self.reg.sync_from_config(seed=False))
hosts = [a["host"] for a in self.reg.by_alias("frame")["addresses"]]
self.assertEqual(hosts, ["192.168.1.237", "frame.tail1234.ts.net"]) # the new one first
def test_setup_changing_the_login_updates_the_headset(self):
self.reg.sync_from_config(seed=False)
(self.ssh / "config").write_text(CONFIG.replace(" User steamos\n", " User deck\n Port 2200\n", 1))
self.assertTrue(self.reg.sync_from_config(seed=False))
d = self.reg.by_alias("frame")
self.assertEqual((d["user"], d["port"]), ("deck", 2200))
def test_removed_headset_stays_removed_until_setup_changes_it(self):
self.reg.sync_from_config(seed=False)
second = self.reg.by_alias("frame-2")
self.reg.remove_device(second["id"])
self.reg.sync_from_config(seed=False)
self.assertIsNone(self.reg.by_alias("frame-2"))
self.reg.undismiss("frame-2") # Set Up Connection run for it from the Devices tab
self.reg.sync_from_config(seed=False)
self.assertIsNotNone(self.reg.by_alias("frame-2"))
def test_corrupt_registry_is_ignored(self):
(self.dir / "bad.json").write_text("{not json")
self.assertEqual(fd.Registry(self.dir / "bad.json").devices(), [])
(self.dir / "evil.json").write_text(json.dumps({"devices": [
{"id": "x1", "alias": "-oProxyCommand=id", "addresses": []},
{"id": "x2", "alias": "ok", "addresses": [{"host": "a b", "kind": "lan"}, {"host": "frame.local", "kind": "mdns"}]}]}))
devices = fd.Registry(self.dir / "evil.json").devices()
self.assertEqual([d["alias"] for d in devices], ["ok"])
self.assertEqual([a["host"] for a in devices[0]["addresses"]], ["frame.local"])
class SharedFile(Base):
"""The desktop app and a standalone server can share devices.json."""
def test_one_server_never_saves_over_anothers_change(self):
self.reg.sync_from_config(seed=False)
other = fd.Registry(self.dir / "devices.json") # a second server, loaded now
d = self.reg.by_alias("frame")
self.reg.add_address(d["id"], "100.101.1.2", "tailscale")
other.record_success(d["id"], d["addresses"][0]["host"], "net-1", 12) # works from its older copy
hosts = [a["host"] for a in fd.Registry(self.dir / "devices.json").get(d["id"])["addresses"]]
self.assertIn("100.101.1.2", hosts)
self.assertIn("100.101.1.2", [a["host"] for a in other.get(d["id"])["addresses"]]) # and it sees it
def test_another_servers_choice_of_headset_doesnt_move_this_one(self):
self.reg.sync_from_config(seed=False)
other = fd.Registry(self.dir / "devices.json")
mine, theirs = self.reg.by_alias("frame")["id"], self.reg.by_alias("frame-2")["id"]
self.reg.set_active(mine)
other.set_active(theirs)
self.assertEqual(self.reg.active(), mine) # after a refresh
self.reg.add_address(mine, "192.0.2.9") # and after a change reloads the file
self.assertEqual(self.reg.active(), mine)
self.assertEqual(fd.Registry(self.dir / "devices.json").active(), mine) # last to save: next start
class ConfigRewrite(Base):
def test_hostname_user_and_port_change_only_inside_the_block(self):
cfg = self.ssh / "config"
self.assertTrue(fd.rewrite_block("frame", hostname="192.168.1.237"))
text = cfg.read_text()
self.assertIn(" HostName 192.168.1.237\n", text)
self.assertEqual(text.replace("192.168.1.237", "frame.tail1234.ts.net"), CONFIG) # nothing else moved
self.assertFalse(fd.rewrite_block("frame", hostname="192.168.1.237")) # no change, no write
self.assertTrue(fd.rewrite_block("frame", port=2200, user="deck"))
block = fd.parse_blocks(cfg.read_text())[0]
self.assertEqual((block["port"], block["user"], block["hostname"]), (2200, "deck", "192.168.1.237"))
self.assertTrue(fd.rewrite_block("frame-2", port=22)) # back to the default: said explicitly
self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22)
self.assertIn(" Port 22\n", cfg.read_text())
self.assertIn("HostName 192.168.1.109", cfg.read_text()) # other hosts untouched
if os.name != "nt":
self.assertEqual(cfg.stat().st_mode & 0o777, 0o600)
def test_concurrent_edits_all_land(self):
import threading
def edit(alias, prefix):
for n in range(15):
fd.rewrite_block(alias, hostname=f"{prefix}.{n}")
threads = [threading.Thread(target=edit, args=("frame", "10.0.0")),
threading.Thread(target=edit, args=("frame-2", "10.0.1"))]
for t in threads:
t.start()
for t in threads:
t.join()
blocks = fd.parse_blocks((self.ssh / "config").read_text())
self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"])
self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left
def test_learning_skips_a_block_someone_changed(self):
# The connector learned an address, but Set Up Connection moved the block meanwhile.
self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9",
expect={"hostname": "old.example", "user": None, "port": None}))
self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text())
self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9",
expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22}))
def test_zone_is_escaped_and_read_back(self):
fd.rewrite_block("frame", hostname="fe80::1%en0")
self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text())
self.assertEqual(fd.parse_blocks((self.ssh / "config").read_text())[0]["hostname"], "fe80::1%en0")
def test_missing_block_is_left_alone(self):
self.assertFalse(fd.rewrite_block("frame-9", hostname="10.0.0.1"))
self.assertFalse(fd.remove_block("frame-9"))
self.assertTrue(fd.remove_block("frame-2"))
self.assertEqual([b["alias"] for b in fd.parse_blocks((self.ssh / "config").read_text())], ["frame"])
@unittest.skipUnless(shutil.which("ssh-keygen"), "needs ssh-keygen")
class Pins(Base):
def test_seed_copies_the_trusted_key_under_the_device_alias(self):
(self.ssh / "known_hosts").write_text(f"frame.tail1234.ts.net {KEY}\nother.example {KEY}X\n")
self.assertFalse(fd.pinned("d1"))
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"]))
self.assertTrue(fd.pinned("d1"))
self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {KEY}\n")
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent
self.assertEqual(fd.known_hosts("d1").read_text().count("\n"), 1)
self.assertFalse(fd.seed_pin("d2", ["never-seen.example"]))
self.assertTrue(fd.forget_pin("d1"))
self.assertFalse(fd.pinned("d1"))
self.assertFalse(fd.forget_pin("d1"))
def test_each_headset_has_its_own_file(self):
(self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n")
fd.seed_pin("da", ["a.local"])
fd.seed_pin("db", ["b.local"])
fd.forget_pin("da")
self.assertTrue(fd.pinned("db")) # forgetting one can't touch another
self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db"))
def test_hashed_and_non_default_port_entries(self):
kh = self.ssh / "known_hosts"
kh.write_text(f"[frame.local]:2222 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
def test_hashed_pins_are_found_and_forgotten(self):
target = fd.known_hosts("d4")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"frame-control-d4 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4"))
self.assertFalse(fd.pinned("d4"))
def test_known_hosts_option_uses_the_override(self):
self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5"))
os.environ.pop("FRAME_CONTROL_SSH_DIR")
self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on
class Registry(Base):
def test_address_editing(self):
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
self.assertEqual(a["kind"], "mdns")
self.reg.add_address(d["id"], "100.100.1.1", kind="tailscale", label="Tailscale")
with self.assertRaises(fd.DeviceError):
self.reg.add_address(d["id"], "frame-3.local") # already there
with self.assertRaises(fd.DeviceError):
self.reg.add_address(d["id"], "frame-3.local; id")
self.reg.move_address(d["id"], "100.100.1.1", -1)
self.reg.move_address(d["id"], "100.100.1.1", -1)
self.reg.move_address(d["id"], "100.100.1.1", -1) # already first: stays
hosts = lambda: [x["host"] for x in self.reg.get(d["id"])["addresses"]]
self.assertEqual(hosts(), ["100.100.1.1", "192.168.1.40", "frame-3.local"])
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 3.2)
self.reg.update_address(d["id"], "192.168.1.40", label="Home")
self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # a label keeps what it learned
with self.assertRaises(fd.DeviceError):
self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41", label="bad\nlabel")
self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # rejected: unchanged
self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41")
moved = self.reg.get(d["id"])["addresses"][1]
self.assertEqual((moved["host"], moved["networks"], moved["last_ok"]), ("192.168.1.41", [], None))
self.reg.remove_address(d["id"], "192.168.1.41")
self.assertEqual(hosts(), ["100.100.1.1", "frame-3.local"])
with self.assertRaises(fd.DeviceError):
self.reg.remove_address(d["id"], "nope")
def test_devices(self):
a = self.reg.add_device("frame")
b = self.reg.add_device("frame-2", name="Office")
self.assertEqual(self.reg.active(), a["id"])
with self.assertRaises(fd.DeviceError):
self.reg.add_device("frame")
self.reg.set_active(b["id"])
self.assertEqual(self.reg.update_device(b["id"], name="Desk", user="deck", port="2222")["port"], 2222)
with self.assertRaises(fd.DeviceError):
self.reg.update_device(b["id"], user="bad user")
with self.assertRaises(fd.DeviceError):
self.reg.update_device(b["id"], user="steam", port="bad")
self.assertEqual(self.reg.get(b["id"])["user"], "deck") # a rejected edit changes nothing
self.reg.remove_device(b["id"])
self.assertEqual(self.reg.active(), a["id"])
self.assertFalse(self.reg.emptied())
self.reg.remove_device(a["id"])
self.assertTrue(self.reg.emptied()) # the connector then uses no headset at all
self.reg.add_device("frame-4")
self.assertFalse(self.reg.emptied())
with self.assertRaises(fd.DeviceError):
self.reg.get(b["id"])
def test_networks_get_names(self):
net = {"id": "n-1", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "ssid": None, "wifi": True}
self.assertEqual(self.reg.network_name(net), "Wi-Fi via 192.168.1.1")
self.reg.record_network(net)
self.reg.name_network("n-1", "Home Wi-Fi")
self.assertEqual(self.reg.network_name(net), "Home Wi-Fi")
self.assertEqual(self.reg.network_name(dict(net, id="n-2", ssid="Cafe")), "Cafe")
self.assertEqual(self.reg.network_name(None), "No network")
with self.assertRaises(fd.DeviceError):
self.reg.name_network("n-unknown", "x")
class Order(unittest.TestCase):
def addr(self, host, kind, networks=()):
return {"host": host, "kind": kind, "networks": list(networks)}
def test_known_here_then_mdns_then_tailscale_then_the_rest(self):
addrs = [self.addr("10.1.1.5", "lan", ["n-office"]), self.addr("192.168.1.40", "lan"),
self.addr("100.64.1.2", "tailscale"), self.addr("frame.local", "mdns"),
self.addr("192.168.1.237", "lan", ["n-home"])]
order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
self.assertEqual(order, ["192.168.1.237", "frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5"])
# Tailscale off: its addresses go last.
order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", False)]
self.assertEqual(order[-1], "100.64.1.2")
# On an unknown network nothing has worked yet; the user's order breaks ties.
ranked = fd.order_addresses(addrs, None, True)
self.assertEqual([a["host"] for a, _ in ranked], ["frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5", "192.168.1.237"])
self.assertEqual(ranked[0][1], "mDNS name")
if __name__ == "__main__":
unittest.main()
class RoutingLongLivedSsh(unittest.TestCase):
"""The keyboard agent and the Mac view keep their own ssh open: switching headset
must end or retarget them, or input would go on reaching the old headset."""
def test_switching_headset_stops_input_and_retargets_the_mac_view(self):
import server
from unittest import mock
before = (server.FRAME, list(server.HOST_OPTS))
self.addCleanup(lambda: server.route(*before))
with mock.patch.object(server._input, "stop") as stop, \
mock.patch.object(server.macview, "retarget") as retarget:
server.route(server.FRAME, ["-o", "HostName=192.0.2.9"]) # same headset, new address
stop.assert_not_called()
server.route("frame-2", ["-o", "HostName=192.0.2.2"])
stop.assert_called_once()
retarget.assert_called_with("frame-2", ["-o", "HostName=192.0.2.2"])
+1 -1
View File
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
with self.assertRaisesRegex(android.FrameError, 'start this app'):
data.install_obb(PKG, [path])
stream.assert_not_called()
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command')
+87
View File
@@ -0,0 +1,87 @@
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
import sandbox # noqa: F401
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
import frame_host
class CapturedSSH(unittest.TestCase):
def run_command(self, source, **kwargs):
with mock.patch.object(frame_host, "WINDOWS", True):
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
def test_binary_output_and_input(self):
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
"sys.stderr.buffer.write(b'error\\r\\n')",
capture_output=True, input=b"data\x00\xff")
self.assertEqual(result.stdout, b"data\x00\xff")
self.assertEqual(result.stderr, b"error\r\n")
def test_text_output_normalizes_newlines(self):
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
capture_output=True, input="hello\n", text=True)
self.assertEqual(result.stdout, "hello\n")
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
def test_explicit_encoding_and_errors(self):
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
capture_output=True, encoding="ascii", errors="replace")
self.assertEqual(result.stderr, "\ufffd\ufffd")
def test_check_preserves_error_output(self):
with self.assertRaises(subprocess.CalledProcessError) as caught:
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
capture_output=True, text=True, check=True)
self.assertEqual(caught.exception.returncode, 7)
self.assertEqual(caught.exception.stdout, "out\n")
self.assertEqual(caught.exception.stderr, "err\n")
def test_timeout_preserves_partial_stderr(self):
with self.assertRaises(subprocess.TimeoutExpired) as caught:
with mock.patch.object(frame_host, "WINDOWS", True):
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
capture_output=True, text=True, timeout=1)
self.assertEqual(caught.exception.stderr, b"waiting")
def test_streamed_stdout_is_kept_separate(self):
with tempfile.TemporaryFile() as output:
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
"sys.stderr.buffer.write(b'error')",
stdout=output, stderr=subprocess.PIPE)
output.seek(0)
self.assertEqual(output.read(), b"file")
self.assertIsNone(result.stdout)
self.assertEqual(result.stderr, b"error")
def test_uncaptured_windows_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
def test_posix_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
def test_capture_rejects_explicit_streams(self):
for stream in ("stdout", "stderr"):
with self.subTest(stream=stream), self.assertRaises(ValueError):
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
def test_real_ssh_failure_returns_stderr_without_hanging(self):
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
self.assertEqual(result.returncode, 255)
self.assertIn("Could not resolve hostname", result.stderr)
+695
View File
@@ -0,0 +1,695 @@
"""frame_link: finding a headset among its addresses and following each stage of
connecting, with a stand-in ssh (tests/fakessh/ssh) and real sockets on this computer.
Also the server's /api/connection, its event stream, and /api/devices.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_devices as fd # noqa: E402
import frame_link as fl # noqa: E402
import frame_network as fn # noqa: E402
FAKESSH = ROOT / "tests" / "fakessh"
NET = {"id": "n-test", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "interface": "en0",
"ssid": None, "wifi": True, "local_ip": "192.168.1.9", "tailscale": {"up": False, "installed": False}}
def explain(msg):
"""A cut-down server.unreachable, so this needs no server import."""
if "Could not resolve" in msg:
return "Can't find the Frame on the network."
if "refused" in msg:
return "The Frame refused the connection."
if "timed out" in msg.lower():
return "The Frame isn't answering."
if "Permission denied" in msg:
return "The Frame didn't accept this computer's SSH key."
return None
class Probe(unittest.TestCase):
def test_answers_refusals_and_unknown_names(self):
with socket.socket() as srv:
srv.bind(("127.0.0.1", 0))
srv.listen(4)
port = srv.getsockname()[1]
seen = []
res = fl.probe("127.0.0.1", port, update=lambda **f: seen.append(f["state"]))
self.assertEqual(res["state"], "answered")
self.assertEqual(res["ip"], "127.0.0.1")
self.assertIsInstance(res["rtt_ms"], float)
self.assertEqual(seen, ["resolving", "trying"])
# Closed now. Windows retries a refused connect for about 2 s before saying so.
self.assertEqual(fl.probe("127.0.0.1", port, timeout=6 if os.name == "nt" else 2)["state"], "refused")
self.assertEqual(fl.probe("frame-control-test.invalid", 22, timeout=2)["state"], "unresolved")
def test_failed_probes_read_like_ssh(self):
# So the server's UNREACHABLE table words them like any other ssh failure.
self.assertIn("Could not resolve hostname x", fl.probe_raw("x", 22, {"state": "unresolved"}))
self.assertIn("port 22: Connection refused", fl.probe_raw("x", 22, {"state": "refused"}))
self.assertIn("Operation timed out", fl.probe_raw("x", 22, {"state": "timeout"}))
class Pick(unittest.TestCase):
def pick(self, results, tried=()):
return fl.Link.pick(results, set(tried), threading.Condition(), time.monotonic() + 5)
def test_best_ranked_answer_wins(self):
now = time.monotonic()
ok = lambda t=now: {"state": "answered", "t": t}
no = {"state": "timeout", "t": now}
self.assertEqual(self.pick([no, ok(), ok()]), 1)
self.assertEqual(self.pick([no, ok(), ok()], tried=[1]), 2)
self.assertIsNone(self.pick([no, no]))
# A worse-ranked answer waits PREFER for a better one still trying, then goes.
t0 = time.monotonic()
self.assertEqual(self.pick([None, ok(time.monotonic())]), 1)
self.assertGreaterEqual(time.monotonic() - t0, fl.PREFER - 0.05)
def test_gives_up_on_slow_lookups_at_the_deadline(self):
t0 = time.monotonic()
results = [None]
self.assertIsNone(fl.Link.pick(results, set(), threading.Condition(), time.monotonic() + 0.3))
self.assertLess(time.monotonic() - t0, 2)
self.assertEqual(results[0]["state"], "timeout")
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
class Connecting(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="frame-link-"))
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
(self.dir / "ssh").mkdir()
self.log = self.dir / "calls.jsonl"
env = {"FRAME_CONTROL_SSH_DIR": str(self.dir / "ssh"), "FAKESSH_LOG": str(self.log),
"FAKESSH_DIR": str(self.dir), "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
patcher = mock.patch.dict(os.environ, env)
patcher.start()
self.addCleanup(patcher.stop)
for name, value in (("current_network", lambda *a, **k: dict(NET)), ("fingerprint", lambda: ("192.168.1.1", "en0", "aa:bb:cc:dd:ee:ff"))):
p = mock.patch.object(fn, name, value)
p.start()
self.addCleanup(p.stop)
self.srv = socket.socket()
self.srv.bind(("127.0.0.1", 0))
self.srv.listen(16)
self.addCleanup(self.srv.close)
self.port = self.srv.getsockname()[1]
self.reg = fd.Registry(self.dir / "devices.json")
self.routes = []
self.link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes", "-o", "ControlPath=x"],
control="x", apply=lambda alias, opts: self.routes.append((alias, list(opts))),
explain=explain)
self.addCleanup(self.link.stop)
def test_start_routes_to_the_saved_headset_before_serving(self):
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
b = self.reg.add_device("frame-2", hosts=["192.0.2.2"])
self.reg.set_active(b["id"])
with mock.patch.object(self.link, "run", lambda: None): # no connector: only what start() applies
self.link.start()
self.assertEqual(self.routes[0][0], "frame-2")
self.assertIn("HostName=192.0.2.2", self.routes[0][1])
self.assertNotEqual(a["id"], b["id"])
def test_headset_removed_elsewhere_mid_install_reaches_nothing(self):
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
self.reg.add_device("frame-2", hosts=["192.0.2.2"])
self.reg.set_active(a["id"])
other = fd.Registry(self.dir / "devices.json") # another Frame Control server
other.remove_device(a["id"])
self.link.work = lambda: 1
self.assertTrue(self.link.active_device().get("none"))
self.link.work = lambda: 0
self.assertEqual(self.link.active_device()["alias"], "frame-2") # idle: move on
def test_nothing_elsewhere_moves_a_running_install(self):
"""A bare alias in use, then another server sets up and picks a headset."""
self.link.override = None
self.hosts({"localhost": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.connect(["start"])
started = self.routes[-1]
other = fd.Registry(self.dir / "devices.json")
other.set_active(other.add_device("frame-2", hosts=["192.0.2.2"])["id"])
self.link.work = lambda: 1
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.close_master()
self.link.connect(["dropped"])
self.assertEqual(self.routes[-1][0], started[0])
self.assertTrue(self.link.deferred)
def listen6(self):
"""A "different device": the same port on IPv6 loopback."""
try:
six = socket.socket(socket.AF_INET6)
self.addCleanup(six.close)
six.bind(("::1", self.port))
six.listen(4)
except OSError:
self.skipTest("no IPv6 loopback")
def pin(self, device_id):
fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True)
fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n")
def hosts(self, mapping):
# An IPv4 answer is what ssh is pointed at, so localhost arrives as 127.0.0.1.
if "localhost" in mapping:
mapping = dict({"127.0.0.1": mapping["localhost"]}, **mapping)
os.environ["FAKESSH_HOSTS"] = json.dumps(mapping)
def calls(self):
return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
def device(self, *hosts):
d = self.reg.add_device("frame-t", port=self.port, hosts=[])
for h in hosts:
self.reg.add_address(d["id"], h, kind="lan")
return d
def test_falls_through_to_the_address_that_is_really_the_headset(self):
# Tried in this order: a name that doesn't resolve, a different device, the headset.
self.listen6()
d = self.device("nothing.invalid", "::1", "127.0.0.1")
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual(s["via"]["host"], "127.0.0.1")
self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
rows = {p["host"]: p for p in s["probes"]}
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
self.assertEqual(rows["::1"]["state"], "sshfailed")
self.assertIn("different headset", rows["::1"]["detail"])
# Every ssh command was pointed at the winner, with the host key pinned per device.
alias, opts = self.routes[-1]
self.assertEqual(alias, "frame-t")
self.assertIn("HostName=127.0.0.1", opts)
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
self.assertIn(f"Port={self.port}", opts)
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
self.assertIn("StrictHostKeyChecking=accept-new", master) # first connection: nothing pinned yet
# ssh takes an option's first value: accept-new must come before the commands' own "yes".
self.assertLess(master.index("StrictHostKeyChecking=accept-new"), master.index("StrictHostKeyChecking=yes"))
self.assertIn("StrictHostKeyChecking=yes", opts) # every other command checks the pinned key
self.assertTrue(fd.known_hosts(d["id"]).parent.is_dir()) # where ssh saves the key it accepts
# It learned: 127.0.0.1 works on this network.
learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]}
self.assertEqual(learned["127.0.0.1"]["networks"], ["n-test"])
self.assertEqual(learned["::1"]["networks"], [])
self.assertTrue(self.link.alive())
self.link.close_master()
self.assertFalse(any(p.name.startswith("master-") for p in self.dir.iterdir()))
def test_stages_are_published_as_they_happen(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
steps, versions = [], []
real = self.link.stage
def stage(sid, state, detail=None):
real(sid, state, detail)
steps.append((sid, state))
versions.append(self.link.snapshot()["version"])
self.link.stage = stage
before = self.link.snapshot()["version"]
self.assertIsNone(self.link.wait(before, 0.05)) # nothing new yet
self.link.connect(["start"])
started = [sid for sid, state in steps if state == "active"]
self.assertEqual(list(dict.fromkeys(started)), ["network", "find", "ssh", "identity", "login"])
self.assertEqual([sid for sid, state in steps if state == "done"][-3:], ["ssh", "identity", "login"])
self.assertEqual(versions, sorted(versions)) # every step is a new version for the page
self.assertEqual(self.link.wait(before, 1)["phase"], "connected")
def test_nothing_answers(self):
self.device("nothing.invalid", "also-nothing.invalid")
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "failed")
self.assertEqual(s["error"]["stage"], "find")
self.assertEqual(s["error"]["message"], "Can't find the Frame on the network.")
self.assertGreater(s["retry_at"], time.time())
self.assertEqual([st["state"] for st in s["stages"]][:2], ["done", "failed"])
def test_refused_key_stops_at_login(self):
self.device("localhost")
self.hosts({"localhost": "denied"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
self.assertIn("SSH key", s["error"]["message"])
def test_pinned_identity_is_checked_strictly(self):
d = self.device("localhost")
self.pin(d["id"])
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
self.assertIn("StrictHostKeyChecking=yes", master)
def test_ssh_goes_to_the_ipv4_address_that_answered(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["via"]["host"], s["via"]["ip"]), ("connected", "localhost", "127.0.0.1"))
self.assertIn("HostName=127.0.0.1", self.routes[-1][1])
def test_no_headset_after_removing_them_all(self):
d = self.device("localhost")
self.reg.remove_device(d["id"])
self.link.connect(["switch"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["device"]["id"], s["retry_at"]), ("failed", "none", None))
self.assertIn("No headset", s["error"]["message"])
self.assertEqual(self.routes[-1], ("frame-control-no-headset", ["-o", "HostName=no-headset.invalid"]))
def test_a_headset_without_addresses_reaches_nothing(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
fl.devices_action(self.link, {"action": "address-remove", "id": d["id"], "host": "localhost"}, None)
self.assertIn("HostName=no-address.invalid", self.routes[-1][1]) # at once, not after a retry
self.link.connect(["switch"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["retry_at"]), ("failed", None))
self.assertIn("no addresses", s["error"]["message"])
def test_switching_back_to_the_frame_alias_the_server_started_with(self):
self.link.override = self.link.session_alias = "frame-bare"
other = self.device("localhost")
ids = [d["id"] for d in fl.devices_view(self.link)["devices"]]
self.assertEqual(ids, ["alias-frame-bare", other["id"]])
fl.devices_action(self.link, {"action": "use", "id": other["id"]}, None)
self.assertIn("alias-frame-bare", [d["id"] for d in fl.devices_view(self.link)["devices"]]) # still there
fl.devices_action(self.link, {"action": "use", "id": "alias-frame-bare"}, None)
self.assertEqual(self.link.active_device()["alias"], "frame-bare")
self.assertEqual(self.routes[-1][0], "frame-bare")
def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self):
d = self.device("localhost")
self.link.override = self.link.session_alias = "frame-t"
fl.devices_action(self.link, {"action": "remove", "id": d["id"], "config": True}, None)
self.assertNotIn("alias-frame-t", [x["id"] for x in fl.devices_view(self.link)["devices"]])
def test_setup_changing_the_login_waits_for_installs(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
cfg = self.dir / "ssh" / "config"
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User steamos\n"
f" Port {self.port}\nHost *\n# <<< steam-frame (frame-t) <<<\n")
self.link.watch_config() # the block's login is recorded
routes = len(self.routes)
cfg.write_text(cfg.read_text().replace("User steamos", "User deck"))
running = [1]
self.link.work = lambda: running[0]
self.link.config_mtime = None
self.link.watch_config()
self.assertEqual(len(self.routes), routes) # an install is running: not yet
self.link.connect(["dropped"]) # a reconnect meanwhile keeps the login it started with
self.assertIn("User=steamos", self.routes[-1][1])
running[0] = 0
self.link.watch_config()
self.assertIn("User=deck", self.routes[-1][1])
def test_a_rename_leaves_the_login_in_the_config_alone(self):
d = self.device("localhost")
cfg = self.dir / "ssh" / "config"
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User deck\n"
"Host *\n# <<< steam-frame (frame-t) <<<\n") # setup wrote a new user, not yet imported
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
self.assertIn("User deck", cfg.read_text())
out = fl.devices_action(self.link, {"action": "update", "id": d["id"], "port": 2200}, None)
self.assertIn("User deck", cfg.read_text()) # changed meanwhile: left as it is
self.assertIn("left as it is", out["message"])
def test_a_late_failure_from_the_last_headset_is_ignored(self):
self.link.state["phase"] = "connected"
self.link.gen = 3
self.link.lost("ssh: connect to host a port 22: Operation timed out", 2) # sent before the switch
self.assertEqual(self.link.kicks, [])
self.link.lost("ssh: connect to host b port 22: Operation timed out", 3)
self.assertEqual(len(self.link.kicks), 1)
def test_a_jump_hosts_login_isnt_the_headsets(self):
opts = ["-o", "HostName=10.0.0.5"]
self.assertFalse(fl.Link.is_target('Authenticated to bastion ([1.2.3.4]:22) using "publickey".', opts, "frame"))
self.assertTrue(fl.Link.is_target('Authenticated to 10.0.0.5 ([10.0.0.5]:22) using "publickey".', opts, "frame"))
self.assertTrue(fl.Link.is_target('Authenticated to frame.local ([10.0.0.5]:22) using "publickey".',
["-o", "HostName=FRAME.LOCAL"], "frame"))
def test_a_forward_the_jump_host_couldnt_open_tries_the_next_address(self):
said = ["Authenticated to bastion ([1.2.3.4]:22) using \"publickey\".",
"channel 0: open failed: connect failed: Connection refused", "stdio forwarding failed"]
self.link.state["stages"] = [{"id": i, "state": "pending", "started": None, "ended": None, "detail": ""}
for i, _ in fl.STAGES]
self.assertEqual(self.link.failed("login", said, False, "frame"), "next")
self.assertEqual(self.link.failed("login", ["steamos@frame: Permission denied (publickey)."], False, "frame"),
"stop")
def test_a_reconnect_being_started_isnt_a_live_connection(self):
self.link.state["phase"] = "connected"
self.assertTrue(self.link.alive())
self.link.busy = True # the loop took a Retry off the queue and is about to reconnect
self.assertFalse(self.link.alive()) # so ensure() waits instead of starting work on it
def test_probes_from_an_earlier_attempt_leave_the_new_rows_alone(self):
self.link.state.update(attempt=2, probes=[{"host": "b", "state": "waiting"}])
self.link.probe_update(0, 1, state="answered", ip="10.0.0.2")
self.assertEqual(self.link.state["probes"][0], {"host": "b", "state": "waiting"})
def test_a_bare_alias_lets_ssh_config_decide(self):
self.link.override = "frame-bare"
self.hosts({"localhost": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertTrue(s["device"]["transient"])
# Where ~/.ssh/config sends it, pinned down for the connection (ssh's own known_hosts).
alias, opts = self.routes[-1]
self.assertEqual((alias, opts[2:]), ("frame-bare", ["-o", "HostName=localhost", "-o", f"Port={self.port}",
"-o", "User=tester"]))
self.assertEqual(opts[:2], ["-o", "ControlPath=" + fl.frame_host.control_path(fl.Link.control_tag(s["device"]))])
def test_each_headset_has_its_own_shared_connection(self):
"""ssh's %C hashes only address, user and port: two headsets at one address
(one moved) must still never share a ControlMaster."""
a, b = (dict(fl.Link.bare("x"), id=i, transient=False, user="steamos", port=22) for i in ("aaaa1111", "bbbb2222"))
pa, pb = (next(o for o in self.link.host_opts(d, "192.0.2.5") if o.startswith("ControlPath=")) for d in (a, b))
self.assertNotEqual(pa, pb)
self.assertIn("aaaa1111", pa)
long_alias = fl.Link.bare("x" * 64)
path = next(o for o in self.link.host_opts(long_alias, None) if o.startswith("ControlPath="))
self.assertLess(len(path) - len("ControlPath=") - len("%C") + 40 + 17, 104) # fits a macOS socket path
def test_a_bare_alias_keeps_its_pinned_route_for_reconnects_and_terminals(self):
self.link.override = "frame-bare"
self.hosts({"localhost": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.connect(["start"])
pinned = self.routes[-1][1]
# ~/.ssh/config now sends the alias elsewhere, but an install is running.
self.link.work = lambda: 1
with mock.patch.object(fl, "ssh_g", return_value=("elsewhere.invalid", 2222, "other", False)):
self.link.close_master()
self.link.connect(["dropped"])
self.assertEqual(self.link.snapshot()["probes"][0]["host"], "localhost") # probed where commands go
self.assertEqual(self.link.snapshot()["phase"], "connected")
self.assertEqual(self.link.named_route(), ("frame-bare", pinned)) # terminals go there too
def test_a_set_up_headset_behind_a_jump_host_is_left_to_ssh(self):
d = self.device("10.99.99.98", "10.99.99.99") # neither answers directly
self.hosts({"10.99.99.98": "wrong", "10.99.99.99": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["via"]["host"]), ("connected", "10.99.99.99"), s["error"])
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", self.routes[-1][1]) # still pinned per headset
def test_a_bare_alias_behind_a_jump_host_is_left_to_ssh(self):
self.link.override = "frame-jump"
self.hosts({"10.99.99.99": "ok"}) # ssh's ProxyJump would get there
with mock.patch.object(fl, "ssh_g", return_value=("10.99.99.99", 22, "tester", True)):
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual(s["via"]["why"], "through a jump host")
def test_changing_the_port_reroutes_even_if_the_attempt_fails(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
self.reg.update_device(d["id"], port=1) # nothing listens there
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "failed")
self.assertIn("Port=1", self.routes[-1][1])
def test_test_now_checks_every_address_without_touching_the_connection(self):
self.listen6()
d = self.device("::1", "127.0.0.1", "nothing.invalid")
self.pin(d["id"])
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
self.link.test(d["id"])
rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]}
self.assertEqual(rows["127.0.0.1"]["ssh"], "ok")
self.assertEqual(rows["::1"]["ssh"], "wrong")
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
self.assertEqual(self.routes, [])
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
other = self.reg.add_device("frame-other", port=self.port)
self.reg.add_address(other["id"], "nothing.invalid")
self.link.use(other["id"])
self.assertEqual(self.routes[-1][0], "frame-other") # at once, before any attempt
self.assertEqual(self.link.snapshot()["phase"], "connecting")
self.assertFalse(self.link.alive()) # so ensure() waits instead of using the old master
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "failed")
alias, opts = self.routes[-1]
self.assertEqual(alias, "frame-other")
self.assertIn("HostName=nothing.invalid", opts)
self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts)
def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
other = self.reg.add_device("frame-other", port=self.port)
self.reg.add_address(other["id"], "nothing.invalid")
pick = fl.Link.pick
def switch_then_pick(*args):
if not getattr(self, "switched", False):
self.switched = True
self.link.use(other["id"]) # the user switches while A is being found
return pick(*args)
with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)):
self.link.connect(["start"])
self.assertEqual(self.routes[-1][0], "frame-other")
self.assertEqual(self.link.snapshot()["phase"], "connecting")
self.assertFalse(self.link.alive())
self.assertIsNone(self.link.master)
def test_no_switching_while_something_is_installing(self):
d = self.device("localhost")
other = self.reg.add_device("frame-other")
for body in ({"action": "use", "id": other["id"]}, {"action": "remove", "id": d["id"]},
{"action": "update", "id": d["id"], "port": 2222},
{"action": "address-remove", "id": d["id"], "host": "localhost"},
{"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"},
{"action": "forget-identity", "id": d["id"]}):
with self.assertRaises(fd.DeviceError, msg=body):
fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1)
# Renaming, or changing another headset, is fine.
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None, busy=lambda: 1)
fl.devices_action(self.link, {"action": "update", "id": other["id"], "port": 2222}, None, busy=lambda: 1)
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
def test_no_reconnecting_under_a_running_install(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "retry"}, None, busy=lambda: 1)
fl.devices_action(self.link, {"action": "retry"}, None) # fine when nothing runs
def test_terminals_get_the_address_by_name(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
alias, opts = self.link.named_route()
self.assertEqual(alias, "frame-t")
self.assertIn("HostName=localhost", opts)
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
def test_renaming_during_an_install_is_fine(self):
d = self.device("localhost")
# The page sends the user and port along with the name, unchanged.
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk", "user": "steamos",
"port": str(self.port)}, None, busy=lambda: 1)
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
def test_a_rename_shows_at_once(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
self.assertEqual(self.link.snapshot()["device"]["name"], "Desk")
def test_stopping_mid_handshake_leaves_no_ssh_behind(self):
self.device("localhost")
self.hosts({"localhost": "slow"})
t = threading.Thread(target=self.link.connect, args=(["start"],), daemon=True)
t.start()
for _ in range(100):
if self.link.pending:
break
time.sleep(0.05)
proc = self.link.pending
self.assertIsNotNone(proc)
self.link.stop()
t.join(10)
self.assertFalse(t.is_alive())
self.assertIsNotNone(proc.poll())
self.assertIsNone(self.link.master)
def test_test_now_goes_through_a_jump_host(self):
d = self.device("10.99.99.99")
self.pin(d["id"])
self.hosts({"10.99.99.99": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
self.link.test(d["id"])
row = self.link.snapshot()["tests"][d["id"]]["rows"][0]
self.assertEqual(row["ssh"], "ok", row)
self.assertIn("jump host", row["detail"])
def test_devices_api_checks_everything(self):
d = self.device("localhost")
bad = [{"action": "address-add", "id": d["id"], "host": "-oProxyCommand=touch /tmp/x"},
{"action": "address-add", "id": d["id"], "host": "a\nHost *"},
{"action": "address-add", "id": d["id"], "host": "frame.local", "kind": "wifi"},
{"action": "update", "id": d["id"], "user": "root; id"},
{"action": "update", "id": d["id"], "port": 0},
{"action": "address-move", "id": d["id"], "host": "localhost", "delta": 5},
{"action": "setup", "alias": "-F/etc/passwd"},
{"action": "setup", "alias": "frame-9", "host": "$(id)"},
{"action": "use", "id": "nope"},
{"action": "explode"}]
for body in bad:
with self.assertRaises(fd.DeviceError, msg=body):
fl.devices_action(self.link, body, open_setup=lambda *a: self.fail("setup ran"))
# Removing every headset leaves none in use, rather than falling back to the `frame` alias.
spare = self.reg.add_device("frame-spare")
fl.devices_action(self.link, {"action": "remove", "id": spare["id"]}, None)
# The only headset, whose ssh alias would stay: not without removing that too.
(self.dir / "ssh" / "config").write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n"
"Host *\n# <<< steam-frame (frame-t) <<<\n")
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "remove", "id": d["id"]}, None)
opened = []
out = fl.devices_action(self.link, {"action": "setup", "alias": "frame-9", "host": "192.168.1.50"},
open_setup=lambda alias, host: opened.append((alias, host)) or "a terminal")
self.assertEqual(opened, [("frame-9", "192.168.1.50")])
self.assertIn("frame-9", out["message"])
self.assertEqual(out["active"], d["id"])
self.assertEqual(fl.next_alias(self.link), "frame")
(self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
self.assertEqual(fl.next_alias(self.link), "frame-2")
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
class ServerConnection(unittest.TestCase):
"""The real server, a Set Up Connection block in a stand-in ~/.ssh, and the stand-in ssh."""
@classmethod
def setUpClass(cls):
cls.dir = Path(tempfile.mkdtemp(prefix="frame-link-server-"))
ssh_dir = cls.dir / "ssh"
ssh_dir.mkdir()
cls.srv = socket.socket() # the "headset's" port 22
cls.srv.bind(("127.0.0.1", 0))
cls.srv.listen(16)
(ssh_dir / "config").write_text("# >>> steam-frame (frame) >>>\nHost frame\n HostName localhost\n"
f" Port {cls.srv.getsockname()[1]}\n"
" User steamos\nHost *\n# <<< steam-frame (frame) <<<\n")
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", "FRAME_CONTROL_SSH_DIR": str(ssh_dir),
"FRAME_CONTROL_DATA_DIR": str(cls.dir / "data"), "FAKESSH_LOG": str(cls.dir / "calls.jsonl"),
"FAKESSH_DIR": str(cls.dir), "FAKESSH_HOSTS": json.dumps({"localhost": "ok", "127.0.0.1": "ok"}),
"PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
env.pop("FRAME_ALIAS", None)
cls.log = tempfile.TemporaryFile()
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
stdout=subprocess.PIPE, stderr=cls.log, text=True)
cls.port = int(cls.proc.stdout.readline().split("127.0.0.1:")[1].split()[0])
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=15)
cls.proc.stdout.close()
cls.log.close()
cls.srv.close()
shutil.rmtree(cls.dir, ignore_errors=True)
def request(self, method, path, body=None, key="1"):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
headers={"X-Frame-UI": key, "Content-Type": "application/json"})
r = conn.getresponse()
data = json.loads(r.read() or b"{}")
conn.close()
return r.status, data
def wait_connected(self):
for _ in range(100):
status, s = self.request("GET", "/api/connection")
if s.get("phase") in ("connected", "failed"):
return s
time.sleep(0.1)
self.fail(f"never connected: {s}")
def test_imports_the_headset_and_connects_through_its_port(self):
s = self.wait_connected()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual(s["via"]["host"], "localhost")
self.assertEqual(s["device"]["alias"], "frame")
self.assertEqual(s["device"]["name"], "Steam Frame")
self.assertEqual(s["probes"][0]["host"], "localhost")
status, devices = self.request("GET", "/api/devices")
self.assertEqual(status, 200)
self.assertEqual([d["alias"] for d in devices["devices"]], ["frame"])
self.assertEqual(devices["nextAlias"], "frame-2")
def test_events_stream_the_state(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
conn.request("GET", "/api/connection/events", headers={"X-Frame-UI": "1"})
r = conn.getresponse()
self.assertEqual(r.status, 200)
self.assertEqual(r.getheader("Content-Type"), "text/event-stream")
line = r.fp.readline()
self.assertTrue(line.startswith(b"data: "), line)
self.assertIn("stages", json.loads(line[6:]))
conn.close()
def test_changes_meant_for_another_headset_are_refused(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
conn.request("POST", "/api/launch", body=b'{"appid": "620"}',
headers={"X-Frame-UI": "1", "Content-Type": "application/json", "X-Frame-Device": "someoneelse"})
r = conn.getresponse()
self.assertEqual(r.status, 409)
self.assertIn("switched headsets", json.loads(r.read())["error"])
conn.close()
def test_guards_and_validation(self):
self.assertEqual(self.request("GET", "/api/connection", key="")[0], 403)
self.assertEqual(self.request("GET", "/api/devices", key="nope")[0], 403)
self.assertEqual(self.request("POST", "/api/devices", {"action": "address-add", "id": "x", "host": "a;b"})[0], 400)
self.assertEqual(self.request("POST", "/api/devices", {"action": "setup", "alias": "-oProxyCommand=x"})[0], 400)
self.assertEqual(self.request("POST", "/api/devices", {"action": "nope"})[0], 400)
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -10,6 +10,7 @@ Run: python3 -m unittest discover -s tests
import base64
import http.client
import json
import io
import os
import shutil
import socket
@@ -42,6 +43,43 @@ class Helpers(unittest.TestCase):
self.assertEqual(h, 1080)
self.assertAlmostEqual(w / h, 0.5, places=2)
def test_the_tunnel_follows_the_headset(self):
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
mv.retarget("frame", ["-o", "ControlPath=/tmp/x-%C", "-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
self.assertEqual(mv.host_opts, ["-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
class Tunnel:
ended = False
def poll(self): return None
def terminate(self): Tunnel.ended = True
mv.tunnel, mv.remote_port = Tunnel(), 47999
mv.retarget("frame", ["-o", "HostName=192.0.2.9"]) # another address, same headset: keep it
self.assertFalse(Tunnel.ended)
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # another headset: never the old one's tunnel
self.assertTrue(Tunnel.ended)
self.assertIsNone(mv.tunnel)
self.assertEqual(mv.frame, "frame-2")
def test_a_switch_just_before_publishing_drops_the_old_headsets_tunnel(self):
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
mv.port = 47000
ended = []
class Proc:
def poll(self): return None
def terminate(self): ended.append(self)
def wait(self): return 0
stderr = io.StringIO("")
def probe(port):
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # the app switches right now
return True
with mock.patch.object(frame_macview.subprocess, "Popen", return_value=Proc()), \
mock.patch.object(frame_macview.time, "sleep"), mock.patch.object(mv, "_probe", probe):
self.assertFalse(mv._open_tunnel([], [47001]))
self.assertIsNone(mv.tunnel)
self.assertEqual(len(ended), 1) # the tunnel to the old headset was closed
@unittest.skipUnless(shutil.which("bash"), "needs bash")
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
def test_launch_script_parses(self):
+160
View File
@@ -1,6 +1,9 @@
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
import argparse
import io
import json
import os
import signal
from pathlib import Path
import struct
import sys
@@ -16,6 +19,13 @@ import frame_splat as splat
import server
def stop_now():
"""What systemd's SIGTERM does to the player, without signalling the test process."""
handler = signal.getsignal(signal.SIGTERM)
if callable(handler):
handler(signal.SIGTERM, None)
class Media(unittest.TestCase):
def test_layout_evidence_and_override(self):
for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
@@ -56,6 +66,156 @@ class Media(unittest.TestCase):
self.assertNotIn('-re', cmd)
self.assertIn('-frames:v', cmd)
def play_with(self, name, busy=0, on_pixels=None, sleeps=None, info=None,
fail=None, layout='auto'):
"""Run the player against a fake OpenVR; returns (status, pixels calls).
Handle 0 is the theatre surround and 1 the screen. `fail(handle, n)` makes
the n-th upload busy; every status written is kept in self.writes."""
calls = []
self.writes = []
write_status = player.write_status
def record(path, **values):
self.writes.append(values)
write_status(path, **values)
class FakeOverlay:
def create(self, *a, **k):
return len(calls)
def call(self, *a):
pass
def pixels(self, handle, data, w, h):
calls.append((handle, w, h))
if on_pixels:
on_pixels(len(calls))
if len(calls) <= busy or (fail and fail(handle, len(calls))):
raise player.OverlayBusy('standby')
def close(self):
# A Stop landing during cleanup must be ignored, not become an error.
# Call the installed handler directly: a real SIGTERM kills Windows.
stop_now()
frame = bytes(4*2*4)
proc = unittest.mock.MagicMock()
proc.stdout = io.BytesIO(frame*4)
proc.wait.return_value = 0
proc.poll.return_value = 0
old = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
with tempfile.TemporaryDirectory() as d, \
patch.object(player, 'Overlay', FakeOverlay), \
patch.object(player, 'probe', return_value=(info or {'codec_name': 'h264', 'width': 4, 'height': 2}, False)), \
patch.object(player.subprocess, 'Popen', return_value=proc), \
patch.object(player, 'write_status', side_effect=record), \
patch.object(player.frame_splat, 'render', return_value=(bytes(4*4*2), 4, 2)), \
patch.object(player.time, 'sleep', side_effect=sleeps):
path = Path(d)/name
path.write_bytes(b'x')
status = Path(d)/'status.json'
try:
player.play(argparse.Namespace(file=str(path), layout=layout, theatre=True, status=str(status)))
finally:
signal.signal(signal.SIGTERM, old[0])
signal.signal(signal.SIGINT, old[1])
return json.loads(status.read_text()), calls
def test_video_survives_standby_and_stop_after_end_stays_ended(self):
# Verified 2026-09-29: an unworn Frame enters standby within seconds and
# SetOverlayRaw then returns RequestFailed (23) until it wakes.
result, calls = self.play_with('clip_SBS.mp4', busy=3)
self.assertEqual((result['state'], result['frames']), ('ended', 4))
# Two video frames were dropped; the surround (handle 0) waited and was re-sent.
self.assertEqual(result['dropped'], 2)
self.assertIn((0, 1, 1), calls[3:])
def test_stop_mid_video_reports_stopped(self):
result, _ = self.play_with('clip_SBS.mp4', on_pixels=lambda n: n == 3 and stop_now())
self.assertEqual(result['state'], 'stopped')
def test_video_errors_when_steamvr_never_takes_frames(self):
with patch.object(player, 'BUSY_LIMIT', -1), \
self.assertRaisesRegex(RuntimeError, 'stopped accepting frames'):
self.play_with('clip_SBS.mp4', busy=99)
def test_still_waits_out_standby_without_a_limit(self):
# Stills have no timeline: keep retrying (here past BUSY_LIMIT) until shown.
ticks = iter(range(10))
def sleep(_):
if next(ticks) == 8:
stop_now()
with patch.object(player, 'BUSY_LIMIT', -1):
result, calls = self.play_with('photo_SBS.png', busy=5, sleeps=sleep,
info={'codec_name': 'png', 'width': 4, 'height': 2})
self.assertEqual(result['state'], 'stopped')
screen = [c for c in calls if c[0] == 1]
self.assertGreater(len(screen), 1) # retried through standby
self.assertEqual(calls[-1], (0, 1, 1)) # surround drained once the screen took a frame
def still_with_late_surround(self, name, **kw):
# The screen takes its first frame while the surround is still refused
# (its first upload, the drain right after the screen, and one retry).
ticks = iter(range(10))
def sleep(_):
if next(ticks) == 5:
stop_now()
surround_tries = []
def fail(handle, n):
if handle == 0:
surround_tries.append(n)
return len(surround_tries) <= 3
return False
result, calls = self.play_with(name, sleeps=sleep, fail=fail, **kw)
self.assertEqual(result['state'], 'stopped')
screen = [c for c in calls if c[0] == 1]
surround = [c for c in calls if c[0] == 0]
self.assertEqual(len(screen), 1) # shown once, not re-sent every second
self.assertEqual(len(surround), 4) # kept retrying after the screen, until it took
self.assertEqual(calls[-1][0], 0)
return calls
def test_photo_surround_recovers_after_screen_is_shown(self):
self.still_with_late_surround('photo_SBS.png',
info={'codec_name': 'png', 'width': 4, 'height': 2})
def test_splat_surround_recovers_after_screen_is_shown(self):
self.still_with_late_surround('scene.splat')
def test_video_standby_limit_is_five_minutes_without_an_accepted_frame(self):
self.assertEqual(player.BUSY_LIMIT, 300)
def run(times, busy):
# Upload n happens at times[n] seconds on a fake clock; 1 is the surround.
clock = [1000.0]
def on_pixels(n):
clock[0] = 1000.0 + times.get(n, times[max(times)])
with patch.object(player.time, 'monotonic', side_effect=lambda: clock[0]):
return self.play_with('clip_SBS.mp4', on_pixels=on_pixels,
fail=lambda h, n: h == 1 and n in busy)
# Busy for 299 s, then a frame lands: no error.
result, _ = run({1: 0, 2: 0, 3: 299, 4: 299, 5: 299}, busy={2, 3})
self.assertEqual((result['state'], result['dropped']), ('ended', 2))
# An accepted frame resets the timer: 600 s busy in total, never 300 s in a row.
result, _ = run({1: 0, 2: 0, 3: 200, 4: 250, 5: 450}, busy={2, 3, 5})
self.assertEqual((result['state'], result['dropped']), ('ended', 3))
# 301 s in a row without an accepted frame is an error.
with self.assertRaisesRegex(RuntimeError, 'stopped accepting frames for 300 s'):
run({1: 0, 2: 0, 3: 301}, busy={2, 3, 4, 5})
def test_status_reports_where_the_layout_came_from(self):
video = {'codec_name': 'h264', 'width': 4, 'height': 2}
for name, layout, tags, expect in [
('clip_SBS.mp4', 'auto', None, ('sbs', 'filename')),
('clip.mkv', 'auto', {'stereo_mode': 'left_right'}, ('full-sbs', 'metadata')),
('clip_OU.mp4', 'sbs', None, ('sbs', 'explicit')),
('clip.mkv', 'mono', {'stereo_mode': 'left_right'}, ('mono', 'explicit'))]:
with self.subTest(name=name, layout=layout):
self.play_with(name, layout=layout, info=dict(video, tags=tags) if tags else video)
playing = self.writes[0]
self.assertEqual(playing['state'], 'playing')
self.assertEqual((playing['layout'], playing['source']), expect)
def test_fake_frame_library_and_traversal(self):
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
identity = 'a'*32+'/space and quote\'.png'
+147
View File
@@ -0,0 +1,147 @@
"""frame_network's parsers, with what macOS, Linux and Windows print.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_network as fn # noqa: E402
MAC_ROUTE = """ route to: default
destination: default
mask: default
gateway: 192.168.1.1
interface: en0
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
"""
MAC_ARP = "? (192.168.1.1) at b4:fb:e4:1:87:3f on en0 ifscope [ethernet]\n"
MAC_ARP_INCOMPLETE = "? (192.168.1.1) at (incomplete) on en0 ifscope [ethernet]\n"
MAC_SUMMARY = """<dictionary> {
BSSID : <redacted>
ConnectionID : 1
InterfaceType : WiFi
LinkStatusActive : TRUE
NetworkID : <redacted>
SSID : <redacted>
Security : WPA2_PSK
}"""
MAC_SUMMARY_NAMED = MAC_SUMMARY.replace("SSID : <redacted>\n Security", "SSID : Home Net\n Security")
MAC_SUMMARY_WIRED = "<dictionary> {\n InterfaceType : Ethernet\n LinkStatusActive : TRUE\n}"
LINUX_ROUTE = """default via 10.0.0.1 dev wlp2s0 proto dhcp src 10.0.0.23 metric 600
default via 192.168.50.1 dev enp3s0 proto dhcp src 192.168.50.9 metric 100
"""
LINUX_NEIGH = "192.168.50.1 dev enp3s0 lladdr 00:11:22:aa:bb:cc REACHABLE\n"
NMCLI = "no:Neighbour\nyes:Cafe\\: upstairs\nno:\n"
WIN_ROUTE = """===========================================================================
Interface List
12...00 15 5d 01 02 03 ......Intel(R) Wi-Fi 6 AX201 160MHz
===========================================================================
IPv4 Route Table
===========================================================================
Active Routes:
Network Destination Netmask Gateway Interface Metric
0.0.0.0 0.0.0.0 192.168.0.254 192.168.0.40 50
0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.50 35
===========================================================================
Persistent Routes:
None
"""
WIN_ARP = """
Interface: 192.168.1.50 --- 0xc
Internet Address Physical Address Type
192.168.1.1 b4-fb-e4-b5-67-55 dynamic
"""
NETSH = """
There is 1 interface on the system:
Name : Wi-Fi
Description : Intel(R) Wi-Fi 6 AX201 160MHz
State : connected
SSID : Office 5G
BSSID : 12:34:56:78:9a:bc
Network type : Infrastructure
"""
NETSH_OFF = NETSH.replace("State : connected", "State : disconnected")
TAILSCALE = json.dumps({
"BackendState": "Running",
"CurrentTailnet": {"Name": "example.github"},
"Self": {"HostName": "laptop", "DNSName": "laptop.tail1234.ts.net.", "TailscaleIPs": ["fd7a:115c:a1e0::1", "100.101.102.103"]},
"Peer": {"nodekey:1": {"HostName": "frame", "DNSName": "frame.tail1234.ts.net.", "OS": "linux", "Online": True,
"TailscaleIPs": ["100.101.102.103", "fd7a:115c:a1e0::1234:5678"]},
"nodekey:2": {"HostName": "phone", "DNSName": "phone.tail1234.ts.net.", "OS": "iOS", "Online": False,
"TailscaleIPs": ["100.77.1.2"]}},
})
class Parsers(unittest.TestCase):
def test_macos(self):
self.assertEqual(fn.parse_route_macos(MAC_ROUTE), ("192.168.1.1", "en0"))
self.assertEqual(fn.parse_route_macos("route: writing to routing socket: not in table\n"), (None, None))
self.assertEqual(fn.parse_arp_macos(MAC_ARP, "192.168.1.1"), "b4:fb:e4:01:87:3f") # padded
self.assertIsNone(fn.parse_arp_macos(MAC_ARP_INCOMPLETE, "192.168.1.1"))
self.assertIsNone(fn.parse_arp_macos(MAC_ARP, "192.168.1.10"))
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY), (None, True)) # no Location permission
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_NAMED), ("Home Net", True))
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_WIRED), (None, False))
def test_linux(self):
self.assertEqual(fn.parse_route_linux(LINUX_ROUTE), ("192.168.50.1", "enp3s0")) # lowest metric
self.assertEqual(fn.parse_route_linux(""), (None, None))
self.assertEqual(fn.parse_neigh_linux(LINUX_NEIGH, "192.168.50.1"), "00:11:22:aa:bb:cc")
self.assertIsNone(fn.parse_neigh_linux("192.168.50.1 dev enp3s0 FAILED\n", "192.168.50.1"))
self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs")
self.assertIsNone(fn.parse_nmcli("no:Neighbour\n"))
def test_windows(self):
self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50"))
self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55")
self.assertEqual(fn.parse_netsh(NETSH), "Office 5G") # not the BSSID
self.assertIsNone(fn.parse_netsh(NETSH_OFF))
def test_mac_addresses(self):
self.assertEqual(fn.norm_mac("B4-FB-E4-B5-67-55"), "b4:fb:e4:b5:67:55")
for bad in ("", "(incomplete)", "ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00", "b4:fb:e4:b5:67", "zz:fb:e4:b5:67:55"):
self.assertIsNone(fn.norm_mac(bad), bad)
def test_network_id_is_stable_and_needs_both_parts(self):
a = fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55")
self.assertEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55"))
self.assertTrue(a.startswith("n-"))
self.assertNotEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:56")) # same IP, another router
self.assertIsNone(fn.network_id("192.168.1.1", None))
self.assertIsNone(fn.network_id(None, "b4:fb:e4:b5:67:55"))
def test_tailscale(self):
ts = fn.parse_tailscale(TAILSCALE)
self.assertTrue(ts["up"])
self.assertEqual(ts["ip"], "100.101.102.103")
self.assertEqual(ts["name"], "laptop.tail1234.ts.net")
self.assertEqual(ts["tailnet"], "example.github")
frame = ts["peers"][0]
self.assertEqual((frame["name"], frame["dns"], frame["os"], frame["online"]),
("frame", "frame.tail1234.ts.net", "linux", True))
self.assertFalse(fn.parse_tailscale(json.dumps({"BackendState": "Stopped", "Self": {}}))["up"])
self.assertEqual(fn.parse_tailscale("not json"), {"up": False, "peers": []})
self.assertEqual(fn.parse_tailscale("[]"), {"up": False, "peers": []})
def test_address_kinds(self):
cases = {"frame.local": "mdns", "frame.local.": "mdns", "frame.tail1234.ts.net": "tailscale",
"100.101.102.103": "tailscale", "fd7a:115c:a1e0::1234:5678": "tailscale",
"192.168.1.40": "lan", "10.0.0.5": "lan", "fe80::1%en0": "lan",
"frame.example.com": "manual", "8.8.8.8": "manual"}
for host, kind in cases.items():
self.assertEqual(fn.guess_kind(host), kind, host)
if __name__ == "__main__":
unittest.main()
+161
View File
@@ -0,0 +1,161 @@
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
staying quiet when the page goes away mid-reply, which on Windows is
ConnectionAbortedError (WinError 10053).
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import email.message
import io
import socket
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import server # noqa: E402
def platform(name):
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
LINUX=name == "linux")
class OpenRdp(unittest.TestCase):
def setUp(self):
self.xrdp = socket.socket()
self.xrdp.bind(("127.0.0.1", 0))
self.xrdp.listen(4)
self.addCleanup(self.xrdp.close)
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
port.start()
self.addCleanup(port.stop)
self.spawned = []
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
spawn.start()
self.addCleanup(spawn.stop)
cache = tempfile.TemporaryDirectory()
self.addCleanup(cache.cleanup)
self.cache = Path(cache.name)
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
where.start()
self.addCleanup(where.stop)
def test_windows_signs_in_as_steamos(self):
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
with platform("windows"):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(len(self.spawned), 1)
argv = self.spawned[0]
self.assertEqual(argv[0], "mstsc.exe")
self.assertNotIn("/v:127.0.0.1", argv)
rdp = Path(argv[1])
self.assertEqual(rdp.suffix, ".rdp")
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
self.assertNotIn(b"\r\r", data)
lines = data.decode("utf-8").split("\r\n")
self.assertIn("full address:s:127.0.0.1", lines)
self.assertIn("username:s:steamos", lines)
self.assertIn("steamos", message)
self.assertIn("Developer Mode password", message)
self.assertIn("certificate", message)
self.assertIn("Connect", message)
def test_nothing_listening_says_why_and_opens_nothing(self):
self.xrdp.close()
for name in ("windows", "mac", "linux"):
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "127.0.0.1")
self.assertIn("Developer Mode", str(cm.exception))
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_says_which_way_it_failed(self):
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
(socket.timeout("timed out"), "didn't answer"),
(OSError(65, "No route to host"), "didn't answer")):
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "frame.local")
self.assertIn(says, str(cm.exception))
self.assertNotIn("refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_server_says_it_as_the_persons_to_fix(self):
# A 400 with the message, not a 500 filed as an error diagnostic.
self.xrdp.close()
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
self.assertRaises(server.Failure) as cm:
server.open_thing({"what": "rdp"})
self.assertEqual(cm.exception.status, 400)
self.assertIn("Developer Mode", str(cm.exception))
def test_one_file_per_address(self):
with platform("windows"):
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
self.assertEqual(len({a, b, c, d}), 4)
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
def test_address_cant_add_lines_to_the_file(self):
with platform("windows"), self.assertRaises(frame_host.HostError):
frame_host.rdp_file("frame\r\nusername:s:root")
self.assertEqual(list(self.cache.iterdir()), [])
def test_linux_clients_get_the_user(self):
with platform("linux"), mock.patch.object(frame_host, "which",
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
self.assertIn("steamos", message)
class PageGoneAway(unittest.TestCase):
"""The report's server log: the page closed while index.html was being sent, and the
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
def handler(self, path="/"):
h = server.Handler.__new__(server.Handler)
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
h.headers = email.message.Message()
h.headers["Host"] = "127.0.0.1:1"
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
h.close_connection = True
return h
def test_not_a_server_error(self):
h = self.handler()
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
h.do_GET()
diagnostic.assert_not_called()
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
def test_server_logs_nothing(self):
srv = server.LoopbackServer.__new__(server.LoopbackServer)
err = io.StringIO()
with mock.patch.object(sys, "stderr", err):
try:
raise server.ClientGone()
except server.ClientGone:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertEqual(err.getvalue(), "")
try:
raise RuntimeError("real")
except RuntimeError:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertIn("RuntimeError: real", err.getvalue())
if __name__ == "__main__":
unittest.main()
+61 -1
View File
@@ -34,7 +34,9 @@ class ServerGuards(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.port = free_port()
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
"FRAME_CONTROL_SSH_DIR": cls.ssh_dir}
cls.log = tempfile.TemporaryFile()
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
@@ -109,6 +111,8 @@ class ServerGuards(unittest.TestCase):
("/api/volume", {"level": 1.5}),
("/api/clipboard", {"text": ""}),
("/api/open", {"what": "anything-else"}),
("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}),
("/api/open", {"what": "shot"}),
("/api/shots/save", {"ids": []}),
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
("/api/shots/save", {"ids": [1]}),
@@ -119,6 +123,10 @@ class ServerGuards(unittest.TestCase):
status, payload = self.post(path, body)
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
def test_showing_a_shot_needs_it_saved_here(self):
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
self.assertEqual(status, 404, payload)
def test_screenshot_ids_checked_before_ssh(self):
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
@@ -238,6 +246,58 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.post("/api/nope", {})[0], 404)
class OneServer(unittest.TestCase):
"""Two servers for one user would each connect and edit headsets on their own."""
def start(self, env):
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close()))
return proc
def test_a_second_server_is_refused_until_the_first_exits(self):
data = tempfile.mkdtemp(prefix="frame-one-server-")
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
"FRAME_CONTROL_SERVER_WAIT": "1"}
first = self.start(env)
self.assertIn("Frame Control on", first.stdout.readline())
second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
capture_output=True, text=True, timeout=60)
self.assertEqual(second.returncode, 1)
self.assertIn("already running", second.stderr)
first.terminate()
first.wait(10)
self.assertIn("Frame Control on", self.start(env).stdout.readline())
def test_a_private_server_runs_alongside_but_cant_change_headsets(self):
"""The MCP adapter starts its own server (FRAME_PRIVATE_SSH=1) while the app runs."""
data = tempfile.mkdtemp(prefix="frame-one-server-")
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
"FRAME_CONTROL_SERVER_WAIT": "1"}
self.assertIn("Frame Control on", self.start(env).stdout.readline())
private = self.start({**env, "FRAME_PRIVATE_SSH": "1"})
line = private.stdout.readline()
self.assertIn("Frame Control on", line)
port = int(line.split("http://127.0.0.1:")[1].split()[0])
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request("POST", "/api/devices", body=json.dumps({"action": "use", "id": "x"}),
headers={"Content-Type": "application/json", "X-Frame-UI": "1", "Host": f"127.0.0.1:{port}"})
r = conn.getresponse()
self.assertEqual(r.status, 403, r.read())
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit."""
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
"FRAME_ALIAS": "frame-control-test.invalid"}
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close()))
self.assertIn("Frame Control on", proc.stdout.readline())
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
+7 -4
View File
@@ -391,14 +391,16 @@ class ReportProblem(Base):
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
got = self.serve()
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com"})
with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com", "contactFollowup": True})
path, body = got[0]
event = body["batch"][0]
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
props = event["properties"]
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
self.assertIn(res["id"], res["message"])
@@ -421,8 +423,9 @@ class ReportProblem(Base):
def test_the_inbox_skips_malformed_reports(self):
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
"0.4.0", "macOS", "", ""]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
"0.4.0", "macOS", "", "", None, None, None]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
["short"], good]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
mock.patch("builtins.print") as out:
+162
View File
@@ -0,0 +1,162 @@
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import frame_devices as fd # noqa: E402
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
def ran(*results):
"""subprocess.run stand-in answering whoami, then icacls."""
calls = []
def run(argv, **kw):
calls.append(argv)
return results[len(calls) - 1]
return run, calls
class MakePrivate(unittest.TestCase):
def test_windows_sets_owner_only_acl_by_sid(self):
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
subprocess.CompletedProcess([], 0))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
def test_windows_falls_back_to_username_and_reports_failure(self):
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
self.assertFalse(frame_host.make_private(Path("config")))
self.assertIn("bob:F", calls[1])
@unittest.skipIf(os.name == "nt", "POSIX modes")
def test_posix_chmods_600(self):
with tempfile.NamedTemporaryFile() as f:
os.chmod(f.name, 0o644)
self.assertTrue(frame_host.make_private(f.name))
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
class ConfigWrites(unittest.TestCase):
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
self.config = self.ssh / "config"
def test_devices_and_connect_writes_make_the_file_private(self):
import frame_connect as fc
self.config.write_text("Host other\n User me\n", encoding="utf-8")
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
fc.write_config("10.0.0.5")
self.assertTrue(fd.repair_permissions(self.config))
fd.rewrite_block("frame", path=self.config, user="deck")
self.assertEqual(private.call_count, 3)
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
def test_setup_runs_isolated_as_the_app_starts_it(self):
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
self.assertNotIn("ModuleNotFoundError", r.stderr)
self.assertIn("frame_connect.py", r.stdout + r.stderr)
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
self.assertFalse(fd.repair_permissions(self.config))
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
self.config.write_bytes(data)
with mock.patch.object(frame_host, "make_private", return_value=True):
self.assertTrue(fd.repair_permissions(self.config))
self.assertEqual(self.config.read_bytes(), data)
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
self.config.write_bytes(b"Host a\n")
before = self.config.stat().st_ino
with mock.patch.object(frame_host, "make_private", return_value=False):
self.assertFalse(fd.repair_permissions(self.config))
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
class ServerRepair(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server = server
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
mock.patch.object(frame_host, "WINDOWS", True),
mock.patch.object(self.server, "_config_repaired", False)]
for p in patches:
p.start()
self.addCleanup(p.stop)
def test_repairs_the_refused_config_once(self):
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
self.assertTrue(self.server.repair_ssh_config(REFUSED))
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_called_once()
def test_leaves_other_files_and_errors_alone(self):
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
with mock.patch.object(fd, "repair_permissions") as repair:
self.assertFalse(self.server.repair_ssh_config(key))
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
with mock.patch.object(frame_host, "WINDOWS", False):
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_not_called()
def test_ssh_retries_after_repairing(self):
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
mock.patch.object(fd, "repair_permissions", return_value=True), \
mock.patch.object(self.server, "LINK", None):
self.assertEqual(self.server.ssh("true"), "ok")
class LinkLocalZone(unittest.TestCase):
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
def probe(self, windows):
import frame_link as fl
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
sock = mock.MagicMock()
with mock.patch.object(frame_host, "WINDOWS", windows), \
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
mock.patch.object(fl.socket, "socket", return_value=sock), \
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
return fl.probe("frame.local", 22)["ip"]
def test_windows_uses_the_numeric_zone(self):
self.assertEqual(self.probe(True), "fe80::1%12")
def test_elsewhere_uses_the_interface_name(self):
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -1,7 +1,9 @@
"""Agent actions and one-use human approvals. No model SDK or network calls here."""
import hashlib
import os
from pathlib import Path
import secrets
import shlex
import shutil
import subprocess
import threading
@@ -129,7 +131,10 @@ def run_script(server, name, args):
script = server.HERE.parent / 'scripts' / name
if not script.exists() or not shutil.which('zsh') or server.LOCAL:
raise ValueError(name + ' requires a computer with zsh and the matching script installed')
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60)
# The headset the server is routed to, not whatever `frame` means in ~/.ssh/config.
env = {**os.environ, 'FRAME_ALIAS': server.FRAME,
'FRAME_SSH_OPTS': shlex.join(server.SSH[1:])}
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60, env=env)
if result.returncode:
raise ValueError(result.stderr.strip() or 'Script failed')
return {'message': result.stdout.strip()}
+3 -3
View File
@@ -47,8 +47,8 @@ def ssh(cmd, input=None, timeout=120):
try:
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}')
if p.returncode != 0:
@@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
+5 -4
View File
@@ -11,16 +11,17 @@ import tempfile
import uuid
import frame_android as android
import frame_host
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
def _stream(command, src=None, dst=None):
try:
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
stdin=src if src else subprocess.DEVNULL,
stdout=dst if dst else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=1800)
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command],
stdin=src if src else subprocess.DEVNULL,
stdout=dst if dst else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=1800)
except subprocess.TimeoutExpired:
raise android.FrameError('app-data transfer timed out')
except OSError as error:
+58 -9
View File
@@ -6,8 +6,9 @@ asking for the Developer Mode password once. The Linux and Windows twin of
scripts/connect.sh (which the Mac app uses); same config block, so either can
re-run over the other. Idempotent.
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
Usage: python3 ui/frame_connect.py [--alias NAME] [HOST_OR_IP[:PORT]]
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame; --alias wins, for
terminals that don't pass the environment on, like Windows' `start`)
"""
import base64
import json
@@ -23,6 +24,10 @@ import urllib.error
import urllib.request
from pathlib import Path
# The app runs this with python -I, which leaves the script's folder off sys.path.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_host # noqa: E402
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
USER_FROM_ENV = "FRAME_USER" in os.environ
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
@@ -283,10 +288,40 @@ def config_block(host, port=22, user=FRAME_USER):
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
class config_lock:
"""The lock Frame Control takes to edit ~/.ssh/config (frame_devices.file_lock), so a
running app and this setup never write over each other's change."""
def __enter__(self):
self.fh = open(SSH_DIR / "config.frame-control.lock", "a+")
for _ in range(300):
try:
if os.name == "nt":
import msvcrt
self.fh.seek(0)
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.lockf(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
return self
except OSError:
time.sleep(0.1)
return self # 30 s: go ahead rather than fail the setup
def __exit__(self, *exc):
self.fh.close() # closing releases the lock
return False
def write_config(host, port=22, user=FRAME_USER):
make_ssh_dir()
with config_lock():
_write_config(host, port, user)
def _write_config(host, port, user):
"""Replace our managed block and put it first: ssh uses the first value it sees per
option. The trailing "Host *" returns the rest of the file to global scope."""
make_ssh_dir()
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
kept, skip = [], False
for line in old.splitlines():
@@ -297,10 +332,11 @@ def write_config(host, port=22, user=FRAME_USER):
elif not skip:
kept.append(line)
block = config_block(host, port, user)
tmp = CONFIG.with_name("config.frame-control.tmp")
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
if os.name != "nt":
tmp.chmod(0o600)
if not frame_host.make_private(tmp):
say(" couldn't make ~/.ssh/config private; if ssh says \"Bad owner or permissions\", "
"Frame Control repairs it when it next connects")
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
# and locked, so the swap can fail for a moment; keep trying for a while.
for attempt in range(60):
@@ -318,9 +354,9 @@ def write_config(host, port=22, user=FRAME_USER):
def key_login_works():
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0
return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0
def configured_user():
@@ -367,9 +403,22 @@ def pair_with_devkit(host, port, user):
return chosen[0], "paired, but key login still fails"
def use_alias(alias):
"""--alias: set up another headset under its own ~/.ssh/config alias (Devices tab)."""
global FRAME_ALIAS, BEGIN, END
if not NAME_RE.fullmatch(alias):
sys.exit(f"--alias must be a plain name, not {alias!r}")
FRAME_ALIAS = alias
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
def main(argv):
if argv and argv[0] in ("-h", "--help"):
sys.exit(__doc__)
if len(argv) >= 2 and argv[0] == "--alias":
use_alias(argv[1])
argv = argv[2:]
say("==> Looking for the Steam Frame")
found = pick_host(argv[0] if argv else None)
while not found:
+252
View File
@@ -0,0 +1,252 @@
"""An email address the person chooses to leave, and what it may be used for. Python stdlib only.
Two separate opt-in choices, both off until ticked:
- updates: occasional notices about Frame Control releases and updates
- followup: the maintainer may ask follow-up questions, mainly about problem reports
The address and the choices are kept on this computer (frame_host.data_dir('contact')) and
sent privately to Frame Control's PostHog project as a `contact_consent` event, the same way
as problem reports (frame_report.py), so only the maintainer can read them. Every change
sends a new event under this copy's own random contact id (not the analytics id), numbered
by `rev`, and the highest rev for an id is the one that counts, whatever the clocks say:
removing the address sends a withdrawal with no address in it, and wipes the address from
the local log of what was sent. The maintainer lists who agreed to what with
`python3 ui/frame_report.py contacts`. Nothing here sends email.
A change that can't be sent (offline) waits in the state file and is retried in the
background, so a withdrawal is never lost. The page's one-time prompt is remembered here
too: once it has been shown or dismissed it never comes back.
"""
import json
import os
import re
import threading
import time
import uuid
import frame_host
import frame_telemetry
STATE = frame_host.data_dir('contact')
FILE = STATE / 'contact.json'
EMAIL_MAX = 254
EMAIL_RE = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s.]+')
PROMPTS = ('new', 'shown', 'dismissed', 'answered')
RETRY_EVERY = 600
_lock = threading.RLock()
_send_lock = threading.Lock() # one send at a time, so events reach PostHog in rev order
_removed = {} # address (lower case) -> when it was removed, for reports still being sent then
_wake = threading.Event()
_retrier = None
def _defaults():
return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False,
'prompt': 'new', 'pending': None, 'rev': 0}
def load():
with _lock:
s = _defaults()
try:
with open(FILE) as f:
saved = json.load(f)
if isinstance(saved, dict):
s.update({k: v for k, v in saved.items() if k in s})
except (OSError, ValueError):
pass
return s
def _save(s):
STATE.mkdir(parents=True, exist_ok=True)
tmp = FILE.with_suffix('.tmp')
tmp.write_text(json.dumps(s, indent=1))
os.replace(tmp, FILE)
def valid_email(email):
return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email))
def flag(body, key):
"""A consent choice: true only when it really is true (not "false" or 1), left out is no."""
v = body.get(key)
if v is not None and not isinstance(v, bool):
raise ValueError(f'{key} must be true or false')
return v is True
def from_report(email):
"""Follow-up questions agreed to with a problem report: the address becomes the contact
email with that choice ticked, so it shows in Settings and is removed the same way. Update
notices stay on only for the same address: a different one replaces the old address with
follow-up questions only (the report form says so before sending). Returns (contact id,
rev) for the report to carry, read together with the change itself: a later change from
this copy has a higher rev, and the newest such change decides whether the report's
follow-up permission still stands, whatever the clocks say."""
with _lock:
s = load()
same = s['email'].lower() == email.lower()
changed, cid, rev = _apply({'email': s['email'] if same else email,
'updates': s['updates'] and same, 'followup': True})
_deliver(changed)
return cid, rev
def state():
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
and the Frame has connected at least once (setup worked), so it never greets a new install."""
s = load()
set_up = bool(frame_telemetry.settings().get('frames_seen'))
return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'],
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
def _event(s):
email = s['email'] if s['updates'] or s['followup'] else ''
return {'event': 'contact_consent', 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()),
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'properties': {**frame_telemetry.common(), 'email': email, 'updates': bool(email and s['updates']),
'followup': bool(email and s['followup']),
'action': 'set' if email else 'withdraw', 'rev': s['rev'], 'level': 'contact'}}
def _send_pending(block=True):
"""Send what's waiting, including changes made while sending. True if nothing is left
waiting. Without block, a send already under way is left to pick up the newest change."""
if not _send_lock.acquire(blocking=block):
return False
try:
while True:
with _lock:
event = load()['pending']
if event is None:
break
try:
frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError:
return False
_sent(event)
finally:
_send_lock.release()
# A change saved just as this finished found the lock still held and left it to us.
with _lock:
left = load()['pending'] is not None
return _send_pending(block=False) if left else True
def _sent(event):
with _lock:
s = load()
if s['pending'] and s['pending'].get('uuid') == event['uuid']: # not replaced meanwhile
s['pending'] = None
_save(s)
# A withdrawal, or the address still in use: not an old one removed while this was on its way.
if event['properties']['email'] in ('', s['email']):
try:
frame_telemetry.record_sent([event])
except OSError:
pass
def _forget_locally(email):
"""Take a removed address out of the log of what was sent (contact events and reports)."""
with frame_telemetry._lock:
_removed[email.lower()] = time.time()
rows = frame_telemetry._read_lines(frame_telemetry.SENT)
hit = False
for e in rows:
p = e.get('properties') or {}
for k in ('email', 'contact'):
if p.get(k) and str(p[k]).strip().lower() == email.lower():
p[k], hit = '<removed>', True
if hit:
frame_telemetry._write_lines(frame_telemetry.SENT, rows)
def redact_removed(event, started):
"""Before logging a report (started at time.time() `started`) whose address was removed
while it was being sent: take the address out. Call with frame_telemetry._lock held, so a
removal can't slip between this and the log."""
p = event.get('properties') or {}
removed_at = _removed.get(str(p.get('contact') or '').strip().lower())
if removed_at is not None and started <= removed_at:
p['contact'] = '<removed>'
def save(body):
"""Set, change or remove the address and the two choices. An address needs at least one
choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
_deliver(_apply(body)[0])
return state()
def _apply(body):
"""save()'s change, kept here and waiting to send. Returns (changed, contact id, rev)."""
email = str(body.get('email') or '').strip()
updates, followup = flag(body, 'updates'), flag(body, 'followup')
if email and not valid_email(email):
raise ValueError("that doesn't look like an email address")
if email and not (updates or followup):
raise ValueError('tick what the address may be used for, or remove it')
if not email:
updates = followup = False
with _lock:
s = load()
old = s['email']
changed = (email, updates, followup) != (s['email'], s['updates'], s['followup'])
s.update(email=email, updates=updates, followup=followup)
if body.get('fromPrompt') or email:
s['prompt'] = 'answered'
if changed:
# Only the newest choice matters, so it replaces anything still waiting. A withdrawal
# is sent even for an address still waiting here: its send may already be under way.
s['rev'] += 1
s['pending'] = _event(s)
_save(s)
if old and old.lower() != email.lower():
try:
_forget_locally(old)
except OSError:
pass
return changed, s['id'], s['rev']
def _deliver(changed):
if changed and not _send_pending(block=False):
_wake.set() # offline, or a send under way that will take this change with it
def prompt(body):
"""The one-time prompt was shown, or dismissed with No thanks. Either way it stays gone."""
action = body.get('prompt')
if action not in ('shown', 'dismissed'):
raise ValueError('unknown prompt action')
with _lock:
s = load()
if s['prompt'] in ('new', 'shown'):
s['prompt'] = action
_save(s)
return state()
def start():
"""Retry a change that couldn't be sent, from now on in the background."""
global _retrier
if _retrier:
return
def loop():
while True:
try:
_send_pending()
except Exception:
pass
_wake.wait(RETRY_EVERY)
_wake.clear()
_retrier = threading.Thread(target=loop, name='contact', daemon=True)
_retrier.start()
+832
View File
@@ -0,0 +1,832 @@
"""The headsets Frame Control knows, and the addresses each can be reached at.
One headset can answer at several addresses: a LAN IP at home, another in the
office, its mDNS name (frame.local), its Tailscale IP or MagicDNS name. The
registry keeps them all, learns which worked on which network, and hands the
connector (frame_link.py) an order to try them in.
Stored as JSON in frame_host.data_dir("devices.json"). The format is plain so the
iPhone app can share it later; docs/devices.md describes it:
{"version": 1, "active": "<device id>",
"devices": [{"id", "name", "alias", "user", "port", "identity_files",
"addresses": [{"host", "kind": lan|mdns|tailscale|manual, "label",
"networks": [network ids it worked on], "last_ok", "last_rtt_ms"}]}],
"networks": {"<network id>": {"name", "ssid", "gateway", "gateway_mac", "last_seen"}}}
Headsets set up before this existed live only in ~/.ssh/config, in the managed
`# >>> steam-frame (ALIAS) >>>` blocks that scripts/connect.sh and
ui/frame_connect.py write; they're imported from there, so nobody has to add
them again. Each device keeps its alias: Terminal's `ssh frame` and the helper
scripts go on working, and the connector rewrites the block's HostName to the
last address that worked, so they follow it.
Host keys are pinned per headset, not per address: ssh gets
`-o HostKeyAlias=frame-control-<id>` and a known_hosts file of the headset's own
(~/.ssh/frame-control-hosts/<id>), so a different device answering at a
remembered IP is caught.
Python stdlib only.
"""
import contextlib
import copy
import json
import os
import re
import secrets
import subprocess
import tempfile
import threading
import time
from pathlib import Path
import frame_host
import frame_network
VERSION = 1
# Everything here can end up in ssh arguments or ~/.ssh/config, so nothing that
# could start an option, add a line, or carry a directive.
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
HOST_RE = re.compile(r"[A-Za-z0-9:][A-Za-z0-9.:-]{0,252}(%[A-Za-z0-9._-]{1,32})?")
TEXT_MAX = 60
KINDS = ("lan", "mdns", "tailscale", "manual")
KIND_LABEL = {"lan": "Local network", "mdns": "mDNS (.local)", "tailscale": "Tailscale", "manual": "Other"}
DEFAULT_USER = "steamos"
class DeviceError(ValueError):
"""Bad input from the page; the server answers 400 with the message."""
def ssh_dir():
"""~/.ssh, or $FRAME_CONTROL_SSH_DIR in tests so they never touch the real one."""
return Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh")
def ssh_config():
return ssh_dir() / "config"
PIN_DIR = "frame-control-hosts"
def known_hosts(device_id):
"""The headset's own known_hosts file: one per headset, so saving or forgetting one
headset's key (by ssh or by the app) can never touch another's."""
return ssh_dir() / PIN_DIR / device_id
def known_hosts_opt(device_id):
"""How ssh is told about it. `~` rather than the full path when it's the usual
place, so a home folder with a space in its name can't split the option."""
if os.environ.get("FRAME_CONTROL_SSH_DIR"):
return str(known_hosts(device_id))
return f"~/.ssh/{PIN_DIR}/{device_id}"
def host_key_alias(device_id):
return f"frame-control-{device_id}"
# ---- validation ----------------------------------------------------------------
def check_alias(alias):
if not isinstance(alias, str) or not NAME_RE.fullmatch(alias):
raise DeviceError("The SSH alias must be a plain name: letters, digits, dot, dash or underscore")
return alias
def check_user(user):
if not isinstance(user, str) or not NAME_RE.fullmatch(user):
raise DeviceError("The user name must be letters, digits, dot, dash or underscore")
return user
def check_host(host):
host = host.strip() if isinstance(host, str) else host
if (not isinstance(host, str) or not HOST_RE.fullmatch(host) or ".." in host
or ("%" in host and ":" not in host.split("%")[0])): # a zone only follows an IPv6 address
raise DeviceError(f"{host!r} isn't a host name or IP address")
return host
def check_port(port):
try:
port = int(port)
except (TypeError, ValueError):
raise DeviceError("The port must be a number") from None
if not 1 <= port <= 65535:
raise DeviceError("The port must be between 1 and 65535")
return port
def check_text(text, what):
text = (text or "").strip() if isinstance(text, (str, type(None))) else None
if text is None or len(text) > TEXT_MAX or re.search(r"[\x00-\x1f\x7f]", text):
raise DeviceError(f"The {what} must be plain text of at most {TEXT_MAX} characters")
return text
def check_kind(kind):
if kind not in KINDS:
raise DeviceError(f"The kind must be one of {', '.join(KINDS)}")
return kind
def ssh_host(host):
"""A host for ssh's HostName, which expands %-tokens: an IPv6 zone's % is doubled."""
return host.replace("%", "%%")
# ---- ~/.ssh/config's managed blocks ---------------------------------------------
BLOCK_RE = re.compile(r"# >>> steam-frame \((" + NAME_RE.pattern + r")\) >>>")
def begin_mark(alias):
return f"# >>> steam-frame ({alias}) >>>"
def end_mark(alias):
return f"# <<< steam-frame ({alias}) <<<"
def parse_blocks(text):
"""The managed blocks: [{"alias", "hostname", "user", "port", "identity_files"}]."""
blocks, cur = [], None
for line in text.splitlines():
m = BLOCK_RE.fullmatch(line.strip())
if m:
cur = {"alias": m.group(1), "hostname": None, "user": None, "port": 22, "port_set": False,
"identity_files": []}
continue
if cur is None:
continue
if line.strip() == end_mark(cur["alias"]):
blocks.append(cur)
cur = None
continue
f = line.split(None, 1)
if len(f) != 2:
continue
key, value = f[0].lower(), f[1].strip()
if key == "hostname" and cur["hostname"] is None:
cur["hostname"] = value.replace("%%", "%")
elif key == "user" and cur["user"] is None:
cur["user"] = value
elif key == "port" and value.isdigit():
cur["port"], cur["port_set"] = int(value), True
elif key == "identityfile":
cur["identity_files"].append(value)
return blocks
def read_config(path=None):
path = Path(path or ssh_config())
try:
return path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return ""
# One edit of ~/.ssh/config at a time: between this app's threads (_config_lock) and
# with Set Up Connection (frame_connect.py and scripts/connect.sh take the same lock
# file). _edit_config also notices any other program writing in between.
_config_lock = threading.Lock()
LOCK_NAME = "config.frame-control.lock"
@contextlib.contextmanager
def file_lock(path, timeout=30):
"""An exclusive lock on `path` (created if need be) shared with other processes:
POSIX record locks (what zsh's `zsystem flock` takes), or msvcrt on Windows."""
path.parent.mkdir(parents=True, exist_ok=True)
fh = open(path, "a+")
try:
deadline = time.monotonic() + timeout
while True:
try:
if frame_host.WINDOWS:
import msvcrt
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.lockf(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except OSError:
if time.monotonic() > deadline:
raise OSError(f"{path} stayed locked (is Set Up Connection running?)")
time.sleep(0.1)
yield
finally:
try:
if frame_host.WINDOWS:
import msvcrt
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.lockf(fh, fcntl.LOCK_UN)
except OSError:
pass
fh.close()
def _write_config(path, text, expected):
"""Swap the file in whole (as frame_connect.write_config does), keeping it private.
Returns False, writing nothing, if the file no longer holds `expected`."""
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
tmp = Path(tmp)
try:
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write(text)
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
if read_config(path) != expected:
return False
try:
os.replace(tmp, path)
return True
except PermissionError:
time.sleep(0.25)
raise OSError(f"{path} stayed locked by another program")
finally:
if tmp.exists():
tmp.unlink()
def _edit_config(path, change):
"""Apply change(lines) -> new lines or None to the file, retrying if another program
wrote it meanwhile. -> True if the file changed."""
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
for _ in range(5):
text = read_config(path)
new = change(text.splitlines())
if new is None:
return False
if _write_config(path, "\n".join(new) + "\n", text):
return True
raise OSError(f"{path} kept changing while Frame Control tried to update it")
def repair_permissions(path=None):
"""Give ~/.ssh/config make_private's ACL by swapping in a byte-for-byte copy: for a
file Windows' OpenSSH refuses ("Bad owner or permissions"). -> True only if the copy
got that ACL and replaced the file."""
path = Path(path or ssh_config())
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
try:
data = path.read_bytes()
except OSError:
return False
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
tmp = Path(tmp)
try:
with os.fdopen(fd_, "wb") as fh:
fh.write(data)
if not frame_host.make_private(tmp):
return False
for attempt in range(20): # a running ssh.exe can hold the file for a moment
if path.read_bytes() != data:
return False
try:
os.replace(tmp, path)
return True
except PermissionError:
time.sleep(0.25)
return False
finally:
if tmp.exists():
tmp.unlink()
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
file alone. -> True if the file changed. Does nothing if there's no such block, or
if `expect` ({"hostname", "user", "port"}; None values match anything) no longer
describes the block, checked under the lock: someone else changed it meanwhile."""
def change(lines):
if expect:
block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
# A port the block doesn't set is inherited from elsewhere in the file: not compared.
if not block or any(v is not None and block[k] != v and (k != "port" or block["port_set"])
for k, v in expect.items()):
return None
block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
# Port 22 needs no line, unless the block would otherwise inherit another port
# from a later Host entry (which ssh would use).
force = bool(port) and block is not None and not block["port_set"] and \
effective_port(alias, config_path) != int(port)
return _rewritten(lines, alias, hostname, user, port, force)
config_path = Path(path or ssh_config())
return _edit_config(config_path, change)
def _rewritten(lines, alias, hostname, user, port, force_port=False):
begin, end = begin_mark(alias), end_mark(alias)
if begin not in lines or end not in lines:
return None
i, j = lines.index(begin), lines.index(end)
if j < i:
return None
block = lines[i:j]
want = {"hostname": ssh_host(hostname) if hostname else None, "user": user,
"port": str(port) if port else None}
out, seen = [], set()
for line in block:
f = line.split(None, 1)
key = f[0].lower() if f else ""
if key in want and want[key] is not None and key not in seen:
seen.add(key)
# An existing Port line is kept, even for 22: dropping it could let a later
# `Host *` Port apply to Terminal but not to the app.
out.append(f" {f[0]} {want[key]}")
else:
out.append(line)
if want["port"] and (want["port"] != "22" or force_port) and "port" not in seen:
at = next((n + 1 for n, line in enumerate(out) if line.split(None, 1)[:1] == ["HostName"]), 2)
out.insert(at, f" Port {want['port']}")
new = lines[:i] + out + lines[j:]
return None if new == lines else new
def remove_block(alias, path=None):
def change(lines):
begin, end = begin_mark(alias), end_mark(alias)
if begin not in lines or end not in lines or lines.index(end) < lines.index(begin):
return None
return lines[:lines.index(begin)] + lines[lines.index(end) + 1:]
return _edit_config(Path(path or ssh_config()), change)
def effective_port(alias, config):
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
try:
out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return 22
m = re.search(r"^port (\d+)$", out, re.M)
port = int(m.group(1)) if m else 22
return port if 1 <= port <= 65535 else 22
# ---- pinned host keys -------------------------------------------------------------
def _keygen(*args):
try:
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10)
except (OSError, subprocess.TimeoutExpired):
return None
def pinned(device_id):
"""Whether a key is saved for the headset. Entries are plain text (ssh gets
HashKnownHosts=no), but ask ssh-keygen too in case one was hashed."""
target = known_hosts(device_id)
try:
lines = target.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return False
name = host_key_alias(device_id)
if any(line.split(None, 1)[0].split(",").count(name) for line in lines
if line.strip() and not line.startswith("#")):
return True
r = _keygen("-F", name, "-f", str(target))
return bool(r and r.returncode == 0 and r.stdout.strip())
def seed_pin(device_id, hosts, port=22, sources=None):
"""Copy the host keys ssh already trusts for one of `hosts` into the headset's file
under its alias, so moving to per-headset pinning asks nobody to trust anything again.
-> True if a key is pinned."""
if pinned(device_id):
return True
sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"]
name = host_key_alias(device_id)
for host in hosts:
wanted = host if port == 22 else f"[{host}]:{port}"
keys = []
for src in sources:
if not Path(src).is_file():
continue
r = _keygen("-F", wanted, "-f", str(src))
for line in (r.stdout if r else "").splitlines():
f = line.split()
if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
keys.append(f"{name} {f[1]} {f[2]}")
if keys:
target = known_hosts(device_id)
target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write("\n".join(dict.fromkeys(keys)) + "\n")
os.replace(tmp, target) # whole file at once: ssh never sees half of it
return True
return False
def forget_pin(device_id):
"""Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection
trusts whatever key the headset shows, as a first connection does."""
try:
known_hosts(device_id).unlink()
return True
except FileNotFoundError:
return False
# ---- address order --------------------------------------------------------------------
def order_addresses(addresses, network_id, tailscale_up):
"""The order to try a device's addresses in, each with why it's there:
known to work on this network, then mDNS, then Tailscale if it's up, then the rest
(addresses that only ever worked elsewhere last). The user's order breaks ties."""
def group(a):
nets = a.get("networks") or []
if network_id and network_id in nets:
return 0, "worked on this network before"
if a["kind"] == "mdns":
return 1, "mDNS name"
if a["kind"] == "tailscale":
return (2, "Tailscale") if tailscale_up else (5, "Tailscale isn't running")
if nets:
return 4, "worked on another network"
return 3, "not tried on this network yet"
ranked = sorted(enumerate(addresses), key=lambda p: (group(p[1])[0], p[0]))
return [(a, group(a)[1]) for _, a in ranked]
# ---- the registry ------------------------------------------------------------------------
def new_address(host, kind=None, label=""):
host = check_host(host)
return {"host": host, "kind": check_kind(kind) if kind else frame_network.guess_kind(host),
"label": check_text(label, "label"), "networks": [], "last_ok": None, "last_rtt_ms": None}
class Registry:
"""devices.json, loaded once and saved on every change. Thread-safe."""
def __init__(self, path=None, config=None):
self.path = Path(path or frame_host.data_dir("devices.json"))
self.config = Path(config) if config else None # None: ssh_config() at call time
self.lock = threading.RLock()
self._depth = 0 # nested _changing() calls
self._mtime = None # devices.json as last loaded or saved
self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
self.load()
# -- storage --
def load(self):
with self.lock:
try:
self._mtime = self.path.stat().st_mtime_ns
data = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return
if isinstance(data, dict) and isinstance(data.get("devices"), list):
data.setdefault("networks", {})
data.setdefault("active", None)
data["devices"] = [d for d in data["devices"] if self._sane(d)]
# The headset in use is this server's own choice: another server picking a
# different one mustn't move commands (an install, say) under it. The file's
# choice is only where a server starts.
# Kept even if another server removed it, so the connector can see that
# (and not quietly move to another headset in the middle of an install).
mine = self.data.get("active")
if mine:
data["active"] = mine
self.data = data
@staticmethod
def _sane(d):
try:
check_alias(d["alias"])
d["addresses"] = [a for a in d.get("addresses") or [] if isinstance(a, dict) and HOST_RE.fullmatch(a.get("host", ""))
and a.get("kind") in KINDS]
for a in d["addresses"]:
a.setdefault("networks", [])
a.setdefault("label", "")
return NAME_RE.fullmatch(d.get("id", "")) is not None
except (KeyError, TypeError, DeviceError):
return False
def save(self):
with self.lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_name(self.path.name + ".tmp")
tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
os.replace(tmp, self.path)
self._mtime = self.path.stat().st_mtime_ns
def _refresh(self):
"""Pick up what another Frame Control server saved (the app and a standalone
server can share devices.json)."""
with self.lock:
try:
if self.path.stat().st_mtime_ns != self._mtime:
self.load()
except OSError:
pass
@contextlib.contextmanager
def _changing(self):
"""Every change holds this registry's lock and a lock file shared with other
processes, and starts from what's on disk, so no server saves over another's
change. Nested calls (sync_from_config adding a device) share the outer one."""
with self.lock:
if self._depth:
self._depth += 1
try:
yield
finally:
self._depth -= 1
return
with file_lock(self.path.with_name(self.path.name + ".lock")):
self.load()
self._depth = 1
try:
yield
finally:
self._depth = 0
def snapshot(self):
self._refresh()
with self.lock:
return copy.deepcopy(self.data)
# -- lookups --
def devices(self):
self._refresh()
with self.lock:
return copy.deepcopy(self.data["devices"])
def _find(self, device_id):
for d in self.data["devices"]:
if d["id"] == device_id:
return d
raise DeviceError("No such headset (it may have been removed)")
def get(self, device_id):
self._refresh()
with self.lock:
return copy.deepcopy(self._find(device_id))
def by_alias(self, alias):
self._refresh()
with self.lock:
return next((copy.deepcopy(d) for d in self.data["devices"] if d["alias"] == alias), None)
def active(self):
self._refresh()
with self.lock:
return self.data.get("active")
def emptied(self):
self._refresh()
with self.lock:
return bool(self.data.get("emptied")) and not self.data["devices"]
def set_active(self, device_id):
with self._changing():
self._find(device_id)
self.data["active"] = device_id
self.save()
# -- devices --
def add_device(self, alias, name=None, user=DEFAULT_USER, port=22, hosts=(), identity_files=()):
with self._changing():
check_alias(alias)
if any(d["alias"] == alias for d in self.data["devices"]):
raise DeviceError(f"There's already a headset with the alias {alias}")
ids = {d["id"] for d in self.data["devices"]}
device_id = secrets.token_hex(4)
while device_id in ids:
device_id = secrets.token_hex(4)
d = {"id": device_id, "name": check_text(name or ("Steam Frame" if alias == "frame" else alias), "name"),
"alias": alias, "user": check_user(user or DEFAULT_USER), "port": check_port(port),
"identity_files": [str(f) for f in identity_files][:8], "addresses": [], "config_host": None,
"added": time.time()}
for host in hosts:
if host and not any(a["host"] == host for a in d["addresses"]):
d["addresses"].append(new_address(host))
self.data["devices"].append(d)
self.data.pop("emptied", None)
if not self.data.get("active"):
self.data["active"] = device_id
self.save()
return copy.deepcopy(d)
def update_device(self, device_id, name=None, user=None, port=None):
"""-> the device after the change. The caller mirrors user and port into ~/.ssh/config."""
with self._changing():
d = self._find(device_id)
# Check everything first: a rejected edit changes nothing.
name = None if name is None else (check_text(name, "name") or d["alias"])
user = None if user is None else check_user(user)
port = None if port is None else check_port(port)
d.update({k: v for k, v in (("name", name), ("user", user), ("port", port)) if v is not None})
self.save()
return copy.deepcopy(d)
def remove_device(self, device_id):
"""Forget a headset. Its ~/.ssh/config block (if kept) isn't imported again
unless Set Up Connection changes it."""
with self._changing():
d = self._find(device_id)
self.data["devices"].remove(d)
self.data.setdefault("dismissed", {})[d["alias"]] = d.get("config_host") or ""
if not self.data["devices"]:
self.data["emptied"] = True # removed on purpose: don't fall back to the `frame` alias
if self.data.get("active") == device_id:
self.data["active"] = self.data["devices"][0]["id"] if self.data["devices"] else None
self.save()
return d
# -- addresses --
def _addr(self, d, host):
for a in d["addresses"]:
if a["host"] == host:
return a
raise DeviceError(f"{host} isn't one of this headset's addresses")
def add_address(self, device_id, host, kind=None, label=""):
with self._changing():
d = self._find(device_id)
a = new_address(host, kind, label)
if any(x["host"] == a["host"] for x in d["addresses"]):
raise DeviceError(f"{a['host']} is already on the list")
if len(d["addresses"]) >= 32:
raise DeviceError("That's enough addresses for one headset")
d["addresses"].append(a)
self.save()
return copy.deepcopy(a)
def update_address(self, device_id, host, new_host=None, kind=None, label=None):
with self._changing():
d = self._find(device_id)
a = self._addr(d, host)
# Check everything first: a rejected edit changes nothing.
moved = new_host is not None and new_host != host
if moved:
new_host = check_host(new_host)
if any(x["host"] == new_host for x in d["addresses"]):
raise DeviceError(f"{new_host} is already on the list")
kind = None if kind is None else check_kind(kind)
label = None if label is None else check_text(label, "label")
if moved:
a.update(host=new_host, networks=[], last_ok=None, last_rtt_ms=None) # a new place: learn again
if kind is not None:
a["kind"] = kind
if label is not None:
a["label"] = label
self.save()
return copy.deepcopy(a)
def remove_address(self, device_id, host):
with self._changing():
d = self._find(device_id)
d["addresses"].remove(self._addr(d, host))
self.save()
def move_address(self, device_id, host, delta):
with self._changing():
d = self._find(device_id)
a = self._addr(d, host)
i = d["addresses"].index(a)
j = max(0, min(len(d["addresses"]) - 1, i + int(delta)))
d["addresses"].insert(j, d["addresses"].pop(i))
self.save()
def record_success(self, device_id, host, network_id, rtt_ms):
"""Learn: this address worked on this network."""
with self._changing():
try:
a = self._addr(self._find(device_id), host)
except DeviceError:
return
if network_id and network_id not in a["networks"]:
a["networks"] = (a["networks"] + [network_id])[-16:]
a["last_ok"] = time.time()
a["last_rtt_ms"] = rtt_ms
self.save()
def undismiss(self, alias):
"""Set Up Connection is about to run for this alias: import its block again."""
with self._changing():
if self.data.get("dismissed", {}).pop(alias, None) is not None:
self.save()
def set_config_host(self, device_id, host):
with self._changing():
try:
self._find(device_id)["config_host"] = host
except DeviceError:
return
self.save()
# -- networks --
def record_network(self, net):
"""Remember a network we've seen (for naming it), keeping its user-given name."""
if not net or not net.get("id"):
return
with self._changing():
known = self.data["networks"].get(net["id"]) or {"name": ""}
changed = (known.get("ssid") != (net.get("ssid") or known.get("ssid")) or
time.time() - (known.get("last_seen") or 0) > 3600 or "gateway" not in known)
known.update(ssid=net.get("ssid") or known.get("ssid"), gateway=net.get("gateway"), wifi=net.get("wifi"),
gateway_mac=net.get("gateway_mac"), last_seen=time.time())
self.data["networks"][net["id"]] = known
if changed:
self.save()
def name_network(self, network_id, name):
with self._changing():
if network_id not in self.data["networks"]:
raise DeviceError("That network hasn't been seen")
self.data["networks"][network_id]["name"] = check_text(name, "network name")
self.save()
def network_name(self, net):
"""What to call a network: the name given to it, its Wi-Fi name, or its router."""
if not net:
return "No network"
self._refresh()
with self.lock:
known = self.data["networks"].get(net.get("id") or "") or {}
if known.get("name"):
return known["name"]
ssid = net.get("ssid") or known.get("ssid")
if ssid:
return ssid
if net.get("gateway"):
return f"{'Wi-Fi' if net.get('wifi') else 'Network'} via {net['gateway']}"
return "No network"
# -- ~/.ssh/config --
def sync_from_config(self, seed=True):
"""Import managed blocks we don't know yet, and pick up a HostName that Set Up
Connection changed since we last looked. -> True if anything changed."""
blocks = parse_blocks(read_config(self.config))
for b in blocks:
if not b["port_set"]:
# No Port in the block: another Host entry may give one (ssh uses the first).
b["port"] = effective_port(b["alias"], self.config or ssh_config())
changed = False
with self._changing():
first = not self.data["devices"] and not self.data.get("active")
for b in blocks:
host = b["hostname"] if b["hostname"] and HOST_RE.fullmatch(b["hostname"]) else None
user = b["user"] if b["user"] and NAME_RE.fullmatch(b["user"]) else DEFAULT_USER
d = next((x for x in self.data["devices"] if x["alias"] == b["alias"]), None)
dismissed = self.data.get("dismissed", {})
if d is None and b["alias"] in dismissed:
if dismissed[b["alias"]] == (host or ""):
continue # removed on the Devices tab; unchanged since
del dismissed[b["alias"]]
if d is None:
try:
d = self._find(self.add_device(b["alias"], user=user, port=b["port"],
identity_files=b["identity_files"])["id"])
except DeviceError:
continue
if host:
d["addresses"].append(dict(new_address(host), label="From Set Up Connection"))
d["config_host"] = host
changed = True
if seed and host:
seed_pin(d["id"], [host], b["port"])
elif host and host != d.get("config_host"):
# Set Up Connection ran again and found the headset somewhere new.
d["config_host"] = host
if not any(a["host"] == host for a in d["addresses"]):
d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection"))
if seed:
seed_pin(d["id"], [host], b["port"])
changed = True
if d.get("config_login") != [user, b["port"]]:
# Set Up Connection (or an edit) changed who to log in as, or the port.
if d.get("config_login") is not None and [d["user"], d["port"]] != [user, b["port"]]:
d["user"], d["port"] = user, b["port"] if 1 <= b["port"] <= 65535 else d["port"]
d["config_login"] = [user, b["port"]]
changed = True
if d["identity_files"] != b["identity_files"] and b["identity_files"]:
d["identity_files"] = b["identity_files"][:8]
changed = True
d["managed"] = True
aliases = {b["alias"] for b in blocks}
for d in self.data["devices"]:
d["managed"] = d["alias"] in aliases
if first:
# First import: the headset the app used before is `frame`, even if Set Up
# Connection put another block above it.
frame = next((d for d in self.data["devices"] if d["alias"] == "frame"), None)
if frame and self.data.get("active") != frame["id"]:
self.data["active"] = frame["id"]
changed = True
if changed:
self.save()
return changed
+152 -13
View File
@@ -5,12 +5,16 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
CLI (used by the Electron app, so terminal handling lives in one place):
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
"""
import hashlib
import io
import os
import shlex
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
from pathlib import Path
MAC = sys.platform == "darwin"
@@ -32,6 +36,45 @@ class HostError(RuntimeError):
pass
class Unreachable(HostError):
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
def run_ssh(argv, **kwargs):
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
while keeping subprocess.run's captured output, text, check and timeout API.
"""
if not WINDOWS:
return subprocess.run(argv, **kwargs)
if kwargs.pop("capture_output", False):
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
raise ValueError("stdout and stderr arguments may not be used with capture_output")
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if kwargs.get("stderr") != subprocess.PIPE:
return subprocess.run(argv, **kwargs)
check = kwargs.pop("check", False)
text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
with tempfile.TemporaryFile() as stderr:
kwargs["stderr"] = stderr
try:
result = subprocess.run(argv, **kwargs)
except subprocess.TimeoutExpired as error:
stderr.seek(0)
error.stderr = stderr.read()
raise
stderr.seek(0)
if text:
with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
result.stderr = reader.read()
else:
result.stderr = stderr.read()
if check:
result.check_returncode()
return result
def data_dir(*parts):
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
@@ -56,13 +99,19 @@ def cache_dir(*parts):
return base.joinpath(*parts)
def control_path(*, private=False):
def control_path(tag="x", *, private=None):
"""ssh ControlPath for the shared connection, or None where it isn't supported.
`tag` names the headset: ssh's %C hashes only the address, user and port, so two
headsets reached at the same address (one of them moved) would otherwise share a
connection, and one's commands would run on the other.
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
"""
# A private server (the MCP adapter's) keeps its own masters: FRAME_PRIVATE_SSH=1.
if private is None:
private = os.environ.get("FRAME_PRIVATE_SSH") == "1"
suffix = f"-{os.getpid()}" if private else ""
return f"/tmp/frame-ui-{os.getuid()}{suffix}-%C" if MUX else None
return f"/tmp/frame-ui-{os.getuid()}{suffix}-{tag}-%C" if MUX else None
def which(name, *extra):
@@ -147,6 +196,19 @@ def open_path(path):
stderr=subprocess.DEVNULL, **DETACHED)
def reveal_path(path):
"""Show a file selected in its folder (Linux file managers vary, so there the folder opens)."""
path = Path(path)
if MAC:
cmd = ["open", "-R", str(path)]
elif WINDOWS:
cmd = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma
else:
return open_path(path.parent)
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, **DETACHED)
open_url = open_path # the same openers hand URLs to the default browser
@@ -222,10 +284,46 @@ def clipboard_text():
raise HostError("Can't read the clipboard")
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
BAD_PERMISSIONS = "Bad owner or permissions on "
def make_private(path):
"""Leave only this user able to open PATH, as ssh insists for ~/.ssh/config.
Windows: an ACL of just this user, SYSTEM and Administrators, inherited nothing.
A file written into ~/.ssh otherwise takes the folder's ACL, and Windows' OpenSSH
refuses it if that grants anyone else, even an account deleted long ago
("Bad owner or permissions"). Best effort: -> False if it couldn't."""
if not WINDOWS:
try:
os.chmod(path, 0o600)
return True
except OSError:
return False
me = os.environ.get("USERNAME", "")
try: # "desktop\me","S-1-5-21-..."
# Bytes: the account name is in the console's code page, the SID is ASCII.
out = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True,
stdin=subprocess.DEVNULL, timeout=10).stdout
sid = out.decode("ascii", "replace").strip().rsplit(",", 1)[-1].strip('"')
if sid.startswith("S-1-"):
me = "*" + sid
except (OSError, subprocess.TimeoutExpired):
pass
if not me:
return False
try:
return subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"{me}:F",
"*S-1-5-18:F", "*S-1-5-32-544:F"], capture_output=True,
stdin=subprocess.DEVNULL, timeout=10).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def ssh_hostname(alias):
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
try:
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return alias
for line in out.splitlines():
@@ -258,24 +356,65 @@ def open_steam_link():
return "Steam Link isn't installed; opened its download page"
def open_rdp(alias):
"""Remote desktop to the Frame's xrdp (user steamos)."""
host = ssh_hostname(alias)
RDP_PORT = 3389
RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's
# xrdp's certificate is its own, so every client warns about it first.
RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
"with your Developer Mode password")
def check_rdp(host, timeout=3):
"""Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
try:
with socket.create_connection((host, RDP_PORT), timeout=timeout):
return
except ConnectionRefusedError:
raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
"refused). Turn on Developer Mode in Steam Settings > System on the headset, "
"then restart it and try again.") from None
except socket.gaierror:
raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
"address on the Devices tab.") from None
except OSError as e:
raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
"switched off or on another network; if it's on, check Developer Mode is on "
"in Steam Settings > System.") from None
def rdp_file(host):
"""A Remote Desktop connection file for the Frame. mstsc /v: alone offers this
computer's Windows account, which xrdp turns away; the file names steamos instead."""
if any(c in host for c in "\r\n"):
raise HostError("That headset address can't be used for remote desktop")
# One file per address, so two launches close together can't swap headsets.
path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
return path
def open_rdp(alias, host=None):
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
host = host or ssh_hostname(alias)
# The client would open either way and then fail on its own, with nothing said here.
check_rdp(host)
if MAC:
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
return "Opened Windows App"
return f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
open_url("https://apps.apple.com/app/windows-app/id1295203466")
return "Windows App isn't installed; opened its App Store page"
if WINDOWS:
_spawn(["mstsc.exe", f"/v:{host}"])
return f"Opened Remote Desktop to {host}"
_spawn(["mstsc.exe", str(rdp_file(host))])
# Windows asks about the unsigned connection file first.
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
if which("remmina"):
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
return f"Opened Remmina to {host}"
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
return f"Opened Remmina to {host}: {RDP_LOGIN}"
for name in ("xfreerdp3", "xfreerdp"):
if which(name):
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}"
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
raise HostError("No RDP client found: install Remmina or FreeRDP")
+1233
View File
File diff suppressed because it is too large. Load diff
+37 -3
View File
@@ -120,6 +120,10 @@ class MacView:
self.tunnel_ssh = list(tunnel_ssh)
self.run = run
self.frame = frame
self.host_opts = [] # the headset in use and how to reach it (see retarget)
# Short, never held across ssh: retarget() and publishing a new tunnel check and
# change the headset together (self.lock is held while a tunnel is being opened).
self.route_lock = threading.Lock()
self.track = track or (lambda proc: None) # the server ends these on exit
self.lock = threading.Lock()
self.token = secrets.token_urlsafe(24)
@@ -239,13 +243,37 @@ class MacView:
last = self._last_tunnel_error
raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}")
def retarget(self, alias, host_opts):
"""The server now reaches the headset as `alias` with `host_opts` (another address,
or another headset). Only the short route_lock, never self.lock: this runs while
the server routes, which a tunnel being opened may be waiting on."""
# host_opts is "-o", "Name=value" pairs; the tunnel keeps its own connection,
# not the shared master.
opts = [x for flag, value in zip(host_opts[::2], host_opts[1::2])
if not value.startswith("ControlPath=") for x in (flag, value)]
with self.route_lock:
moved = alias != self.frame
self.frame, self.host_opts = alias, opts
tunnel = self.tunnel
if moved and tunnel is not None:
# Another headset: its viewers can't be the old one's. The supervisor
# reopens a tunnel to the new one if anything is being shown.
self.tunnel, self.remote_port = None, None
if moved and tunnel is not None and tunnel.poll() is None:
tunnel.terminate()
def _open_tunnel(self, via, ports):
"""Tries the ports on one route; True once the tunnel answers. With self.lock held."""
last = ""
for port in ports:
proc = subprocess.Popen([*self.tunnel_ssh, *via, "-o", "ExitOnForwardFailure=yes",
with self.route_lock:
target = (self.frame, self.host_opts) # retarget() may change these meanwhile
# `via` first: ssh keeps the first value of an option, so USB-C's HostName wins
# while the headset's pinned identity (in host_opts) still checks it.
proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ControlPath=none", *via, *target[1],
"-o", "ExitOnForwardFailure=yes",
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N",
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", self.frame],
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", target[0]],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, text=True)
# A taken port makes ssh exit once it's connected; a working
@@ -259,7 +287,11 @@ class MacView:
ok = True
break
if ok:
self.tunnel, self.remote_port = proc, port
with self.route_lock: # checked and published together, so a switch can't slip between
ok = target[0] == self.frame # else the app switched headset while this connected
if ok:
self.tunnel, self.remote_port = proc, port
if ok:
self.track(proc)
self._supervise()
return True
@@ -294,6 +326,8 @@ class MacView:
socket.create_connection((ip, 22), timeout=1).close()
except OSError:
return [] # not plugged into this Mac
if any(o.startswith("HostKeyAlias=") for o in self.host_opts):
return ["-o", f"HostName={ip}"] # checked against the headset's own pinned key
alias = self.frame
try:
cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
+80 -14
View File
@@ -33,6 +33,15 @@ SLOTS = {
}
class OverlayBusy(RuntimeError):
"""SetOverlayRaw's RequestFailed (23): SteamVR isn't taking frames, e.g. the
unworn headset is in standby (verified 2026-09-29). Transient, not fatal."""
# A screen that can't take a frame for this long is broken, not asleep.
BUSY_LIMIT = 300
class Overlay:
def __init__(self):
self.handles = []
@@ -53,6 +62,8 @@ class Overlay:
def call(self, name, *values):
slot, args = SLOTS[name]
rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values)
if rc == 23 and name == 'SetOverlayRaw':
raise OverlayBusy('SteamVR is not accepting frames (standby?)')
if rc:
raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
@@ -75,11 +86,14 @@ class Overlay:
self.call('ShowOverlay', handle)
def close(self):
try:
for h in reversed(self.handles):
# Best effort: SteamVR removes a disconnected client's overlays anyway,
# and a teardown error must not overwrite a finished playback's status.
for h in reversed(self.handles):
try:
self.call('DestroyOverlay', h)
finally:
self.vr.VR_ShutdownInternal()
except RuntimeError:
pass
self.vr.VR_ShutdownInternal()
def probe(path):
@@ -114,6 +128,11 @@ def decoder_command(path, info, width, height, audio, photo=False):
return cmd
def ignore_signals():
signal.signal(signal.SIGTERM, signal.SIG_IGN)
signal.signal(signal.SIGINT, signal.SIG_IGN)
def write_status(path, **values):
tmp = path.with_suffix('.tmp')
tmp.write_text(json.dumps(values))
@@ -135,6 +154,46 @@ def play(args):
photo = plan['kind'] == 'photo'
command = decoder_command(path, info, width, height, audio, photo)
vr, proc, frames, started = None, None, 0, time.monotonic()
dropped, busy_since, pending = 0, None, []
def show(handle, data, w, h, video=False):
"""Submit a frame. During standby return False; a video frame is dropped."""
nonlocal dropped, busy_since
try:
vr.pixels(handle, data, w, h)
except OverlayBusy:
if not video:
return False # stills and the surround just wait; nothing is lost
dropped += 1
busy_since = busy_since or time.monotonic()
if time.monotonic() - busy_since > BUSY_LIMIT:
raise RuntimeError('SteamVR stopped accepting frames for %d s' % BUSY_LIMIT)
return False
if video:
busy_since = None
drain()
return True
def drain():
"""Re-send anything that arrived during standby (e.g. the theatre surround)."""
while pending:
item = pending.pop(0)
try:
vr.pixels(*item)
except OverlayBusy:
pending.insert(0, item)
return
def hold(handle, data, w, h):
"""Keep a still (photo or splat) up until Stop, retrying through standby."""
shown = False
while True:
if shown:
drain()
else:
shown = show(handle, data, w, h)
time.sleep(1)
# systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds
# ownership; no unrelated Steam/SteamVR process or setting is touched.
def stop(signum, frame):
@@ -146,14 +205,13 @@ def play(args):
if args.theatre:
surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
vr.call('SetOverlayAlpha', surround, .85)
vr.pixels(surround, b'\x00\x00\x00\xff', 1, 1)
if not show(surround, b'\x00\x00\x00\xff', 1, 1):
pending.append((surround, b'\x00\x00\x00\xff', 1, 1))
screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2,
plan['layout'] != 'mono', aspect)
if splat:
vr.pixels(screen, data, width, height)
write_status(status, state='playing', file=path.name, frames=1, **plan)
while True:
time.sleep(1)
hold(screen, data, width, height)
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
video_start = time.monotonic()
while True:
@@ -163,11 +221,14 @@ def play(args):
data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
if not photo:
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
vr.pixels(screen, data, outw, outh)
if photo:
still = data, outw, outh
else:
show(screen, data, outw, outh, video=True)
frames += 1
if frames == 1 or frames % 30 == 0:
write_status(status, state='playing', file=path.name, frames=frames,
seconds=time.monotonic()-started, **plan)
dropped=dropped, seconds=time.monotonic()-started, **plan)
if not photo:
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
rc = proc.wait(timeout=10)
@@ -176,12 +237,17 @@ def play(args):
if not frames:
raise RuntimeError('Decoder produced no frames')
if photo:
while True:
time.sleep(1)
write_status(status, state='ended', frames=frames, seconds=time.monotonic()-started)
hold(screen, *still)
# From here on a Stop can't change the outcome; don't let it turn
# 'ended' into an error while we write status and clean up.
ignore_signals()
write_status(status, state='ended', frames=frames, dropped=dropped,
seconds=time.monotonic()-started)
except InterruptedError:
write_status(status, state='stopped', frames=frames)
ignore_signals()
write_status(status, state='stopped', frames=frames, dropped=dropped)
finally:
ignore_signals()
if proc:
if proc.poll() is None:
proc.terminate()
+1 -1
View File
@@ -88,7 +88,7 @@ def run(body):
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
'python3', str(RUNTIME/'frame_media_player.py'), str(path),
'--layout', plan['layout'], '--status', str(STATUS)]
'--layout', body.get('layout', 'auto'), '--status', str(STATUS)]
if body.get('theatre'):
command.append('--theatre')
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
+310
View File
@@ -0,0 +1,310 @@
"""Which network this computer is on, and whether Tailscale is up.
Frame Control remembers which of a headset's addresses worked on which network,
so it needs a stable name for "this network". The Wi-Fi name (SSID) is the
friendly one, but macOS 14+ hides it from apps without Location permission, and
wired networks have none. So every network is identified by a fingerprint of
its default gateway: the router's IP and MAC address, which stay the same for a
given home or office network. The user can give a fingerprint a name.
Runs on this computer (macOS, Linux, Windows). Python stdlib only; every probe
is a short command with a timeout, and each parser has fixtures in
tests/test_network.py.
"""
import hashlib
import ipaddress
import json
import os
import re
import socket
import subprocess
import time
import frame_host
TIMEOUT = 3
def run(argv, timeout=TIMEOUT):
"""A command's stdout, or "" if it's missing, fails or takes too long."""
try:
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, timeout=timeout,
**({"creationflags": subprocess.CREATE_NO_WINDOW} if frame_host.WINDOWS else {}))
except (OSError, subprocess.TimeoutExpired):
return ""
return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else ""
def valid_ip(text):
try:
ipaddress.ip_address(text)
return True
except ValueError:
return False
def norm_mac(text):
""""b4:fb:e4:1:87:3f" or "B4-FB-E4-01-87-3F" -> "b4:fb:e4:01:87:3f"; None if it isn't a MAC."""
parts = re.split(r"[:-]", (text or "").strip())
if len(parts) != 6 or not all(re.fullmatch(r"[0-9A-Fa-f]{1,2}", p) for p in parts):
return None
mac = ":".join(p.lower().zfill(2) for p in parts)
return None if mac in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff") else mac
# ---- default gateway -------------------------------------------------------
def parse_route_macos(text):
"""`route -n get default` -> (gateway, interface)."""
gw = re.search(r"^\s*gateway:\s*(\S+)", text, re.M)
iface = re.search(r"^\s*interface:\s*(\S+)", text, re.M)
gateway = gw.group(1) if gw and valid_ip(gw.group(1)) else None
return gateway, iface.group(1) if iface else None
def parse_route_linux(text):
"""`ip -4 route show default` -> (gateway, interface) of the lowest-metric route."""
best = None
for line in text.splitlines():
m = re.search(r"^default via (\S+) dev (\S+)", line.strip())
if not m or not valid_ip(m.group(1)):
continue
metric = re.search(r"\bmetric (\d+)", line)
key = int(metric.group(1)) if metric else 0
if best is None or key < best[0]:
best = (key, m.group(1), m.group(2))
return (best[1], best[2]) if best else (None, None)
def parse_route_windows(text):
"""`route print -4 0.0.0.0` -> (gateway, local IP of the interface), lowest metric wins."""
best = None
for line in text.splitlines():
f = line.split()
if len(f) == 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and valid_ip(f[2]) and f[4].isdigit():
if best is None or int(f[4]) < best[0]:
best = (int(f[4]), f[2], f[3])
return (best[1], best[2]) if best else (None, None)
# ---- the gateway's MAC address ----------------------------------------------
def parse_arp_macos(text, ip):
"""`arp -n IP` -> MAC ("? (192.168.1.1) at b4:fb:e4:b5:67:55 on en0 ifscope [ethernet]")."""
m = re.search(r"\(" + re.escape(ip) + r"\) at (\S+)", text)
return norm_mac(m.group(1)) if m else None
def parse_neigh_linux(text, ip):
"""`ip neigh show IP` -> MAC ("192.168.1.1 dev wlan0 lladdr b4:fb:... REACHABLE")."""
for line in text.splitlines():
f = line.split()
if f and f[0] == ip and "lladdr" in f:
return norm_mac(f[f.index("lladdr") + 1]) if f.index("lladdr") + 1 < len(f) else None
return None
def parse_arp_windows(text, ip):
"""`arp -a IP` -> MAC (" 192.168.1.1 b4-fb-e4-b5-67-55 dynamic")."""
for line in text.splitlines():
f = line.split()
if len(f) >= 2 and f[0] == ip:
return norm_mac(f[1])
return None
# ---- Wi-Fi name --------------------------------------------------------------
def parse_summary_macos(text):
"""`ipconfig getsummary IFACE` -> (ssid, is_wifi). macOS prints "<redacted>" without Location permission."""
kind = re.search(r"^\s*InterfaceType\s*:\s*(\S+)", text, re.M)
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
name = ssid.group(1) if ssid else None
if name in ("<redacted>", ""):
name = None
return name, (kind.group(1).lower() == "wifi") if kind else None
def parse_nmcli(text):
"""`nmcli -t -f active,ssid dev wifi` -> the active SSID (colons in names come escaped as \\:)."""
for line in text.splitlines():
if line.startswith("yes:"):
return line[4:].replace("\\:", ":") or None
return None
def parse_netsh(text):
"""`netsh wlan show interfaces` -> the connected SSID (not the BSSID line)."""
state = re.search(r"^\s*State\s*:\s*(\S+)", text, re.M)
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
if not ssid or (state and state.group(1).lower() != "connected"):
return None
return ssid.group(1)
# ---- Tailscale -----------------------------------------------------------------
def tailscale_cli():
extra = []
if frame_host.MAC:
extra.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
elif frame_host.WINDOWS:
for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")):
if base:
extra.append(os.path.join(base, "Tailscale", "tailscale.exe"))
return frame_host.which("tailscale", *extra)
def parse_tailscale(text):
"""`tailscale status --json` -> {"up", "ip", "name", "tailnet", "peers": [...]}.
Each peer: {"name", "dns" (MagicDNS name, no trailing dot), "ips", "os", "online"}.
"""
try:
data = json.loads(text)
except ValueError:
return {"up": False, "peers": []}
if not isinstance(data, dict):
return {"up": False, "peers": []}
me = data.get("Self") or {}
tailnet = (data.get("CurrentTailnet") or {}).get("Name") if isinstance(data.get("CurrentTailnet"), dict) else None
out = {"up": data.get("BackendState") == "Running",
"ip": next((ip for ip in me.get("TailscaleIPs") or [] if "." in ip), None),
"name": (me.get("DNSName") or "").rstrip(".") or None,
"tailnet": tailnet, "peers": []}
for p in (data.get("Peer") or {}).values():
if not isinstance(p, dict):
continue
out["peers"].append({"name": p.get("HostName") or "", "dns": (p.get("DNSName") or "").rstrip("."),
"ips": [ip for ip in p.get("TailscaleIPs") or [] if isinstance(ip, str)],
"os": p.get("OS") or "", "online": bool(p.get("Online"))})
return out
def tailscale_status():
cli = tailscale_cli()
if not cli:
return {"up": False, "installed": False, "peers": []}
out = parse_tailscale(run([cli, "status", "--json"], timeout=4) or "{}")
out["installed"] = True
return out
TAILNET_V4 = ipaddress.ip_network("100.64.0.0/10")
TAILNET_V6 = ipaddress.ip_network("fd7a:115c:a1e0::/48")
def is_tailscale(host):
host = host.lower().rstrip(".")
if host.endswith(".ts.net"):
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
return ip in (TAILNET_V4 if ip.version == 4 else TAILNET_V6)
def guess_kind(host):
"""What sort of address a host is: mdns, tailscale, lan or manual."""
h = host.lower().rstrip(".")
if h.endswith(".local"):
return "mdns"
if is_tailscale(h):
return "tailscale"
try:
ip = ipaddress.ip_address(h.split("%")[0])
if ip.is_private or ip.is_link_local:
return "lan"
except ValueError:
pass
return "manual"
# ---- putting it together ----------------------------------------------------------
def network_id(gateway, mac):
"""A short, stable id for a network: its gateway's IP and MAC. None until both are known."""
if not gateway or not mac:
return None
return "n-" + hashlib.sha1(f"{gateway}|{mac}".encode()).hexdigest()[:10]
def local_ip(towards="192.0.2.1"):
"""This computer's address on the default route (UDP connect sends nothing)."""
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect((towards, 9))
return s.getsockname()[0]
except OSError:
return None
def gateway():
"""(gateway IP, interface) of the default route."""
if frame_host.MAC:
return parse_route_macos(run(["route", "-n", "get", "default"]))
if frame_host.WINDOWS:
return parse_route_windows(run(["route", "print", "-4", "0.0.0.0"]))
return parse_route_linux(run(["ip", "-4", "route", "show", "default"]))
def gateway_mac(ip):
if frame_host.MAC:
return parse_arp_macos(run(["arp", "-n", ip]), ip)
if frame_host.WINDOWS:
return parse_arp_windows(run(["arp", "-a", ip]), ip)
return parse_neigh_linux(run(["ip", "neigh", "show", ip]), ip)
def poke(ip):
"""Make the system look up the gateway's MAC (an ARP entry can expire)."""
try:
with socket.create_connection((ip, 53), timeout=0.3):
pass
except OSError:
pass
def wifi(interface):
"""(ssid or None, is_wifi or None) for the default route's interface."""
if frame_host.MAC:
if interface:
ssid, is_wifi = parse_summary_macos(run(["ipconfig", "getsummary", interface]))
if ssid or is_wifi is False:
return ssid, is_wifi
m = re.search(r"Current Wi-Fi Network: (.+)", run(["networksetup", "-getairportnetwork", interface]))
return (m.group(1).strip() if m else None), is_wifi
return None, None
if frame_host.WINDOWS:
ssid = parse_netsh(run(["netsh", "wlan", "show", "interfaces"]))
return ssid, True if ssid else None
if frame_host.which("nmcli"):
ssid = parse_nmcli(run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"]))
else:
ssid = run(["iwgetid", "-r"]).strip() or None
return ssid, True if ssid else (interface.startswith(("wl", "wlan")) if interface else None)
def fingerprint():
"""The cheap part, polled every few seconds: (gateway, interface, gateway MAC)."""
gw, iface = gateway()
mac = None
if gw:
mac = gateway_mac(gw)
if not mac:
poke(gw)
mac = gateway_mac(gw)
return gw, iface, mac
def current_network(fp=None, with_tailscale=True):
"""Everything the connection status shows about this computer's network."""
gw, iface, mac = fp or fingerprint()
ssid, is_wifi = wifi(iface) if gw else (None, None)
net = {"id": network_id(gw, mac), "gateway": gw, "gateway_mac": mac, "interface": iface,
"ssid": ssid, "wifi": is_wifi, "local_ip": local_ip(gw) if gw else None, "checked": time.time()}
if with_tailscale:
ts = tailscale_status()
net["tailscale"] = {k: ts.get(k) for k in ("up", "installed", "ip", "name", "tailnet")}
return net
+110 -11
View File
@@ -6,6 +6,10 @@ project as a `problem_report` event: only the maintainer can read it, and
nothing is published. It is sent whatever the analytics settings are, because
the person sends it deliberately. Diagnostics are scrubbed first
(frame_telemetry.scrub); the person's own words are sent as written.
An email address goes with a report only when the person ticks "may contact me with
follow-up questions" (contact_followup). Standing choices made in Settings are
frame_contact.py's `contact_consent` events; `contacts` lists them.
"""
import os
import platform
@@ -13,6 +17,7 @@ import sys
import time
import uuid
import frame_contact
import frame_host
import frame_telemetry
@@ -107,9 +112,18 @@ def send(body):
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
title, text, diag = compose(body)
followup = frame_contact.flag(body, 'contactFollowup')
contact = str(body.get('contact') or '').strip() if followup else ''
if followup and not frame_contact.valid_email(contact):
raise ValueError('add your email address for follow-up questions, or untick that box')
started = time.time() # a removal from now on (even while saving the address) is redacted from the log
# It becomes the contact email in Settings, where it's changed or removed like any other.
contact_id, contact_rev = frame_contact.from_report(contact) if followup else ('', 0)
ref = uuid.uuid4().hex[:8].upper()
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag,
'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
# Only with an address: a later change from this copy (higher rev) can take it back.
'contact_id': contact_id, 'contact_rev': contact_rev,
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
# Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
@@ -119,7 +133,9 @@ def send(body):
except frame_telemetry.SendError as e:
raise ReportError(str(e))
try:
frame_telemetry.record_sent([event])
with frame_telemetry._lock: # the lock a removal holds while wiping its address
frame_contact.redact_removed(event, started)
frame_telemetry.record_sent([event])
except OSError:
pass # it was sent; failing to log it here mustn't make the person send it again
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
@@ -131,26 +147,109 @@ class ReportError(RuntimeError):
def inbox(days=30):
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
frame_compat_db.sync uses)."""
frame_compat_db.sync uses). Column 10 is whether the person may be asked follow-up
questions now: 'withdrawn' when a later choice from the same copy took it back."""
import frame_compat_db
days = int(days)
res = frame_compat_db._posthog_query(
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics "
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
"properties.contact_followup, properties.contact_id, properties.contact_rev "
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {days} DAY "
"ORDER BY timestamp DESC LIMIT 200")
return res.get('results') or []
rows = [r for r in res.get('results') or [] if isinstance(r, list) and len(r) == 13]
if any(r[11] and _yes(r[10]) for r in rows):
later = frame_compat_db._posthog_query(
"SELECT distinct_id, properties.email, properties.followup, ifNull(toInt(properties.rev), 0) "
"FROM events WHERE event = 'contact_consent' LIMIT 100000")
mark_withdrawn(rows, later.get('results') or [])
return rows
def mark_withdrawn(reports, consents):
"""Mark reports whose follow-up permission was taken back: the newest contact choice from
the same copy made after the report (a higher rev than it carries, not a later clock) no
longer agrees to follow-up questions at that address."""
newest = {}
for c in consents:
if not isinstance(c, list) or len(c) != 4:
continue
cid, email, followup, rev = c
try:
rev = int(rev or 0)
except (TypeError, ValueError):
continue
if rev > newest.get(str(cid), (-1,))[0]:
newest[str(cid)] = (rev, str(email or ''), followup)
for r in reports:
if not (r[11] and _yes(r[10])):
continue
try:
sent_at = int(r[12] or 0)
except (TypeError, ValueError):
sent_at = 0
rev, email, followup = newest.get(str(r[11]), (-1, '', None))
if rev > sent_at and not (_yes(followup) and email.strip().lower() == str(r[5] or '').strip().lower()):
r[10] = 'withdrawn'
def _yes(v):
return v is True or str(v).lower() in ('true', '1')
def contacts():
"""{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest
contact_consent event agrees to each, oldest first. A withdrawal, or a change to another
address, replaces what came before, so withdrawn addresses are never listed. "Newest" is
the highest rev from that copy (then time), so every field comes from the same event
whatever order they arrived in or what the clocks said."""
import frame_compat_db
newest = "tuple(ifNull(toInt(properties.rev), 0), timestamp)"
res = frame_compat_db._posthog_query(
f"SELECT distinct_id, argMax(properties.email, {newest}), argMax(properties.updates, {newest}), "
f"argMax(properties.followup, {newest}), argMax(timestamp, {newest}) FROM events "
"WHERE event = 'contact_consent' GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000")
out = {'updates': [], 'followup': []}
for row in res.get('results') or []:
if not isinstance(row, list) or len(row) != 5:
continue
_, email, updates, followup, ts = row
email = str(email or '').strip()
if not frame_contact.valid_email(email):
continue
for kind, agreed in (('updates', updates), ('followup', followup)):
if _yes(agreed):
out[kind].append((email, str(ts or '')[:10]))
return out
USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]'
def main():
cmd, *args = sys.argv[1:] or ['inbox']
if cmd == 'contacts':
kinds = args[:1] or ['updates', 'followup']
if not set(kinds) <= {'updates', 'followup'}:
sys.exit(USAGE)
found = contacts()
for kind in kinds:
print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}"
f" ({len(found[kind])})")
for email, since in found[kind]:
print(f" {email} (since {since})")
print()
return
if cmd != 'inbox':
sys.exit('usage: frame_report.py inbox [days]')
sys.exit(USAGE)
for row in inbox(*(args[:1] or [30])):
if not isinstance(row, list) or len(row) != 10:
continue
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
# Reports from before contact_followup existed only carried an address given for a reply.
reply = contact and (row[10] is None or _yes(row[10]))
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
f"{', may follow up at ' + contact if reply else ''}"
f"{', follow-up permission since withdrawn' if row[10] == 'withdrawn' else ''}")
print(' ' + text.replace('\n', '\n '))
if diag:
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
+952 -50
View File
File diff suppressed because it is too large. Load diff
+299 -63
View File
@@ -3,7 +3,8 @@
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
up by scripts/connect.sh or ui/frame_connect.py.
up by scripts/connect.sh or ui/frame_connect.py, or another headset picked on the
Devices tab: frame_link.py finds it at one of its addresses (frame_devices.py).
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
Env: FRAME_ALIAS (default frame)
@@ -14,6 +15,7 @@ Env: FRAME_ALIAS (default frame)
"""
import argparse
import base64
import contextlib
import hashlib
import http.client
import json
@@ -45,9 +47,12 @@ import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
import frame_steamgriddb
import frame_comfort # noqa: E402
import frame_contact # noqa: E402
import frame_host # noqa: E402
import frame_link # noqa: E402
import frame_macview # noqa: E402
import frame_media # noqa: E402
import frame_panels # noqa: E402
@@ -74,18 +79,93 @@ DEVICE = os.environ.get("FRAME_DEVICE") or "phone"
# What the Frame's KDE Connect calls this device (keyboard and trackpad).
INPUT_NAME = DEVICE if LOCAL else socket.gethostname().split(".")[0]
FRAME = os.environ.get("FRAME_ALIAS", "frame")
FRAME_FROM_ENV = "FRAME_ALIAS" in os.environ
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
# supports it (not on Windows: there every command connects on its own).
CONTROL = None if LOCAL else frame_host.control_path(private=os.environ.get("FRAME_PRIVATE_SSH") == "1")
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
# A private server (the MCP adapter starts one per session) keeps its own SSH masters, and
# uses the headsets without editing them.
PRIVATE = os.environ.get("FRAME_PRIVATE_SSH") == "1"
CONTROL = None if LOCAL else frame_host.control_path(private=PRIVATE)
# The ControlPath itself is per headset: the connector puts it in HOST_OPTS.
MUX = ["ssh", "-o", "BatchMode=yes"]
MUX_BASE = list(MUX)
# Commands use the master when it's up and connect directly when it isn't.
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
SSH_TAIL = [*(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
SSH = [*MUX, *SSH_TAIL]
# The address the connector picked (-o HostName=... and friends), in every ssh command.
HOST_OPTS = []
# Android helpers share the multiplexed connection when it's up.
frame_android.SSH_OPTS = SSH[1:]
_route_lock = threading.Lock()
def route(alias, host_opts):
"""Point every ssh, scp and rsync at `alias` with `host_opts` (frame_link calls this
when it picks a headset and an address). The lists change in place, so code holding
them follows; frame_titles reads frame_android.SSH_OPTS at call time."""
global FRAME, HOST_OPTS
with _route_lock:
moved = alias != FRAME
if moved:
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
FRAME = frame_android.FRAME = alias
HOST_OPTS = list(host_opts)
MUX[:] = [*MUX_BASE, *HOST_OPTS]
SSH[:] = [*MUX, *SSH_TAIL]
frame_android.SSH_OPTS = SSH[1:]
# Long-lived ssh processes started on the old route (outside the lock: they take their own).
mv = globals().get("macview")
if mv:
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
agent = globals().get("_input")
if moved and agent:
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
LINK = None # the connector (frame_link.Link); None on the Frame itself
# Installs and other changes in progress. Switching headsets waits for them: they
# read the ssh settings step by step, so a switch could send the rest (or a failed
# install's clean-up) to the other headset.
_work_lock = threading.Lock()
_work = [0]
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
@contextlib.contextmanager
def working(meant=None):
"""Counts as running work. `meant`: the headset the page made this change for; if the
app has switched away from it, refuse (checked together with counting, so a switch
can't slip in between)."""
with _work_lock:
if LINK and meant and meant != LINK.active_device()["id"]:
raise Failure("Frame Control switched headsets; try again on this one", 409)
_work[0] += 1
try:
yield
finally:
with _work_lock:
_work[0] -= 1
def busy_thread(fn, *args):
"""A thread that counts as work from before it starts until it ends."""
with _work_lock:
_work[0] += 1
def run():
try:
fn(*args)
finally:
with _work_lock:
_work[0] -= 1
return threading.Thread(target=run, daemon=True)
APPID = re.compile(r"^\d{1,10}$")
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
MAX_UPLOAD = 8 * 1024**3
@@ -228,7 +308,7 @@ def start_job(label, work, progress=False):
with _jobs_lock:
_jobs[job].update(fields, done=True, time=time.time())
threading.Thread(target=run, daemon=True).start()
busy_thread(run).start()
return {"message": f"{label}…", "job": job}
@@ -241,59 +321,59 @@ def job_status(query):
return snapshot
_master_lock = threading.Lock()
_master = None
def ensure_master():
"""Start the shared SSH connection if it isn't up (one attempt at a time).
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
"""
global _master
if not CONTROL:
return
def up():
try:
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
timeout=5).returncode == 0
except subprocess.TimeoutExpired:
return False
with _master_lock:
if up() or (_master and _master.poll() is None):
return
# Keepalives make a dead link (Frame asleep, off Wi-Fi) exit within ~10s,
# so the next request starts a fresh master.
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=2", "-N", FRAME],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
for _ in range(60):
if up() or _master.poll() is not None:
return
time.sleep(0.05)
"""Make sure the connection to the headset is up, or being tried (frame_link.Link.ensure)."""
if LINK:
LINK.ensure()
def ssh(remote, *, stdin=None, timeout=30, text=True):
route_gen = LINK.gen if LINK else None # which headset this command is for
try:
ensure_master()
# Never let ssh inherit our stdin: under the app it's the pipe held open for
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired:
raise Failure(f"Timed out talking to {FRAME}")
if r.returncode != 0:
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
if r.returncode == 255 and repair_ssh_config(err):
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
if r.returncode == 255 and LINK and unreachable(err):
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
raise failure
return r.stdout
_config_repaired = False
def repair_ssh_config(err):
"""Windows' OpenSSH refused ~/.ssh/config for its ACL: give the file a private one,
once per run. -> True if it did, so the command is worth retrying."""
global _config_repaired
if _config_repaired or not frame_host.WINDOWS or frame_host.BAD_PERMISSIONS not in err:
return False
# ssh doubles the backslashes: "C:\\Users\\me/.ssh/config"
named = re.sub(r"[\\/]+", "/", err.split(frame_host.BAD_PERMISSIONS, 1)[1].splitlines()[0].strip())
config = frame_devices.ssh_config()
if not named.lower().endswith("/" + config.name.lower()):
return False # a key or another file: not ours to rewrite
_config_repaired = True
try:
if frame_devices.repair_permissions(config):
print(f"Gave {config} a private ACL: ssh refused it ({named})", file=sys.stderr)
return True
except OSError as e:
print(f"Couldn't repair {config}'s permissions: {e}", file=sys.stderr)
return False
def strip_ansi(s):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
@@ -350,6 +430,12 @@ print(r.stdout, end='')
def headset_view():
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
# Counts as work: the copy and clean-up must reach the headset that took the capture.
with working():
return _headset_view()
def _headset_view():
# `timeout`: VR_Init can block if SteamVR is restarting.
out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30)
# SteamVR prints its own notices (e.g. about vrwebhelper) on stdout too, so
@@ -445,8 +531,8 @@ def save_shots(body):
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
try:
try:
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
@@ -1111,23 +1197,37 @@ def open_thing(body):
if what in ("reboot", "poweroff", "suspend"):
return power(what, body.get("password"))
raise Failure("open that from the app", 400)
# The headset and address in use, as every other command gets them (one snapshot).
with _route_lock:
alias, opts = LINK.named_route() if LINK else (FRAME, list(HOST_OPTS))
host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), None)
if what in ("terminal", "reboot", "poweroff", "suspend", "rdp", "sftp") and host and host.endswith(".invalid"):
raise Failure("No headset address to use: add one on the Devices tab", 400)
try:
if what == "terminal":
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
return {"message": f"Opened an SSH session in {terminal(['ssh', *opts, alias])}"}
if what in ("reboot", "poweroff", "suspend"):
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
where = terminal(["ssh", "-t", *opts, alias, "sudo", "systemctl", what])
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
if what == "steamlink":
return {"message": frame_host.open_steam_link()}
if what == "rdp":
return {"message": frame_host.open_rdp(FRAME)}
return {"message": frame_host.open_rdp(alias, host)}
if what == "sftp":
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
return {"message": f"Opened an SFTP session in {terminal(['sftp', *opts, alias])}"}
if what == "shots":
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
frame_host.open_path(SHOTS_DIR)
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
if what == "shot":
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
if not saved.exists():
raise Failure("That screenshot isn't saved on this computer yet", 404)
frame_host.reveal_path(saved)
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
except frame_host.Unreachable as e:
raise Failure(str(e), 400) # theirs to turn on; nothing failed here
except frame_host.HostError as e:
raise Failure(str(e), 500)
raise Failure("unknown target", 400)
@@ -1257,7 +1357,8 @@ def stage_title(path, temp_dir=None, name=None):
raise
token = secrets.token_hex(12)
with _titles_lock:
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time()}
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time(),
"device": LINK.active_device()["id"] if LINK else None}
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
"token": token, "plan": frame_titles.public(plan)}
@@ -1302,13 +1403,15 @@ def titles(body):
if action == "discard":
_drop_staged(entry)
return {"message": "Discarded"}
if LINK and entry.get("device") != LINK.active_device()["id"]:
_drop_staged(entry) # it was checked against the other headset's titles
raise Failure("Frame Control switched headsets since this was read; drop the file again", 409)
with _titles_lock:
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
"message": None, "title": None, "time": time.time()}
ensure_master()
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
threading.Thread(target=_run_title_install, daemon=True,
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
busy_thread(_run_title_install, token, entry, opt("name"), opt("exe"), opt("runtime")).start()
return {"message": f"Installing {entry['plan']['source']}", "job": token}
if action not in ("launch", "remove", "refresh-art"):
raise Failure("unknown action", 400)
@@ -1421,7 +1524,7 @@ class AdbTunnel:
fwd = [a for p, lp in self.local.items() for a in ("-L", f"127.0.0.1:{lp}:127.0.0.1:{p}")]
# Its own connection (ControlPath=none), so killing it drops the forwards.
self.proc = _proc = subprocess.Popen(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none",
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none", *HOST_OPTS,
"-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
_live_tunnels.add(_proc)
@@ -1696,7 +1799,7 @@ def webinstall_start(body):
_web_jobs.clear()
_web_jobs[pid] = job
# Started under the lock, so shutdown never sees a thread it can't join.
worker = threading.Thread(target=_webinstall_run, args=(plan, job), daemon=True)
worker = busy_thread(_webinstall_run, plan, job)
_web_workers.add(worker)
worker.start()
return {"job": pid}
@@ -2084,7 +2187,56 @@ POST = {
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action}
"/api/contact": frame_contact.save, "/api/contact/prompt": frame_contact.prompt,
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
"/api/devices": lambda body: devices_post(body)}
# ---- headsets and the connection (frame_devices.py, frame_link.py) ----------
def open_setup(alias, host=None):
"""Set Up Connection for `alias` in a terminal window, as the app's menu does."""
if frame_host.MAC:
argv = ["env", f"FRAME_ALIAS={alias}", "zsh", str(HERE.parent / "scripts" / "connect.sh")]
else:
argv = [sys.executable, str(HERE / "frame_connect.py"), "--alias", alias]
return terminal(argv + ([host] if host else []))
def devices_post(body):
if not LINK:
raise Failure("Headsets are managed from the computer app", 400)
if PRIVATE:
raise Failure("Headsets are managed in the Frame Control app", 403)
try:
# Under the work lock: nothing can start on the old headset while it switches.
with _work_lock:
return frame_link.devices_action(LINK, body, open_setup, lambda: _work[0])
except frame_devices.DeviceError as e:
raise Failure(str(e), 400)
def devices_get(path, query):
if not LINK:
raise Failure("Headsets are managed from the computer app", 400)
q = parse_qs(query)
device = (q.get("id") or [None])[0]
try:
if path == "/api/devices":
return dict(frame_link.devices_view(LINK), nextAlias=frame_link.next_alias(LINK))
if path == "/api/devices/tailscale":
return frame_link.tailscale_find(LINK, device)
return frame_link.mdns_find(LINK, device)
except frame_devices.DeviceError as e:
raise Failure(str(e), 400)
def connection_state():
if not LINK: # on the Frame itself there's nothing to find
return {"local": True, "phase": "connected", "version": 0}
return LINK.snapshot()
# ---- HTTP ------------------------------------------------------------------
@@ -2131,7 +2283,7 @@ def push_file(path, dest="Downloads/"):
else:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
except subprocess.TimeoutExpired:
raise Failure(f"Copying {name} timed out")
if r.returncode != 0:
@@ -2139,6 +2291,11 @@ def push_file(path, dest="Downloads/"):
return f"Sent {name} to ~/{dest}"
class ClientGone(Exception):
"""The page went away (a reload, the app quitting) before its reply was written:
nobody to answer, and nothing went wrong here."""
class Handler(BaseHTTPRequestHandler):
server_version = "FrameControl/1"
timeout = 60 # per socket operation, so a stalled client can't hold a thread
@@ -2171,8 +2328,11 @@ class Handler(BaseHTTPRequestHandler):
# Nobody may frame the UI (clickjacking).
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
self.end_headers()
self.wfile.write(data)
try:
self.end_headers()
self.wfile.write(data)
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
raise ClientGone() from e
def send_json(self, obj, status=200):
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
@@ -2205,10 +2365,18 @@ class Handler(BaseHTTPRequestHandler):
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
"computer": "Mac" if frame_host.MAC else "PC"})
elif path == "/api/connection":
self.send_json(connection_state())
elif path == "/api/connection/events":
self.connection_events()
elif path in ("/api/devices", "/api/devices/tailscale", "/api/devices/mdns"):
self.send_json(devices_get(path, url.query))
elif path.startswith("/source-image/"):
from apk_sources import _images
try:
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
except ClientGone:
raise
except Exception:
self.send_json({"error": "Artwork unavailable"}, 404)
elif path == "/api/sources/details":
@@ -2259,6 +2427,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(macview_state(parse_qs(url.query)))
elif path == "/api/telemetry":
self.send_json(frame_telemetry.state())
elif path == "/api/contact":
self.send_json(frame_contact.state())
elif path == "/api/computer/state":
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
elif path == "/api/status":
@@ -2284,6 +2454,8 @@ class Handler(BaseHTTPRequestHandler):
headers=[("X-Capture-Source", "gamescope")])
else:
self.send_json({"error": "not found"}, 404)
except ClientGone:
raise
except Failure as e:
self.send_error_json(str(e), e.status, e.apk)
except ValueError as e:
@@ -2298,10 +2470,12 @@ class Handler(BaseHTTPRequestHandler):
if not self.local_request():
return
path = urlparse(self.path).path
meant = self.headers.get("X-Frame-Device")
body = None
try:
if path == "/api/upload":
self.send_json(self.upload())
with working(meant):
self.send_json(self.upload())
return
handler = POST.get(path)
if not handler:
@@ -2313,7 +2487,12 @@ class Handler(BaseHTTPRequestHandler):
body = json.loads(self.rfile.read(length) or b"{}")
if not isinstance(body, dict):
raise Failure("request body must be a JSON object", 400)
self.send_json(handler(body))
# Not headset work: switching headsets mustn't wait for (or refuse) these.
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
result = handler(body)
self.send_json(result)
except ClientGone:
raise
except Failure as e:
if e.status >= 500:
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
@@ -2327,6 +2506,30 @@ class Handler(BaseHTTPRequestHandler):
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
def connection_events(self):
"""Server-sent events: the connection state each time it changes, until the page goes.
(The page reads it with fetch, which can send the X-Frame-UI header; EventSource can't.)"""
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Frame-Options", "DENY")
self.end_headers()
self.close_connection = True
version = -1
try:
while True:
snap = connection_state() if version < 0 else (LINK.wait(version, 15) if LINK else None)
if snap is None:
if not LINK and version >= 0:
time.sleep(15)
self.wfile.write(b": still here\n\n") # keeps proxies and the page's watchdog happy
else:
version = max(snap["version"], 0)
self.wfile.write(b"data: " + json.dumps(snap).encode() + b"\n\n")
self.wfile.flush()
except OSError:
pass # the page went away
def stream_video(self, query):
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
global _stream_proc
@@ -2465,6 +2668,28 @@ class LoopbackServer(ThreadingHTTPServer):
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
def handle_error(self, request, client_address):
if not isinstance(sys.exc_info()[1], ClientGone):
super().handle_error(request, client_address)
_ONE_SERVER = None
def one_server():
"""Only one Frame Control server per user: two would each connect, reconnect and
edit the headsets on their own, and could move each other's installs to another
headset. Held until this process exits. (FRAME_CONTROL_DATA_DIR gives a second,
separate one, as the tests do. A private server, the MCP adapter's, runs alongside:
it can't add, remove or switch headsets.)"""
lock = frame_devices.file_lock(frame_host.data_dir("server.lock"), timeout=float(os.environ.get("FRAME_CONTROL_SERVER_WAIT") or 20)) # while the app restarts it
try:
lock.__enter__()
except OSError:
sys.exit("Frame Control is already running on this computer (the app, or a server started "
"from a terminal). Quit it, then try again.")
return lock
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
@@ -2477,16 +2702,29 @@ def main():
sweep_tmp()
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
frame_telemetry.start()
frame_contact.start()
global LINK, _ONE_SERVER
if not LOCAL:
if not PRIVATE: # a private server only uses the headsets (see one_server)
_ONE_SERVER = one_server()
LINK = frame_link.Link(frame_devices.Registry(), env_alias=FRAME if FRAME_FROM_ENV else None,
mux_base=MUX_BASE, control=CONTROL, apply=route, explain=unreachable)
LINK.work_lock, LINK.work = _work_lock, lambda: _work[0]
LINK.start()
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof:
def watch_stdin():
sys.stdin.buffer.read()
# os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock,
# and if a signal stops the server first, Python aborts (SIGABRT) at exit
# when it can't take that lock back from this thread.
while os.read(0, 4096):
pass
threading.Thread(target=httpd.shutdown, daemon=True).start()
threading.Thread(target=watch_stdin, daemon=True).start()
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
try:
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
httpd.serve_forever()
except KeyboardInterrupt:
pass
@@ -2498,10 +2736,8 @@ def main():
webinstall_shutdown()
macview.shutdown() # close the headset's viewers before the agent goes
# The master was started with -N, so it stays up until told to exit.
if CONTROL:
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
if _master and _master.poll() is None:
_master.terminate()
if LINK:
LINK.stop()
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
if proc.poll() is None:
proc.terminate()